diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md new file mode 100644 index 0000000000..de6b58f26d --- /dev/null +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -0,0 +1,79 @@ +--- +name: e2e-cucumber-playwright +description: Write, update, or review Dify end-to-end tests under `e2e/` that use Cucumber, Gherkin, and Playwright. Use when the task involves `.feature` files, `features/step-definitions/`, `features/support/`, `DifyWorld`, scenario tags, locator/assertion choices, or E2E testing best practices for this repository. +--- + +# Dify E2E Cucumber + Playwright + +Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) as the canonical guide for local architecture and conventions, then apply Playwright/Cucumber best practices only where they fit the current suite. + +## Scope + +- Use this skill for `.feature` files, Cucumber step definitions, `DifyWorld`, hooks, tags, and E2E review work under `e2e/`. +- Do not use this skill for Vitest or React Testing Library work under `web/`; use `frontend-testing` instead. +- Do not use this skill for backend test or API review tasks under `api/`. + +## Read Order + +1. Read [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) first. +2. Read only the files directly involved in the task: + - target `.feature` files under `e2e/features/` + - related step files under `e2e/features/step-definitions/` + - `e2e/features/support/hooks.ts` and `e2e/features/support/world.ts` when session lifecycle or shared state matters + - `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter +3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved. +4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved. +5. Re-check official docs with Context7 before introducing a new Playwright or Cucumber pattern. + +## Local Rules + +- `e2e/` uses Cucumber for scenarios and Playwright as the browser layer. +- `DifyWorld` is the per-scenario context object. Type `this` as `DifyWorld` and use `async function`, not arrow functions. +- Keep glue organized by capability under `e2e/features/step-definitions/`; use `common/` only for broadly reusable steps. +- Browser session behavior comes from `features/support/hooks.ts`: + - default: authenticated session with shared storage state + - `@unauthenticated`: clean browser context + - `@authenticated`: readability/selective-run tag only unless implementation changes + - `@fresh`: only for `e2e:full*` flows +- Do not import Playwright Test runner patterns that bypass the current Cucumber + `DifyWorld` architecture unless the task is explicitly about changing that architecture. + +## Workflow + +1. Rebuild local context. + - Inspect the target feature area. + - Reuse an existing step when wording and behavior already match. + - Add a new step only for a genuinely new user action or assertion. + - Keep edits close to the current capability folder unless the step is broadly reusable. +2. Write behavior-first scenarios. + - Describe user-observable behavior, not DOM mechanics. + - Keep each scenario focused on one workflow or outcome. + - Keep scenarios independent and re-runnable. +3. Write step definitions in the local style. + - Keep one step to one user-visible action or one assertion. + - Prefer Cucumber Expressions such as `{string}` and `{int}`. + - Scope locators to stable containers when the page has repeated elements. + - Avoid page-object layers or extra helper abstractions unless repeated complexity clearly justifies them. +4. Use Playwright in the local style. + - Prefer user-facing locators: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then `getByTestId` for explicit contracts. + - Use web-first `expect(...)` assertions. + - Do not use `waitForTimeout`, manual polling, or raw visibility checks when a locator action or retrying assertion already expresses the behavior. +5. Validate narrowly. + - Run the narrowest tagged scenario or flow that exercises the change. + - Run `pnpm -C e2e check`. + - Broaden verification only when the change affects hooks, tags, setup, or shared step semantics. + +## Review Checklist + +- Does the scenario describe behavior rather than implementation? +- Does it fit the current session model, tags, and `DifyWorld` usage? +- Should an existing step be reused instead of adding a new one? +- Are locators user-facing and assertions web-first? +- Does the change introduce hidden coupling across scenarios, tags, or instance state? +- Does it document or implement behavior that differs from the real hooks or configuration? + +Lead findings with correctness, flake risk, and architecture drift. + +## References + +- [`references/playwright-best-practices.md`](references/playwright-best-practices.md) +- [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) diff --git a/.agents/skills/e2e-cucumber-playwright/agents/openai.yaml b/.agents/skills/e2e-cucumber-playwright/agents/openai.yaml new file mode 100644 index 0000000000..605cce041d --- /dev/null +++ b/.agents/skills/e2e-cucumber-playwright/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "E2E Cucumber + Playwright" + short_description: "Write and review Dify E2E scenarios." + default_prompt: "Use $e2e-cucumber-playwright to write or review a Dify E2E scenario under e2e/." diff --git a/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md b/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md new file mode 100644 index 0000000000..d7a1a52852 --- /dev/null +++ b/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md @@ -0,0 +1,93 @@ +# Cucumber Best Practices For Dify E2E + +Use this reference when writing or reviewing Gherkin scenarios, step definitions, parameter expressions, and step reuse in Dify's `e2e/` suite. + +Official sources: + +- https://cucumber.io/docs/guides/10-minute-tutorial/ +- https://cucumber.io/docs/cucumber/step-definitions/ +- https://cucumber.io/docs/cucumber/cucumber-expressions/ + +## What Matters Most + +### 1. Treat scenarios as executable specifications + +Cucumber scenarios should describe examples of behavior, not test implementation recipes. + +Apply it like this: + +- write what the user does and what should happen +- avoid UI-internal wording such as selector details, DOM structure, or component names +- keep language concrete enough that the scenario reads like living documentation + +### 2. Keep scenarios focused + +A scenario should usually prove one workflow or business outcome. If a scenario wanders across several unrelated behaviors, split it. + +In Dify's suite, this means: + +- one capability-focused scenario per feature path +- no long setup chains when existing bootstrap or reusable steps already cover them +- no hidden dependency on another scenario's side effects + +### 3. Reuse steps, but only when behavior really matches + +Good reuse reduces duplication. Bad reuse hides meaning. + +Prefer reuse when: + +- the user action is genuinely the same +- the expected outcome is genuinely the same +- the wording stays natural across features + +Write a new step when: + +- the behavior is materially different +- reusing the old wording would make the scenario misleading +- a supposedly generic step would become an implementation-detail wrapper + +### 4. Prefer Cucumber Expressions + +Use Cucumber Expressions for parameters unless regex is clearly necessary. + +Common examples: + +- `{string}` for labels, names, and visible text +- `{int}` for counts +- `{float}` for decimal values +- `{word}` only when the value is truly a single token + +Keep expressions readable. If a step needs complicated parsing logic, first ask whether the scenario wording should be simpler. + +### 5. Keep step definitions thin and meaningful + +Step definitions are glue between Gherkin and automation, not a second abstraction language. + +For Dify: + +- type `this` as `DifyWorld` +- use `async function` +- keep each step to one user-visible action or assertion +- rely on `DifyWorld` and existing support code for shared context +- avoid leaking cross-scenario state + +### 6. Use tags intentionally + +Tags should communicate run scope or session semantics, not become ad hoc metadata. + +In Dify's current suite: + +- capability tags group related scenarios +- `@unauthenticated` changes session behavior +- `@authenticated` is descriptive/selective, not a behavior switch by itself +- `@fresh` belongs to reset/full-install flows only + +If a proposed tag implies behavior, verify that hooks or runner configuration actually implement it. + +## Review Questions + +- Does the scenario read like a real example of product behavior? +- Are the steps behavior-oriented instead of implementation-oriented? +- Is a reused step still truthful in this feature? +- Is a new tag documenting real behavior, or inventing semantics that the suite does not implement? +- Would a new reader understand the outcome without opening the step-definition file? diff --git a/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md b/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md new file mode 100644 index 0000000000..02e763d46b --- /dev/null +++ b/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md @@ -0,0 +1,96 @@ +# Playwright Best Practices For Dify E2E + +Use this reference when writing or reviewing locator, assertion, isolation, or synchronization logic for Dify's Cucumber-based E2E suite. + +Official sources: + +- https://playwright.dev/docs/best-practices +- https://playwright.dev/docs/locators +- https://playwright.dev/docs/test-assertions +- https://playwright.dev/docs/browser-contexts + +## What Matters Most + +### 1. Keep scenarios isolated + +Playwright's model is built around clean browser contexts so one test does not leak into another. In Dify's suite, that principle maps to per-scenario session setup in `features/support/hooks.ts` and `DifyWorld`. + +Apply it like this: + +- do not depend on another scenario having run first +- do not persist ad hoc scenario state outside `DifyWorld` +- do not couple ordinary scenarios to `@fresh` behavior +- when a flow needs special auth/session semantics, express that through the existing tag model or explicit hook changes + +### 2. Prefer user-facing locators + +Playwright recommends built-in locators that reflect what users perceive on the page. + +Preferred order in this repository: + +1. `getByRole` +2. `getByLabel` +3. `getByPlaceholder` +4. `getByText` +5. `getByTestId` when an explicit test contract is the most stable option + +Avoid raw CSS/XPath selectors unless no stable user-facing contract exists and adding one is not practical. + +Also remember: + +- repeated content usually needs scoping to a stable container +- exact text matching is often too brittle when role/name or label already exists +- `getByTestId` is acceptable when semantics are weak but the contract is intentional + +### 3. Use web-first assertions + +Playwright assertions auto-wait and retry. Prefer them over manual state inspection. + +Prefer: + +- `await expect(page).toHaveURL(...)` +- `await expect(locator).toBeVisible()` +- `await expect(locator).toBeHidden()` +- `await expect(locator).toBeEnabled()` +- `await expect(locator).toHaveText(...)` + +Avoid: + +- `expect(await locator.isVisible()).toBe(true)` +- custom polling loops for DOM state +- `waitForTimeout` as synchronization + +If a condition genuinely needs custom retry logic, use Playwright's polling/assertion tools deliberately and keep that choice local and explicit. + +### 4. Let actions wait for actionability + +Locator actions already wait for the element to be actionable. Do not preface every click/fill with extra timing logic unless the action needs a specific visible/ready assertion for clarity. + +Good pattern: + +- assert a meaningful visible state when that is part of the behavior +- then click/fill/select via locator APIs + +Bad pattern: + +- stack arbitrary waits before every action +- wait on unstable implementation details instead of the visible state the user cares about + +### 5. Match debugging to the current suite + +Playwright's wider ecosystem supports traces and rich debugging tools. Dify's current suite already captures: + +- full-page screenshots +- page HTML +- console errors +- page errors + +Use the existing artifact flow by default. If a task is specifically about improving diagnostics, confirm the change fits the current Cucumber architecture before importing broader Playwright tooling. + +## Review Questions + +- Would this locator survive DOM refactors that do not change user-visible behavior? +- Is this assertion using Playwright's retrying semantics? +- Is any explicit wait masking a real readiness problem? +- Does this code preserve per-scenario isolation? +- Is a new abstraction really needed, or does it bypass the existing `DifyWorld` + step-definition model? diff --git a/.claude/skills/e2e-cucumber-playwright b/.claude/skills/e2e-cucumber-playwright new file mode 120000 index 0000000000..71b0eae34f --- /dev/null +++ b/.claude/skills/e2e-cucumber-playwright @@ -0,0 +1 @@ +../../.agents/skills/e2e-cucumber-playwright \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a183f0b58c..266fa17c29 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,106 +1,6 @@ version: 2 updates: - - package-ecosystem: "pip" - directory: "/api" - open-pull-requests-limit: 10 - schedule: - interval: "weekly" - groups: - flask: - patterns: - - "flask" - - "flask-*" - - "werkzeug" - - "gunicorn" - google: - patterns: - - "google-*" - - "googleapis-*" - opentelemetry: - patterns: - - "opentelemetry-*" - pydantic: - patterns: - - "pydantic" - - "pydantic-*" - llm: - patterns: - - "langfuse" - - "langsmith" - - "litellm" - - "mlflow*" - - "opik" - - "weave*" - - "arize*" - - "tiktoken" - - "transformers" - database: - patterns: - - "sqlalchemy" - - "psycopg2*" - - "psycogreen" - - "redis*" - - "alembic*" - storage: - patterns: - - "boto3*" - - "botocore*" - - "azure-*" - - "bce-*" - - "cos-python-*" - - "esdk-obs-*" - - "google-cloud-storage" - - "opendal" - - "oss2" - - "supabase*" - - "tos*" - vdb: - patterns: - - "alibabacloud*" - - "chromadb" - - "clickhouse-*" - - "clickzetta-*" - - "couchbase" - - "elasticsearch" - - "opensearch-py" - - "oracledb" - - "pgvect*" - - "pymilvus" - - "pymochow" - - "pyobvector" - - "qdrant-client" - - "intersystems-*" - - "tablestore" - - "tcvectordb" - - "tidb-vector" - - "upstash-*" - - "volcengine-*" - - "weaviate-*" - - "xinference-*" - - "mo-vector" - - "mysql-connector-*" - dev: - patterns: - - "coverage" - - "dotenv-linter" - - "faker" - - "lxml-stubs" - - "basedpyright" - - "ruff" - - "pytest*" - - "types-*" - - "boto3-stubs" - - "hypothesis" - - "pandas-stubs" - - "scipy-stubs" - - "import-linter" - - "celery-types" - - "mypy*" - - "pyrefly" - python-packages: - patterns: - - "*" - package-ecosystem: "uv" directory: "/api" open-pull-requests-limit: 10 diff --git a/.github/labeler.yml b/.github/labeler.yml index d1d324d381..3b9dc24749 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,3 +1,10 @@ web: - changed-files: - - any-glob-to-any-file: 'web/**' + - any-glob-to-any-file: + - 'web/**' + - 'packages/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.npmrc' + - '.nvmrc' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a069b6cbc7..1e848612ec 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,6 +7,7 @@ ## Summary + ## Screenshots @@ -17,7 +18,7 @@ ## Checklist - [ ] This change requires a documentation update, included: [Dify Document](https://github.com/langgenius/dify-docs) -- [x] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!) -- [x] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change. -- [x] I've updated the documentation accordingly. -- [x] I ran `make lint` and `make type-check` (backend) and `cd web && pnpm exec vp staged` (frontend) to appease the lint gods +- [ ] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!) +- [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change. +- [ ] I've updated the documentation accordingly. +- [ ] I ran `make lint && make type-check` (backend) and `cd web && pnpm exec vp staged` (frontend) to appease the lint gods diff --git a/.github/scripts/generate-i18n-changes.mjs b/.github/scripts/generate-i18n-changes.mjs new file mode 100644 index 0000000000..3d25115ac3 --- /dev/null +++ b/.github/scripts/generate-i18n-changes.mjs @@ -0,0 +1,82 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +const repoRoot = process.cwd() +const baseSha = process.env.BASE_SHA || '' +const headSha = process.env.HEAD_SHA || '' +const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean) +const outputPath = process.env.I18N_CHANGES_OUTPUT_PATH || '/tmp/i18n-changes.json' + +const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`) + +const readCurrentJson = (fileStem) => { + const filePath = englishPath(fileStem) + if (!fs.existsSync(filePath)) + return null + + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +const readBaseJson = (fileStem) => { + if (!baseSha) + return null + + try { + const relativePath = `web/i18n/en-US/${fileStem}.json` + const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' }) + return JSON.parse(content) + } + catch { + return null + } +} + +const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue) + +const changes = {} + +for (const fileStem of files) { + const currentJson = readCurrentJson(fileStem) + const beforeJson = readBaseJson(fileStem) || {} + const afterJson = currentJson || {} + const added = {} + const updated = {} + const deleted = [] + + for (const [key, value] of Object.entries(afterJson)) { + if (!(key in beforeJson)) { + added[key] = value + continue + } + + if (!compareJson(beforeJson[key], value)) { + updated[key] = { + before: beforeJson[key], + after: value, + } + } + } + + for (const key of Object.keys(beforeJson)) { + if (!(key in afterJson)) + deleted.push(key) + } + + changes[fileStem] = { + fileDeleted: currentJson === null, + added, + updated, + deleted, + } +} + +fs.writeFileSync( + outputPath, + JSON.stringify({ + baseSha, + headSha, + files, + changes, + }) +) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index cd967b76cf..fd910531db 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -54,7 +54,7 @@ jobs: run: uv run --project api bash dev/pytest/pytest_unit_tests.sh - name: Upload unit coverage data - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: api-coverage-unit path: coverage-unit @@ -129,7 +129,7 @@ jobs: api/tests/test_containers_integration_tests - name: Upload integration coverage data - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: api-coverage-integration path: coverage-integration diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 9648c34274..772ab8dd56 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -39,9 +39,11 @@ jobs: with: files: | web/** + packages/** package.json pnpm-lock.yaml pnpm-workspace.yaml + .npmrc .nvmrc - name: Check api inputs if: github.event_name != 'merge_group' diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 79ecdb5938..5f16fc6927 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -81,7 +81,7 @@ jobs: - name: Build Docker image id: build - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ matrix.build_context }} file: ${{ matrix.file }} @@ -101,7 +101,7 @@ jobs: touch "/tmp/digests/${sanitized_digest}" - name: Upload digest - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ matrix.artifact_context }}-${{ env.PLATFORM_PAIR }} path: /tmp/digests/* diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index cbeb1a3bb1..5752076c36 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -6,12 +6,7 @@ on: - "main" paths: - api/Dockerfile - - web/docker/** - web/Dockerfile - - package.json - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .nvmrc concurrency: group: docker-build-${{ github.head_ref || github.run_id }} @@ -48,7 +43,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build Docker Image - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: push: false context: ${{ matrix.context }} diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 104368d192..ba36b5c07a 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -65,9 +65,11 @@ jobs: - 'docker/volumes/sandbox/conf/**' web: - 'web/**' + - 'packages/**' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - '.npmrc' - '.nvmrc' - '.github/workflows/web-tests.yml' - '.github/actions/setup-web/**' @@ -77,9 +79,11 @@ jobs: - 'api/uv.lock' - 'e2e/**' - 'web/**' + - 'packages/**' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - '.npmrc' - '.nvmrc' - 'docker/docker-compose.middleware.yaml' - 'docker/middleware.env.example' @@ -88,6 +92,7 @@ jobs: vdb: - 'api/core/rag/datasource/**' - 'api/tests/integration_tests/vdb/**' + - 'api/providers/vdb/*/tests/**' - '.github/workflows/vdb-tests.yml' - '.github/workflows/expose_service_ports.sh' - 'docker/.env.example' diff --git a/.github/workflows/pyrefly-diff-comment.yml b/.github/workflows/pyrefly-diff-comment.yml index 0278e1e0d3..eefb1ebbb9 100644 --- a/.github/workflows/pyrefly-diff-comment.yml +++ b/.github/workflows/pyrefly-diff-comment.yml @@ -21,7 +21,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }} steps: - name: Download pyrefly diff artifact - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -49,7 +49,7 @@ jobs: run: unzip -o pyrefly_diff.zip - name: Post comment - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index 8623d35b04..ac3732579c 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -66,7 +66,7 @@ jobs: echo ${{ github.event.pull_request.number }} > pr_number.txt - name: Upload pyrefly diff - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pyrefly_diff path: | @@ -75,7 +75,7 @@ jobs: - name: Comment PR with pyrefly diff if: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.line_count_check.outputs.same == 'false' }} - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/pyrefly-type-coverage-comment.yml b/.github/workflows/pyrefly-type-coverage-comment.yml new file mode 100644 index 0000000000..51f3ca54b6 --- /dev/null +++ b/.github/workflows/pyrefly-type-coverage-comment.yml @@ -0,0 +1,118 @@ +name: Comment with Pyrefly Type Coverage + +on: + workflow_run: + workflows: + - Pyrefly Type Coverage + types: + - completed + +permissions: {} + +jobs: + comment: + name: Comment PR with type coverage + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + issues: write + pull-requests: write + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }} + steps: + - name: Checkout default branch (trusted code) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Python & UV + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --project api --dev + + - name: Download type coverage artifact + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + const match = artifacts.data.artifacts.find((artifact) => + artifact.name === 'pyrefly_type_coverage' + ); + if (!match) { + throw new Error('pyrefly_type_coverage artifact not found'); + } + const download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: match.id, + archive_format: 'zip', + }); + fs.writeFileSync('pyrefly_type_coverage.zip', Buffer.from(download.data)); + + - name: Unzip artifact + run: unzip -o pyrefly_type_coverage.zip + + - name: Render coverage markdown from structured data + id: render + run: | + comment_body="$(uv run --directory api python api/libs/pyrefly_type_coverage.py \ + --base base_report.json \ + < pr_report.json)" + + { + echo "### Pyrefly Type Coverage" + echo "" + echo "$comment_body" + } > /tmp/type_coverage_comment.md + + - name: Post comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const body = fs.readFileSync('/tmp/type_coverage_comment.md', { encoding: 'utf8' }); + let prNumber = null; + try { + prNumber = parseInt(fs.readFileSync('pr_number.txt', { encoding: 'utf8' }), 10); + } catch (err) { + const prs = context.payload.workflow_run.pull_requests || []; + if (prs.length > 0 && prs[0].number) { + prNumber = prs[0].number; + } + } + if (!prNumber) { + throw new Error('PR number not found in artifact or workflow_run payload'); + } + + // Update existing comment if one exists, otherwise create new + const { data: comments } = await github.rest.issues.listComments({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + }); + const marker = '### Pyrefly Type Coverage'; + const existing = comments.find(c => c.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } diff --git a/.github/workflows/pyrefly-type-coverage.yml b/.github/workflows/pyrefly-type-coverage.yml new file mode 100644 index 0000000000..c795c32e31 --- /dev/null +++ b/.github/workflows/pyrefly-type-coverage.yml @@ -0,0 +1,120 @@ +name: Pyrefly Type Coverage + +on: + pull_request: + paths: + - 'api/**/*.py' + +permissions: + contents: read + +jobs: + pyrefly-type-coverage: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Checkout PR branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Setup Python & UV + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --project api --dev + + - name: Run pyrefly report on PR branch + run: | + uv run --directory api --dev pyrefly report 2>/dev/null > /tmp/pyrefly_report_pr.tmp && \ + mv /tmp/pyrefly_report_pr.tmp /tmp/pyrefly_report_pr.json || \ + echo '{}' > /tmp/pyrefly_report_pr.json + + - name: Save helper script from base branch + run: | + git show ${{ github.event.pull_request.base.sha }}:api/libs/pyrefly_type_coverage.py > /tmp/pyrefly_type_coverage.py 2>/dev/null \ + || cp api/libs/pyrefly_type_coverage.py /tmp/pyrefly_type_coverage.py + + - name: Checkout base branch + run: git checkout ${{ github.base_ref }} + + - name: Run pyrefly report on base branch + run: | + uv run --directory api --dev pyrefly report 2>/dev/null > /tmp/pyrefly_report_base.tmp && \ + mv /tmp/pyrefly_report_base.tmp /tmp/pyrefly_report_base.json || \ + echo '{}' > /tmp/pyrefly_report_base.json + + - name: Generate coverage comparison + id: coverage + run: | + comment_body="$(uv run --directory api python /tmp/pyrefly_type_coverage.py \ + --base /tmp/pyrefly_report_base.json \ + < /tmp/pyrefly_report_pr.json)" + + { + echo "### Pyrefly Type Coverage" + echo "" + echo "$comment_body" + } | tee -a "$GITHUB_STEP_SUMMARY" > /tmp/type_coverage_comment.md + + # Save structured data for the fork-PR comment workflow + cp /tmp/pyrefly_report_pr.json pr_report.json + cp /tmp/pyrefly_report_base.json base_report.json + + - name: Save PR number + run: | + echo ${{ github.event.pull_request.number }} > pr_number.txt + + - name: Upload type coverage artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pyrefly_type_coverage + path: | + pr_report.json + base_report.json + pr_number.txt + + - name: Comment PR with type coverage + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const marker = '### Pyrefly Type Coverage'; + let body; + try { + body = fs.readFileSync('/tmp/type_coverage_comment.md', { encoding: 'utf8' }); + } catch { + body = `${marker}\n\n_Coverage report unavailable._`; + } + const prNumber = context.payload.pull_request.number; + + // Update existing comment if one exists, otherwise create new + const { data: comments } = await github.rest.issues.listComments({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + }); + const existing = comments.find(c => c.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5cf52daed2..c74f4a670a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -23,8 +23,8 @@ jobs: days-before-issue-stale: 15 days-before-issue-close: 3 repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: "Close due to it's no longer active, if you have any questions, you can reopen it." - stale-pr-message: "Close due to it's no longer active, if you have any questions, you can reopen it." + stale-issue-message: "Closed due to inactivity. If you have any questions, you can reopen it." + stale-pr-message: "Closed due to inactivity. If you have any questions, you can reopen it." stale-issue-label: 'no-issue-activity' stale-pr-label: 'no-pr-activity' - any-of-labels: 'duplicate,question,invalid,wontfix,no-issue-activity,no-pr-activity,enhancement,cant-reproduce,help-wanted' + any-of-labels: '🌚 invalid,🙋‍♂️ question,wont-fix,no-issue-activity,no-pr-activity,💪 enhancement,🤔 cant-reproduce,🙏 help wanted' diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 9f8b784778..c32fc9d0cb 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -77,9 +77,11 @@ jobs: with: files: | web/** + packages/** package.json pnpm-lock.yaml pnpm-workspace.yaml + .npmrc .nvmrc .github/workflows/style.yml .github/actions/setup-web/** diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml index 536a52b560..467f31fccf 100644 --- a/.github/workflows/tool-test-sdks.yaml +++ b/.github/workflows/tool-test-sdks.yaml @@ -9,6 +9,7 @@ on: - package.json - pnpm-lock.yaml - pnpm-workspace.yaml + - .npmrc concurrency: group: sdk-tests-${{ github.head_ref || github.run_id }} diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index a813c87cec..541200293d 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -68,89 +68,7 @@ jobs: " web/i18n-config/languages.ts | sed 's/[[:space:]]*$//') generate_changes_json() { - node <<'NODE' - const { execFileSync } = require('node:child_process') - const fs = require('node:fs') - const path = require('node:path') - - const repoRoot = process.cwd() - const baseSha = process.env.BASE_SHA || '' - const headSha = process.env.HEAD_SHA || '' - const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean) - - const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`) - - const readCurrentJson = (fileStem) => { - const filePath = englishPath(fileStem) - if (!fs.existsSync(filePath)) - return null - - return JSON.parse(fs.readFileSync(filePath, 'utf8')) - } - - const readBaseJson = (fileStem) => { - if (!baseSha) - return null - - try { - const relativePath = `web/i18n/en-US/${fileStem}.json` - const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' }) - return JSON.parse(content) - } - catch (error) { - return null - } - } - - const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue) - - const changes = {} - - for (const fileStem of files) { - const currentJson = readCurrentJson(fileStem) - const beforeJson = readBaseJson(fileStem) || {} - const afterJson = currentJson || {} - const added = {} - const updated = {} - const deleted = [] - - for (const [key, value] of Object.entries(afterJson)) { - if (!(key in beforeJson)) { - added[key] = value - continue - } - - if (!compareJson(beforeJson[key], value)) { - updated[key] = { - before: beforeJson[key], - after: value, - } - } - } - - for (const key of Object.keys(beforeJson)) { - if (!(key in afterJson)) - deleted.push(key) - } - - changes[fileStem] = { - fileDeleted: currentJson === null, - added, - updated, - deleted, - } - } - - fs.writeFileSync( - '/tmp/i18n-changes.json', - JSON.stringify({ - baseSha, - headSha, - files, - changes, - }) - ) - NODE + node .github/scripts/generate-i18n-changes.mjs } if [ "${{ github.event_name }}" = "repository_dispatch" ]; then @@ -240,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # v1.0.89 + uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1.0.93 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} @@ -270,7 +188,7 @@ jobs: Tool rules: - Use Read for repository files. - Use Edit for JSON updates. - - Use Bash only for `pnpm`. + - Use Bash only for `vp`. - Do not use Bash for `git`, `gh`, or branch management. Required execution plan: @@ -292,7 +210,7 @@ jobs: - Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate. - If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth. 4. Run a scoped pre-check before editing: - - `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` + - `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - Use this command as the source of truth for missing and extra keys inside the current scope. 5. Apply translations. - For every target language and scoped file: @@ -300,19 +218,19 @@ jobs: - If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed. - ADD missing keys. - UPDATE stale translations when the English value changed. - - DELETE removed keys. Prefer `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope. + - DELETE removed keys. Prefer `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope. - Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names. - Match the existing terminology and register used by each locale. - Prefer one Edit per file when stable, but prioritize correctness over batching. 6. Verify only the edited files. - - Run `pnpm --dir ${{ github.workspace }}/web lint:fix --quiet -- ` - - Run `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` + - Run `vp run dify-web#lint:fix --quiet -- ` + - Run `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - If verification fails, fix the remaining problems before continuing. 7. Stop after the scoped locale files are updated and verification passes. - Do not create branches, commits, or pull requests. claude_args: | --max-turns 120 - --allowedTools "Read,Write,Edit,Bash(pnpm *),Bash(pnpm:*),Glob,Grep" + --allowedTools "Read,Write,Edit,Bash(vp *),Bash(vp:*),Glob,Grep" - name: Prepare branch metadata id: pr_meta @@ -354,6 +272,7 @@ jobs: - name: Create or update translation PR if: steps.pr_meta.outputs.has_changes == 'true' env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }} FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }} TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }} @@ -402,8 +321,8 @@ jobs: '', '## Verification', '', - `- \`pnpm --dir web run i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``, - `- \`pnpm --dir web lint:fix --quiet -- \``, + `- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``, + `- \`vp run dify-web#lint:fix --quiet -- \``, '', '## Notes', '', diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml index a1ca42b26e..790ea9126d 100644 --- a/.github/workflows/trigger-i18n-sync.yml +++ b/.github/workflows/trigger-i18n-sync.yml @@ -42,88 +42,7 @@ jobs: fi export BASE_SHA HEAD_SHA CHANGED_FILES - node <<'NODE' - const { execFileSync } = require('node:child_process') - const fs = require('node:fs') - const path = require('node:path') - - const repoRoot = process.cwd() - const baseSha = process.env.BASE_SHA || '' - const headSha = process.env.HEAD_SHA || '' - const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean) - - const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`) - - const readCurrentJson = (fileStem) => { - const filePath = englishPath(fileStem) - if (!fs.existsSync(filePath)) - return null - - return JSON.parse(fs.readFileSync(filePath, 'utf8')) - } - - const readBaseJson = (fileStem) => { - if (!baseSha) - return null - - try { - const relativePath = `web/i18n/en-US/${fileStem}.json` - const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' }) - return JSON.parse(content) - } - catch (error) { - return null - } - } - - const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue) - - const changes = {} - - for (const fileStem of files) { - const beforeJson = readBaseJson(fileStem) || {} - const afterJson = readCurrentJson(fileStem) || {} - const added = {} - const updated = {} - const deleted = [] - - for (const [key, value] of Object.entries(afterJson)) { - if (!(key in beforeJson)) { - added[key] = value - continue - } - - if (!compareJson(beforeJson[key], value)) { - updated[key] = { - before: beforeJson[key], - after: value, - } - } - } - - for (const key of Object.keys(beforeJson)) { - if (!(key in afterJson)) - deleted.push(key) - } - - changes[fileStem] = { - fileDeleted: readCurrentJson(fileStem) === null, - added, - updated, - deleted, - } - } - - fs.writeFileSync( - '/tmp/i18n-changes.json', - JSON.stringify({ - baseSha, - headSha, - files, - changes, - }) - ) - NODE + node .github/scripts/generate-i18n-changes.mjs if [ -n "$CHANGED_FILES" ]; then echo "has_changes=true" >> "$GITHUB_OUTPUT" @@ -137,7 +56,7 @@ jobs: - name: Trigger i18n sync workflow if: steps.detect.outputs.has_changes == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: BASE_SHA: ${{ steps.detect.outputs.base_sha }} HEAD_SHA: ${{ steps.detect.outputs.head_sha }} diff --git a/.github/workflows/vdb-tests-full.yml b/.github/workflows/vdb-tests-full.yml index 72b3ea9aac..f0def8fe7a 100644 --- a/.github/workflows/vdb-tests-full.yml +++ b/.github/workflows/vdb-tests-full.yml @@ -89,7 +89,7 @@ jobs: cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env # - name: Check VDB Ready (TiDB) -# run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py +# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py - name: Test Vector Stores run: uv run --project api bash dev/pytest/pytest_vdb.sh diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index 47ec70f603..f3966f15b9 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -81,12 +81,12 @@ jobs: cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env # - name: Check VDB Ready (TiDB) -# run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py +# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py - name: Test Vector Stores run: | uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \ - api/tests/integration_tests/vdb/chroma \ - api/tests/integration_tests/vdb/pgvector \ - api/tests/integration_tests/vdb/qdrant \ - api/tests/integration_tests/vdb/weaviate + api/providers/vdb/vdb-chroma/tests/integration_tests \ + api/providers/vdb/vdb-pgvector/tests/integration_tests \ + api/providers/vdb/vdb-qdrant/tests/integration_tests \ + api/providers/vdb/vdb-weaviate/tests/integration_tests diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index eb752619be..10dc31bde8 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -53,7 +53,7 @@ jobs: - name: Upload Cucumber report if: ${{ !cancelled() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cucumber-report path: e2e/cucumber-report @@ -61,7 +61,7 @@ jobs: - name: Upload E2E logs if: ${{ !cancelled() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-logs path: e2e/.logs diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 3c36335e79..f3ab4c62c7 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -43,7 +43,7 @@ jobs: - name: Upload blob report if: ${{ !cancelled() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: blob-report-${{ matrix.shardIndex }} path: web/.vitest-reports/* diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit index db5c504606..13bbd81cf6 100755 --- a/.vite-hooks/pre-commit +++ b/.vite-hooks/pre-commit @@ -81,8 +81,8 @@ if $web_modified; then if $web_ts_modified; then echo "Running TypeScript type-check:tsgo" - if ! pnpm run type-check:tsgo; then - echo "Type check failed. Please run 'pnpm run type-check:tsgo' to fix the errors." + if ! npm run type-check:tsgo; then + echo "Type check failed. Please run 'npm run type-check:tsgo' to fix the errors." exit 1 fi else @@ -90,8 +90,8 @@ if $web_modified; then fi echo "Running knip" - if ! pnpm run knip; then - echo "Knip check failed. Please run 'pnpm run knip' to fix the errors." + if ! npm run knip; then + echo "Knip check failed. Please run 'npm run knip' to fix the errors." exit 1 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 775401bfa5..d7f007af67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,8 +97,3 @@ Feel free to reach out if you encounter any issues during the setup process. ## Getting Help If you ever get stuck or get a burning question while contributing, simply shoot your queries our way via the related GitHub issue, or hop onto our [Discord](https://discord.gg/8Tpq4AcN9c) for a quick chat. - -## Automated Agent Contributions - -> [!NOTE] -> If you are an automated agent, just add 🤖🤖🤖 to the end of the PR title to opt-in. diff --git a/api/.env.example b/api/.env.example index c6541731e6..a04a18944a 100644 --- a/api/.env.example +++ b/api/.env.example @@ -71,6 +71,13 @@ REDIS_USE_CLUSTERS=false REDIS_CLUSTERS= REDIS_CLUSTERS_PASSWORD= +REDIS_RETRY_RETRIES=3 +REDIS_RETRY_BACKOFF_BASE=1.0 +REDIS_RETRY_BACKOFF_CAP=10.0 +REDIS_SOCKET_TIMEOUT=5.0 +REDIS_SOCKET_CONNECT_TIMEOUT=5.0 +REDIS_HEALTH_CHECK_INTERVAL=30 + # celery configuration CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1 CELERY_BACKEND=redis @@ -102,6 +109,7 @@ S3_BUCKET_NAME=your-bucket-name S3_ACCESS_KEY=your-access-key S3_SECRET_KEY=your-secret-key S3_REGION=your-region +S3_ADDRESS_STYLE=auto # Workflow run and Conversation archive storage (S3-compatible) ARCHIVE_STORAGE_ENABLED=false diff --git a/api/.ruff.toml b/api/.ruff.toml index 2a825f1ef0..bd9684ef65 100644 --- a/api/.ruff.toml +++ b/api/.ruff.toml @@ -69,8 +69,6 @@ ignore = [ "FURB152", # math-constant "UP007", # non-pep604-annotation "UP032", # f-string - "UP045", # non-pep604-annotation-optional - "B005", # strip-with-multi-characters "B006", # mutable-argument-default "B007", # unused-loop-control-variable "B026", # star-arg-unpacking-after-keyword-arg @@ -84,7 +82,6 @@ ignore = [ "SIM102", # collapsible-if "SIM103", # needless-bool "SIM105", # suppressible-exception - "SIM107", # return-in-try-except-finally "SIM108", # if-else-block-instead-of-if-exp "SIM113", # enumerate-for-loop "SIM117", # multiple-with-statements @@ -93,29 +90,16 @@ ignore = [ ] [lint.per-file-ignores] -"__init__.py" = [ - "F401", # unused-import - "F811", # redefined-while-unused -] "configs/*" = [ "N802", # invalid-function-name ] -"graphon/model_runtime/callbacks/base_callback.py" = ["T201"] -"core/workflow/callbacks/workflow_logging_callback.py" = ["T201"] "libs/gmpy2_pkcs10aep_cipher.py" = [ "N803", # invalid-argument-name ] "tests/*" = [ - "F811", # redefined-while-unused "T201", # allow print in tests, "S110", # allow ignoring exceptions in tests code (currently) - ] -"controllers/console/explore/trial.py" = ["TID251"] -"controllers/console/human_input_form.py" = ["TID251"] -"controllers/web/human_input_form.py" = ["TID251"] - -[lint.flake8-tidy-imports] [lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"] msg = "Use Pydantic payload/query models instead of reqparse." diff --git a/api/Dockerfile b/api/Dockerfile index 7e0a439954..6098652573 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -21,8 +21,9 @@ RUN apt-get update \ # for building gmpy2 libmpfr-dev libmpc-dev -# Install Python dependencies +# Install Python dependencies (workspace members under providers/vdb/) COPY pyproject.toml uv.lock ./ +COPY providers ./providers RUN uv sync --locked --no-dev # production stage diff --git a/api/commands/account.py b/api/commands/account.py index 84af7a5ae6..6a2a2e0428 100644 --- a/api/commands/account.py +++ b/api/commands/account.py @@ -2,7 +2,6 @@ import base64 import secrets import click -from sqlalchemy.orm import sessionmaker from constants.languages import languages from extensions.ext_database import db @@ -25,30 +24,31 @@ def reset_password(email, new_password, password_confirm): return normalized_email = email.strip().lower() - with sessionmaker(db.engine, expire_on_commit=False).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=session) + account = AccountService.get_account_by_email_with_case_fallback(email.strip()) - if not account: - click.echo(click.style(f"Account not found for email: {email}", fg="red")) - return + if not account: + click.echo(click.style(f"Account not found for email: {email}", fg="red")) + return - try: - valid_password(new_password) - except: - click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red")) - return + try: + valid_password(new_password) + except: + click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red")) + return - # generate password salt - salt = secrets.token_bytes(16) - base64_salt = base64.b64encode(salt).decode() + # generate password salt + salt = secrets.token_bytes(16) + base64_salt = base64.b64encode(salt).decode() - # encrypt password with salt - password_hashed = hash_password(new_password, salt) - base64_password_hashed = base64.b64encode(password_hashed).decode() - account.password = base64_password_hashed - account.password_salt = base64_salt - AccountService.reset_login_error_rate_limit(normalized_email) - click.echo(click.style("Password reset successfully.", fg="green")) + # encrypt password with salt + password_hashed = hash_password(new_password, salt) + base64_password_hashed = base64.b64encode(password_hashed).decode() + account = db.session.merge(account) + account.password = base64_password_hashed + account.password_salt = base64_salt + db.session.commit() + AccountService.reset_login_error_rate_limit(normalized_email) + click.echo(click.style("Password reset successfully.", fg="green")) @click.command("reset-email", help="Reset the account email.") @@ -65,21 +65,22 @@ def reset_email(email, new_email, email_confirm): return normalized_new_email = new_email.strip().lower() - with sessionmaker(db.engine, expire_on_commit=False).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=session) + account = AccountService.get_account_by_email_with_case_fallback(email.strip()) - if not account: - click.echo(click.style(f"Account not found for email: {email}", fg="red")) - return + if not account: + click.echo(click.style(f"Account not found for email: {email}", fg="red")) + return - try: - email_validate(normalized_new_email) - except: - click.echo(click.style(f"Invalid email: {new_email}", fg="red")) - return + try: + email_validate(normalized_new_email) + except: + click.echo(click.style(f"Invalid email: {new_email}", fg="red")) + return - account.email = normalized_new_email - click.echo(click.style("Email updated successfully.", fg="green")) + account = db.session.merge(account) + account.email = normalized_new_email + db.session.commit() + click.echo(click.style("Email updated successfully.", fg="green")) @click.command("create-tenant", help="Create account and tenant.") diff --git a/api/commands/vector.py b/api/commands/vector.py index cb7eb7c452..956f20d6bb 100644 --- a/api/commands/vector.py +++ b/api/commands/vector.py @@ -341,11 +341,10 @@ def add_qdrant_index(field: str): click.echo(click.style("No dataset collection bindings found.", fg="red")) return import qdrant_client + from dify_vdb_qdrant.qdrant_vector import PathQdrantParams, QdrantConfig from qdrant_client.http.exceptions import UnexpectedResponse from qdrant_client.http.models import PayloadSchemaType - from core.rag.datasource.vdb.qdrant.qdrant_vector import PathQdrantParams, QdrantConfig - for binding in bindings: if dify_config.QDRANT_URL is None: raise ValueError("Qdrant URL is required.") diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py index 15ac8bf0bf..c392b8840f 100644 --- a/api/configs/middleware/__init__.py +++ b/api/configs/middleware/__init__.py @@ -1,5 +1,5 @@ import os -from typing import Any, Literal +from typing import Any, Literal, TypedDict from urllib.parse import parse_qsl, quote_plus from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field @@ -107,6 +107,17 @@ class KeywordStoreConfig(BaseSettings): ) +class SQLAlchemyEngineOptionsDict(TypedDict): + pool_size: int + max_overflow: int + pool_recycle: int + pool_pre_ping: bool + connect_args: dict[str, str] + pool_use_lifo: bool + pool_reset_on_return: None + pool_timeout: int + + class DatabaseConfig(BaseSettings): # Database type selector DB_TYPE: Literal["postgresql", "mysql", "oceanbase", "seekdb"] = Field( @@ -149,6 +160,16 @@ class DatabaseConfig(BaseSettings): default="", ) + DB_SESSION_TIMEZONE_OVERRIDE: str = Field( + description=( + "PostgreSQL session timezone override injected via startup options." + " Default is 'UTC' for out-of-the-box consistency." + " Set to empty string to disable app-level timezone injection, for example when using RDS Proxy" + " together with a database-side default timezone." + ), + default="UTC", + ) + @computed_field # type: ignore[prop-decorator] @property def SQLALCHEMY_DATABASE_URI_SCHEME(self) -> str: @@ -209,21 +230,22 @@ class DatabaseConfig(BaseSettings): @computed_field # type: ignore[prop-decorator] @property - def SQLALCHEMY_ENGINE_OPTIONS(self) -> dict[str, Any]: + def SQLALCHEMY_ENGINE_OPTIONS(self) -> SQLAlchemyEngineOptionsDict: # Parse DB_EXTRAS for 'options' db_extras_dict = dict(parse_qsl(self.DB_EXTRAS)) options = db_extras_dict.get("options", "") - connect_args = {} + connect_args: dict[str, str] = {} # Use the dynamic SQLALCHEMY_DATABASE_URI_SCHEME property if self.SQLALCHEMY_DATABASE_URI_SCHEME.startswith("postgresql"): - timezone_opt = "-c timezone=UTC" - if options: - merged_options = f"{options} {timezone_opt}" - else: - merged_options = timezone_opt - connect_args = {"options": merged_options} + merged_options = options.strip() + session_timezone_override = self.DB_SESSION_TIMEZONE_OVERRIDE.strip() + if session_timezone_override: + timezone_opt = f"-c timezone={session_timezone_override}" + merged_options = f"{merged_options} {timezone_opt}".strip() if merged_options else timezone_opt + if merged_options: + connect_args = {"options": merged_options} - return { + result: SQLAlchemyEngineOptionsDict = { "pool_size": self.SQLALCHEMY_POOL_SIZE, "max_overflow": self.SQLALCHEMY_MAX_OVERFLOW, "pool_recycle": self.SQLALCHEMY_POOL_RECYCLE, @@ -233,6 +255,7 @@ class DatabaseConfig(BaseSettings): "pool_reset_on_return": None, "pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT, } + return result class CeleryConfig(DatabaseConfig): diff --git a/api/configs/middleware/cache/redis_config.py b/api/configs/middleware/cache/redis_config.py index 3b91207545..b49275758a 100644 --- a/api/configs/middleware/cache/redis_config.py +++ b/api/configs/middleware/cache/redis_config.py @@ -117,6 +117,37 @@ class RedisConfig(BaseSettings): default=None, ) + REDIS_RETRY_RETRIES: NonNegativeInt = Field( + description="Maximum number of retries per Redis command on " + "transient failures (ConnectionError, TimeoutError, socket.timeout)", + default=3, + ) + + REDIS_RETRY_BACKOFF_BASE: PositiveFloat = Field( + description="Base delay in seconds for exponential backoff between retries", + default=1.0, + ) + + REDIS_RETRY_BACKOFF_CAP: PositiveFloat = Field( + description="Maximum backoff delay in seconds between retries", + default=10.0, + ) + + REDIS_SOCKET_TIMEOUT: PositiveFloat | None = Field( + description="Socket timeout in seconds for Redis read/write operations", + default=5.0, + ) + + REDIS_SOCKET_CONNECT_TIMEOUT: PositiveFloat | None = Field( + description="Socket timeout in seconds for Redis connection establishment", + default=5.0, + ) + + REDIS_HEALTH_CHECK_INTERVAL: NonNegativeInt = Field( + description="Interval in seconds between Redis connection health checks (0 to disable)", + default=30, + ) + @field_validator("REDIS_MAX_CONNECTIONS", mode="before") @classmethod def _empty_string_to_none_for_max_conns(cls, v): diff --git a/api/configs/middleware/vdb/hologres_config.py b/api/configs/middleware/vdb/hologres_config.py index 9812cce268..788b3cfb78 100644 --- a/api/configs/middleware/vdb/hologres_config.py +++ b/api/configs/middleware/vdb/hologres_config.py @@ -1,4 +1,3 @@ -from holo_search_sdk.types import BaseQuantizationType, DistanceType, TokenizerType from pydantic import Field from pydantic_settings import BaseSettings @@ -42,17 +41,17 @@ class HologresConfig(BaseSettings): default="public", ) - HOLOGRES_TOKENIZER: TokenizerType = Field( + HOLOGRES_TOKENIZER: str = Field( description="Tokenizer for full-text search index (e.g., 'jieba', 'ik', 'standard', 'simple').", default="jieba", ) - HOLOGRES_DISTANCE_METHOD: DistanceType = Field( + HOLOGRES_DISTANCE_METHOD: str = Field( description="Distance method for vector index (e.g., 'Cosine', 'Euclidean', 'InnerProduct').", default="Cosine", ) - HOLOGRES_BASE_QUANTIZATION_TYPE: BaseQuantizationType = Field( + HOLOGRES_BASE_QUANTIZATION_TYPE: str = Field( description="Base quantization type for vector index (e.g., 'rabitq', 'sq8', 'fp16', 'fp32').", default="rabitq", ) diff --git a/api/configs/middleware/vdb/iris_config.py b/api/configs/middleware/vdb/iris_config.py index c532d191c3..f5993dd8f8 100644 --- a/api/configs/middleware/vdb/iris_config.py +++ b/api/configs/middleware/vdb/iris_config.py @@ -1,5 +1,7 @@ """Configuration for InterSystems IRIS vector database.""" +from typing import Any + from pydantic import Field, PositiveInt, model_validator from pydantic_settings import BaseSettings @@ -64,7 +66,7 @@ class IrisVectorConfig(BaseSettings): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict) -> dict: + def validate_config(cls, values: dict[str, Any]) -> dict[str, Any]: """Validate IRIS configuration values. Args: diff --git a/api/controllers/common/controller_schemas.py b/api/controllers/common/controller_schemas.py new file mode 100644 index 0000000000..c12d576473 --- /dev/null +++ b/api/controllers/common/controller_schemas.py @@ -0,0 +1,104 @@ +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, Field, model_validator + +from libs.helper import UUIDStrOrEmpty + +# --- Conversation schemas --- + + +class ConversationRenamePayload(BaseModel): + name: str | None = None + auto_generate: bool = False + + @model_validator(mode="after") + def validate_name_requirement(self): + if not self.auto_generate: + if self.name is None or not self.name.strip(): + raise ValueError("name is required when auto_generate is false") + return self + + +# --- Message schemas --- + + +class MessageListQuery(BaseModel): + conversation_id: UUIDStrOrEmpty = Field(description="Conversation UUID") + first_id: UUIDStrOrEmpty | None = Field(default=None, description="First message ID for pagination") + limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)") + + +class MessageFeedbackPayload(BaseModel): + rating: Literal["like", "dislike"] | None = None + content: str | None = None + + +# --- Saved message schemas --- + + +class SavedMessageListQuery(BaseModel): + last_id: UUIDStrOrEmpty | None = None + limit: int = Field(default=20, ge=1, le=100) + + +class SavedMessageCreatePayload(BaseModel): + message_id: UUIDStrOrEmpty + + +# --- Workflow schemas --- + + +class DefaultBlockConfigQuery(BaseModel): + q: str | None = None + + +class WorkflowListQuery(BaseModel): + page: int = Field(default=1, ge=1, le=99999) + limit: int = Field(default=10, ge=1, le=100) + user_id: str | None = None + named_only: bool = False + + +class WorkflowRunPayload(BaseModel): + inputs: dict[str, Any] + files: list[dict[str, Any]] | None = None + + +class WorkflowUpdatePayload(BaseModel): + marked_name: str | None = Field(default=None, max_length=20) + marked_comment: str | None = Field(default=None, max_length=100) + + +# --- Dataset schemas --- + + +DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100 + + +class ChildChunkCreatePayload(BaseModel): + content: str + + +class ChildChunkUpdatePayload(BaseModel): + content: str + + +class DocumentBatchDownloadZipPayload(BaseModel): + """Request payload for bulk downloading documents as a zip archive.""" + + document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS) + + +class MetadataUpdatePayload(BaseModel): + name: str + + +# --- Audio schemas --- + + +class TextToAudioPayload(BaseModel): + message_id: str | None = Field(default=None, description="Message ID") + voice: str | None = Field(default=None, description="Voice to use for TTS") + text: str | None = Field(default=None, description="Text to convert to audio") + streaming: bool | None = Field(default=None, description="Enable streaming response") diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index 772bb9d0f1..b03d9b4a4c 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -1,12 +1,16 @@ +from datetime import datetime + import flask_restx -from flask_restx import Resource, fields, marshal_with +from flask_restx import Resource from flask_restx._http import HTTPStatus +from pydantic import field_validator from sqlalchemy import delete, func, select from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden +from controllers.common.schema import register_schema_models from extensions.ext_database import db -from libs.helper import TimestampField +from fields.base import ResponseModel from libs.login import current_account_with_tenant, login_required from models.dataset import Dataset from models.enums import ApiTokenType @@ -16,21 +20,31 @@ from services.api_token_service import ApiTokenCache from . import console_ns from .wraps import account_initialization_required, edit_permission_required, setup_required -api_key_fields = { - "id": fields.String, - "type": fields.String, - "token": fields.String, - "last_used_at": TimestampField, - "created_at": TimestampField, -} -api_key_item_model = console_ns.model("ApiKeyItem", api_key_fields) +def _to_timestamp(value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return int(value.timestamp()) + return value -api_key_list = {"data": fields.List(fields.Nested(api_key_item_model), attribute="items")} -api_key_list_model = console_ns.model( - "ApiKeyList", {"data": fields.List(fields.Nested(api_key_item_model), attribute="items")} -) +class ApiKeyItem(ResponseModel): + id: str + type: str + token: str + last_used_at: int | None = None + created_at: int | None = None + + @field_validator("last_used_at", "created_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) + + +class ApiKeyList(ResponseModel): + data: list[ApiKeyItem] + + +register_schema_models(console_ns, ApiKeyItem, ApiKeyList) def _get_resource(resource_id, tenant_id, resource_model): @@ -54,7 +68,6 @@ class BaseApiKeyListResource(Resource): token_prefix: str | None = None max_keys = 10 - @marshal_with(api_key_list_model) def get(self, resource_id): assert self.resource_id_field is not None, "resource_id_field must be set" resource_id = str(resource_id) @@ -66,9 +79,8 @@ class BaseApiKeyListResource(Resource): ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id ) ).all() - return {"items": keys} + return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json") - @marshal_with(api_key_item_model) @edit_permission_required def post(self, resource_id): assert self.resource_id_field is not None, "resource_id_field must be set" @@ -100,7 +112,7 @@ class BaseApiKeyListResource(Resource): api_token.type = self.resource_type db.session.add(api_token) db.session.commit() - return api_token, 201 + return ApiKeyItem.model_validate(api_token, from_attributes=True).model_dump(mode="json"), 201 class BaseApiKeyResource(Resource): @@ -147,7 +159,7 @@ class AppApiKeyListResource(BaseApiKeyListResource): @console_ns.doc("get_app_api_keys") @console_ns.doc(description="Get all API keys for an app") @console_ns.doc(params={"resource_id": "App ID"}) - @console_ns.response(200, "Success", api_key_list_model) + @console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__]) def get(self, resource_id): # type: ignore """Get all API keys for an app""" return super().get(resource_id) @@ -155,7 +167,7 @@ class AppApiKeyListResource(BaseApiKeyListResource): @console_ns.doc("create_app_api_key") @console_ns.doc(description="Create a new API key for an app") @console_ns.doc(params={"resource_id": "App ID"}) - @console_ns.response(201, "API key created successfully", api_key_item_model) + @console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(400, "Maximum keys exceeded") def post(self, resource_id): # type: ignore """Create a new API key for an app""" @@ -187,7 +199,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource): @console_ns.doc("get_dataset_api_keys") @console_ns.doc(description="Get all API keys for a dataset") @console_ns.doc(params={"resource_id": "Dataset ID"}) - @console_ns.response(200, "Success", api_key_list_model) + @console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__]) def get(self, resource_id): # type: ignore """Get all API keys for a dataset""" return super().get(resource_id) @@ -195,7 +207,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource): @console_ns.doc("create_dataset_api_key") @console_ns.doc(description="Create a new API key for a dataset") @console_ns.doc(params={"resource_id": "Dataset ID"}) - @console_ns.response(201, "API key created successfully", api_key_item_model) + @console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(400, "Maximum keys exceeded") def post(self, resource_id): # type: ignore """Create a new API key for a dataset""" diff --git a/api/controllers/console/app/advanced_prompt_template.py b/api/controllers/console/app/advanced_prompt_template.py index 3bd61feb44..ed66da1be5 100644 --- a/api/controllers/console/app/advanced_prompt_template.py +++ b/api/controllers/console/app/advanced_prompt_template.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, setup_required from libs.login import login_required -from services.advanced_prompt_template_service import AdvancedPromptTemplateService +from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService class AdvancedPromptTemplateQuery(BaseModel): @@ -35,5 +35,10 @@ class AdvancedPromptTemplateList(Resource): @account_initialization_required def get(self): args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore - - return AdvancedPromptTemplateService.get_prompt(args.model_dump()) + prompt_args: AdvancedPromptTemplateArgs = { + "app_mode": args.app_mode, + "model_mode": args.model_mode, + "model_name": args.model_name, + "has_context": args.has_context, + } + return AdvancedPromptTemplateService.get_prompt(prompt_args) diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index 9931bb5dd7..528785931e 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -25,7 +25,13 @@ from fields.annotation_fields import ( ) from libs.helper import uuid_value from libs.login import login_required -from services.annotation_service import AppAnnotationService +from services.annotation_service import ( + AppAnnotationService, + EnableAnnotationArgs, + UpdateAnnotationArgs, + UpdateAnnotationSettingArgs, + UpsertAnnotationArgs, +) DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" @@ -120,7 +126,12 @@ class AnnotationReplyActionApi(Resource): args = AnnotationReplyPayload.model_validate(console_ns.payload) match action: case "enable": - result = AppAnnotationService.enable_app_annotation(args.model_dump(), app_id) + enable_args: EnableAnnotationArgs = { + "score_threshold": args.score_threshold, + "embedding_provider_name": args.embedding_provider_name, + "embedding_model_name": args.embedding_model_name, + } + result = AppAnnotationService.enable_app_annotation(enable_args, app_id) case "disable": result = AppAnnotationService.disable_app_annotation(app_id) return result, 200 @@ -161,7 +172,8 @@ class AppAnnotationSettingUpdateApi(Resource): args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload) - result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, args.model_dump()) + setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold} + result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, setting_args) return result, 200 @@ -237,8 +249,16 @@ class AnnotationApi(Resource): def post(self, app_id): app_id = str(app_id) args = CreateAnnotationPayload.model_validate(console_ns.payload) - data = args.model_dump(exclude_none=True) - annotation = AppAnnotationService.up_insert_app_annotation_from_message(data, app_id) + upsert_args: UpsertAnnotationArgs = {} + if args.answer is not None: + upsert_args["answer"] = args.answer + if args.content is not None: + upsert_args["content"] = args.content + if args.message_id is not None: + upsert_args["message_id"] = args.message_id + if args.question is not None: + upsert_args["question"] = args.question + annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, app_id) return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") @setup_required @@ -315,9 +335,12 @@ class AnnotationUpdateDeleteApi(Resource): app_id = str(app_id) annotation_id = str(annotation_id) args = UpdateAnnotationPayload.model_validate(console_ns.payload) - annotation = AppAnnotationService.update_app_annotation_directly( - args.model_dump(exclude_none=True), app_id, annotation_id - ) + update_args: UpdateAnnotationArgs = {} + if args.answer is not None: + update_args["answer"] = args.answer + if args.question is not None: + update_args["question"] = args.question + annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_id, annotation_id) return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") @setup_required diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index c4b9bf6540..2018f60215 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -34,9 +34,10 @@ from fields.base import ResponseModel from libs.login import current_account_with_tenant, login_required from models import App, DatasetPermissionEnum, Workflow from models.model import IconType -from services.app_dsl_service import AppDslService, ImportMode +from services.app_dsl_service import AppDslService from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService +from services.entities.dsl_entities import ImportMode from services.entities.knowledge_entities.knowledge_entities import ( DataSource, InfoList, diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index 16e1fa3245..80bd7d1d8d 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -1,7 +1,8 @@ -from flask_restx import Resource, fields, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker +from controllers.common.schema import register_schema_models from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( account_initialization_required, @@ -10,34 +11,15 @@ from controllers.console.wraps import ( setup_required, ) from extensions.ext_database import db -from fields.app_fields import ( - app_import_check_dependencies_fields, - app_import_fields, - leaked_dependency_fields, -) from libs.login import current_account_with_tenant, login_required from models.model import App -from services.app_dsl_service import AppDslService, ImportStatus +from services.app_dsl_service import AppDslService, Import from services.enterprise.enterprise_service import EnterpriseService +from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus from services.feature_service import FeatureService from .. import console_ns -# Register models for flask_restx to avoid dict type issues in Swagger -# Register base model first -leaked_dependency_model = console_ns.model("LeakedDependency", leaked_dependency_fields) - -app_import_model = console_ns.model("AppImport", app_import_fields) - -# For nested models, need to replace nested dict with registered model -app_import_check_dependencies_fields_copy = app_import_check_dependencies_fields.copy() -app_import_check_dependencies_fields_copy["leaked_dependencies"] = fields.List(fields.Nested(leaked_dependency_model)) -app_import_check_dependencies_model = console_ns.model( - "AppImportCheckDependencies", app_import_check_dependencies_fields_copy -) - -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class AppImportPayload(BaseModel): mode: str = Field(..., description="Import mode") @@ -51,18 +33,18 @@ class AppImportPayload(BaseModel): app_id: str | None = Field(None) -console_ns.schema_model( - AppImportPayload.__name__, AppImportPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult) @console_ns.route("/apps/imports") class AppImportApi(Resource): @console_ns.expect(console_ns.models[AppImportPayload.__name__]) + @console_ns.response(200, "Import completed", console_ns.models[Import.__name__]) + @console_ns.response(202, "Import pending confirmation", console_ns.models[Import.__name__]) + @console_ns.response(400, "Import failed", console_ns.models[Import.__name__]) @setup_required @login_required @account_initialization_required - @marshal_with(app_import_model) @cloud_edition_billing_resource_check("apps") @edit_permission_required def post(self): @@ -92,19 +74,22 @@ class AppImportApi(Resource): EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private") # Return appropriate status code based on result status = result.status - if status == ImportStatus.FAILED: - return result.model_dump(mode="json"), 400 - elif status == ImportStatus.PENDING: - return result.model_dump(mode="json"), 202 - return result.model_dump(mode="json"), 200 + match status: + case ImportStatus.FAILED: + return result.model_dump(mode="json"), 400 + case ImportStatus.PENDING: + return result.model_dump(mode="json"), 202 + case ImportStatus.COMPLETED | ImportStatus.COMPLETED_WITH_WARNINGS: + return result.model_dump(mode="json"), 200 @console_ns.route("/apps/imports//confirm") class AppImportConfirmApi(Resource): + @console_ns.response(200, "Import confirmed", console_ns.models[Import.__name__]) + @console_ns.response(400, "Import failed", console_ns.models[Import.__name__]) @setup_required @login_required @account_initialization_required - @marshal_with(app_import_model) @edit_permission_required def post(self, import_id): # Check user role first @@ -125,11 +110,11 @@ class AppImportConfirmApi(Resource): @console_ns.route("/apps/imports//check-dependencies") class AppImportCheckDependenciesApi(Resource): + @console_ns.response(200, "Dependencies checked", console_ns.models[CheckDependenciesResult.__name__]) @setup_required @login_required @get_app_model @account_initialization_required - @marshal_with(app_import_check_dependencies_model) @edit_permission_required def get(self, app_model: App): with sessionmaker(db.engine).begin() as session: diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 412fc8795a..5b1abc98dc 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -1,39 +1,68 @@ import json +from datetime import datetime +from typing import Any -from flask_restx import Resource, marshal_with -from pydantic import BaseModel, Field +from flask_restx import Resource +from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from werkzeug.exceptions import NotFound +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from extensions.ext_database import db -from fields.app_fields import app_server_fields +from fields.base import ResponseModel from libs.login import current_account_with_tenant, login_required from models.enums import AppMCPServerStatus from models.model import AppMCPServer -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" -# Register model for flask_restx to avoid dict type issues in Swagger -app_server_model = console_ns.model("AppServer", app_server_fields) +def _to_timestamp(value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return int(value.timestamp()) + return value class MCPServerCreatePayload(BaseModel): description: str | None = Field(default=None, description="Server description") - parameters: dict = Field(..., description="Server parameters configuration") + parameters: dict[str, Any] = Field(..., description="Server parameters configuration") class MCPServerUpdatePayload(BaseModel): id: str = Field(..., description="Server ID") description: str | None = Field(default=None, description="Server description") - parameters: dict = Field(..., description="Server parameters configuration") + parameters: dict[str, Any] = Field(..., description="Server parameters configuration") status: str | None = Field(default=None, description="Server status") -for model in (MCPServerCreatePayload, MCPServerUpdatePayload): - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) +class AppMCPServerResponse(ResponseModel): + id: str + name: str + server_code: str + description: str + status: str + parameters: dict[str, Any] | list[Any] | str + created_at: int | None = None + updated_at: int | None = None + + @field_validator("parameters", mode="before") + @classmethod + def _parse_json_string(cls, value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return value + return value + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) + + +register_schema_models(console_ns, MCPServerCreatePayload, MCPServerUpdatePayload, AppMCPServerResponse) @console_ns.route("/apps//server") @@ -41,27 +70,27 @@ class AppMCPServerController(Resource): @console_ns.doc("get_app_mcp_server") @console_ns.doc(description="Get MCP server configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "MCP server configuration retrieved successfully", app_server_model) + @console_ns.response(200, "Server configuration", console_ns.models[AppMCPServerResponse.__name__]) @login_required @account_initialization_required @setup_required @get_app_model - @marshal_with(app_server_model) def get(self, app_model): server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1)) - return server + if server is None: + return {} + return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") @console_ns.doc("create_app_mcp_server") @console_ns.doc(description="Create MCP server configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[MCPServerCreatePayload.__name__]) - @console_ns.response(201, "MCP server configuration created successfully", app_server_model) + @console_ns.response(200, "Server created", console_ns.models[AppMCPServerResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @account_initialization_required @get_app_model @login_required @setup_required - @marshal_with(app_server_model) @edit_permission_required def post(self, app_model): _, current_tenant_id = current_account_with_tenant() @@ -82,20 +111,19 @@ class AppMCPServerController(Resource): ) db.session.add(server) db.session.commit() - return server + return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") @console_ns.doc("update_app_mcp_server") @console_ns.doc(description="Update MCP server configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[MCPServerUpdatePayload.__name__]) - @console_ns.response(200, "MCP server configuration updated successfully", app_server_model) + @console_ns.response(200, "Server updated", console_ns.models[AppMCPServerResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "Server not found") @get_app_model @login_required @setup_required @account_initialization_required - @marshal_with(app_server_model) @edit_permission_required def put(self, app_model): payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {}) @@ -118,7 +146,7 @@ class AppMCPServerController(Resource): except ValueError: raise ValueError("Invalid status") db.session.commit() - return server + return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps//server/refresh") @@ -126,13 +154,12 @@ class AppMCPServerRefreshController(Resource): @console_ns.doc("refresh_app_mcp_server") @console_ns.doc(description="Refresh MCP server configuration and regenerate server code") @console_ns.doc(params={"server_id": "Server ID"}) - @console_ns.response(200, "MCP server refreshed successfully", app_server_model) + @console_ns.response(200, "Server refreshed", console_ns.models[AppMCPServerResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "Server not found") @setup_required @login_required @account_initialization_required - @marshal_with(app_server_model) @edit_permission_required def get(self, server_id): _, current_tenant_id = current_account_with_tenant() @@ -145,4 +172,4 @@ class AppMCPServerRefreshController(Resource): raise NotFound() server.server_code = AppMCPServer.generate_server_code(16) db.session.commit() - return server + return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 2afe276742..5a19544eab 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field, field_validator from sqlalchemy import exists, func, select from werkzeug.exceptions import InternalServerError, NotFound +from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( @@ -59,10 +60,8 @@ class ChatMessagesQuery(BaseModel): return uuid_value(value) -class MessageFeedbackPayload(BaseModel): +class MessageFeedbackPayload(_MessageFeedbackPayloadBase): message_id: str = Field(..., description="Message ID") - rating: Literal["like", "dislike"] | None = Field(default=None, description="Feedback rating") - content: str | None = Field(default=None, description="Feedback content") @field_validator("message_id") @classmethod diff --git a/api/controllers/console/app/model_config.py b/api/controllers/console/app/model_config.py index 8bb5aa2c1b..1869cbf5f6 100644 --- a/api/controllers/console/app/model_config.py +++ b/api/controllers/console/app/model_config.py @@ -1,9 +1,11 @@ import json -from typing import cast +from typing import Any, cast from flask import request -from flask_restx import Resource, fields +from flask_restx import Resource +from pydantic import BaseModel, Field +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required @@ -18,30 +20,30 @@ from models.model import AppMode, AppModelConfig from services.app_model_config_service import AppModelConfigService +class ModelConfigRequest(BaseModel): + provider: str | None = Field(default=None, description="Model provider") + model: str | None = Field(default=None, description="Model name") + configs: dict[str, Any] | None = Field(default=None, description="Model configuration parameters") + opening_statement: str | None = Field(default=None, description="Opening statement") + suggested_questions: list[str] | None = Field(default=None, description="Suggested questions") + more_like_this: dict[str, Any] | None = Field(default=None, description="More like this configuration") + speech_to_text: dict[str, Any] | None = Field(default=None, description="Speech to text configuration") + text_to_speech: dict[str, Any] | None = Field(default=None, description="Text to speech configuration") + retrieval_model: dict[str, Any] | None = Field(default=None, description="Retrieval model configuration") + tools: list[dict[str, Any]] | None = Field(default=None, description="Available tools") + dataset_configs: dict[str, Any] | None = Field(default=None, description="Dataset configurations") + agent_mode: dict[str, Any] | None = Field(default=None, description="Agent mode configuration") + + +register_schema_models(console_ns, ModelConfigRequest) + + @console_ns.route("/apps//model-config") class ModelConfigResource(Resource): @console_ns.doc("update_app_model_config") @console_ns.doc(description="Update application model configuration") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect( - console_ns.model( - "ModelConfigRequest", - { - "provider": fields.String(description="Model provider"), - "model": fields.String(description="Model name"), - "configs": fields.Raw(description="Model configuration parameters"), - "opening_statement": fields.String(description="Opening statement"), - "suggested_questions": fields.List(fields.String(), description="Suggested questions"), - "more_like_this": fields.Raw(description="More like this configuration"), - "speech_to_text": fields.Raw(description="Speech to text configuration"), - "text_to_speech": fields.Raw(description="Text to speech configuration"), - "retrieval_model": fields.Raw(description="Retrieval model configuration"), - "tools": fields.List(fields.Raw(), description="Available tools"), - "dataset_configs": fields.Raw(description="Dataset configurations"), - "agent_mode": fields.Raw(description="Agent mode configuration"), - }, - ) - ) + @console_ns.expect(console_ns.models[ModelConfigRequest.__name__]) @console_ns.response(200, "Model configuration updated successfully") @console_ns.response(400, "Invalid configuration") @console_ns.response(404, "App not found") diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index 7f44a99ff1..9991d78d94 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -1,11 +1,12 @@ from typing import Literal -from flask_restx import Resource, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from werkzeug.exceptions import NotFound from constants.languages import supported_language +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( @@ -15,13 +16,11 @@ from controllers.console.wraps import ( setup_required, ) from extensions.ext_database import db -from fields.app_fields import app_site_fields +from fields.base import ResponseModel from libs.datetime_utils import naive_utc_now from libs.login import current_account_with_tenant, login_required from models import Site -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class AppSiteUpdatePayload(BaseModel): title: str | None = Field(default=None) @@ -49,13 +48,26 @@ class AppSiteUpdatePayload(BaseModel): return supported_language(value) -console_ns.schema_model( - AppSiteUpdatePayload.__name__, - AppSiteUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +class AppSiteResponse(ResponseModel): + app_id: str + access_token: str | None = Field(default=None, validation_alias="code") + code: str | None = None + title: str + icon: str | None = None + icon_background: str | None = None + description: str | None = None + default_language: str + customize_domain: str | None = None + copyright: str | None = None + privacy_policy: str | None = None + custom_disclaimer: str | None = None + customize_token_strategy: str + prompt_public: bool + show_workflow_steps: bool + use_icon_as_answer_icon: bool -# Register model for flask_restx to avoid dict type issues in Swagger -app_site_model = console_ns.model("AppSite", app_site_fields) + +register_schema_models(console_ns, AppSiteUpdatePayload, AppSiteResponse) @console_ns.route("/apps//site") @@ -64,7 +76,7 @@ class AppSite(Resource): @console_ns.doc(description="Update application site configuration") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[AppSiteUpdatePayload.__name__]) - @console_ns.response(200, "Site configuration updated successfully", app_site_model) + @console_ns.response(200, "Site configuration updated successfully", console_ns.models[AppSiteResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "App not found") @setup_required @@ -72,7 +84,6 @@ class AppSite(Resource): @edit_permission_required @account_initialization_required @get_app_model - @marshal_with(app_site_model) def post(self, app_model): args = AppSiteUpdatePayload.model_validate(console_ns.payload or {}) current_user, _ = current_account_with_tenant() @@ -106,7 +117,7 @@ class AppSite(Resource): site.updated_at = naive_utc_now() db.session.commit() - return site + return AppSiteResponse.model_validate(site, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps//site/access-token-reset") @@ -114,7 +125,7 @@ class AppSiteAccessTokenReset(Resource): @console_ns.doc("reset_app_site_access_token") @console_ns.doc(description="Reset access token for application site") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Access token reset successfully", app_site_model) + @console_ns.response(200, "Access token reset successfully", console_ns.models[AppSiteResponse.__name__]) @console_ns.response(403, "Insufficient permissions (admin/owner required)") @console_ns.response(404, "App or site not found") @setup_required @@ -122,7 +133,6 @@ class AppSiteAccessTokenReset(Resource): @is_admin_or_owner_required @account_initialization_required @get_app_model - @marshal_with(app_site_model) def post(self, app_model): current_user, _ = current_account_with_tenant() site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) @@ -135,4 +145,4 @@ class AppSiteAccessTokenReset(Resource): site.updated_at = naive_utc_now() db.session.commit() - return site + return AppSiteResponse.model_validate(site, from_attributes=True).model_dump(mode="json") diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index dcd24d2200..da8d25c2eb 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services +from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload from controllers.console import console_ns from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync from controllers.console.app.workflow_run import workflow_run_node_execution_model @@ -142,10 +143,6 @@ class PublishWorkflowPayload(BaseModel): marked_comment: str | None = Field(default=None, max_length=100) -class DefaultBlockConfigQuery(BaseModel): - q: str | None = None - - class ConvertToWorkflowPayload(BaseModel): name: str | None = None icon_type: str | None = None @@ -153,18 +150,6 @@ class ConvertToWorkflowPayload(BaseModel): icon_background: str | None = None -class WorkflowListQuery(BaseModel): - page: int = Field(default=1, ge=1, le=99999) - limit: int = Field(default=10, ge=1, le=100) - user_id: str | None = None - named_only: bool = False - - -class WorkflowUpdatePayload(BaseModel): - marked_name: str | None = Field(default=None, max_length=20) - marked_comment: str | None = Field(default=None, max_length=100) - - class DraftWorkflowTriggerRunPayload(BaseModel): node_id: str diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index 3b24c2a402..8ae6a78a62 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -87,7 +87,7 @@ class WorkflowAppLogApi(Resource): # get paginate workflow app logs workflow_app_service = WorkflowAppService() - with sessionmaker(db.engine).begin() as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( session=session, app_model=app_model, @@ -124,7 +124,7 @@ class WorkflowArchivedLogApi(Resource): args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore workflow_app_service = WorkflowAppService() - with sessionmaker(db.engine).begin() as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_archive_logs( session=session, app_model=app_model, diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index f6d076320c..657e794ac4 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -1,7 +1,7 @@ import logging from collections.abc import Callable from functools import wraps -from typing import Any +from typing import Any, TypedDict from flask import Response, request from flask_restx import Resource, fields, marshal, marshal_with @@ -86,7 +86,14 @@ def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str: return value_type.exposed_type().value -def _serialize_full_content(variable: WorkflowDraftVariable) -> dict | None: +class FullContentDict(TypedDict): + size_bytes: int | None + value_type: str + length: int | None + download_url: str + + +def _serialize_full_content(variable: WorkflowDraftVariable) -> FullContentDict | None: """Serialize full_content information for large variables.""" if not variable.is_truncated(): return None @@ -94,12 +101,13 @@ def _serialize_full_content(variable: WorkflowDraftVariable) -> dict | None: variable_file = variable.variable_file assert variable_file is not None - return { + result: FullContentDict = { "size_bytes": variable_file.size, "value_type": variable_file.value_type.exposed_type().value, "length": variable_file.length, "download_url": file_helpers.get_signed_file_url(variable_file.upload_file_id, as_attachment=True), } + return result def _ensure_variable_access( @@ -384,24 +392,27 @@ class VariableApi(Resource): new_value = None if raw_value is not None: - if variable.value_type == SegmentType.FILE: - if not isinstance(raw_value, dict): - raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( - mapping=raw_value, - tenant_id=app_model.tenant_id, - access_controller=_file_access_controller, - ) - elif variable.value_type == SegmentType.ARRAY_FILE: - if not isinstance(raw_value, list): - raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( - mappings=raw_value, - tenant_id=app_model.tenant_id, - access_controller=_file_access_controller, - ) + match variable.value_type: + case SegmentType.FILE: + if not isinstance(raw_value, dict): + raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") + raw_value = build_from_mapping( + mapping=raw_value, + tenant_id=app_model.tenant_id, + access_controller=_file_access_controller, + ) + case SegmentType.ARRAY_FILE: + if not isinstance(raw_value, list): + raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") + if len(raw_value) > 0 and not isinstance(raw_value[0], dict): + raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") + raw_value = build_from_mappings( + mappings=raw_value, + tenant_id=app_model.tenant_id, + access_controller=_file_access_controller, + ) + case _: + pass new_value = build_segment_with_type(variable.value_type, raw_value) draft_var_srv.update_variable(variable, name=new_name, value=new_value) db.session.commit() diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index 83e8bedc11..a1a075be71 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -36,7 +36,7 @@ from models import Account, App, AppMode, EndUser, WorkflowArchiveLog, WorkflowR from models.workflow import WorkflowRun from repositories.factory import DifyAPIRepositoryFactory from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME -from services.workflow_run_service import WorkflowRunService +from services.workflow_run_service import WorkflowRunListArgs, WorkflowRunService def _build_backstage_input_url(form_token: str | None) -> str | None: @@ -214,7 +214,11 @@ class AdvancedChatAppWorkflowRunListApi(Resource): Get advanced chat app workflow run list """ args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore - args = args_model.model_dump(exclude_none=True) + args: WorkflowRunListArgs = {"limit": args_model.limit} + if args_model.last_id is not None: + args["last_id"] = args_model.last_id + if args_model.status is not None: + args["status"] = args_model.status # Default to DEBUGGING if not specified triggered_from = ( @@ -356,7 +360,11 @@ class WorkflowRunListApi(Resource): Get workflow run list """ args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore - args = args_model.model_dump(exclude_none=True) + args: WorkflowRunListArgs = {"limit": args_model.limit} + if args_model.last_id is not None: + args["last_id"] = args_model.last_id + if args_model.status is not None: + args["status"] = args_model.status # Default to DEBUGGING for workflow if not specified (backward compatibility) triggered_from = ( diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index e4a6afae1e..c457684c15 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -64,7 +64,7 @@ class WebhookTriggerApi(Resource): node_id = args.node_id - with sessionmaker(db.engine).begin() as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: # Get webhook trigger for this app and node webhook_trigger = session.scalar( select(WorkflowWebhookTrigger) @@ -95,7 +95,7 @@ class AppTriggersApi(Resource): assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None - with sessionmaker(db.engine).begin() as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: # Get all triggers for this app using select API triggers = ( session.execute( diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index f741107b87..e6316bfd62 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -1,8 +1,9 @@ from flask import request -from flask_restx import Resource, fields +from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from constants.languages import supported_language +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.error import AlreadyActivateError from extensions.ext_database import db @@ -11,8 +12,6 @@ from libs.helper import EmailStr, timezone from models import AccountStatus from services.account_service import RegisterService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ActivateCheckQuery(BaseModel): workspace_id: str | None = Field(default=None) @@ -39,8 +38,16 @@ class ActivatePayload(BaseModel): return timezone(value) -for model in (ActivateCheckQuery, ActivatePayload): - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) +class ActivationCheckResponse(BaseModel): + is_valid: bool = Field(description="Whether token is valid") + data: dict | None = Field(default=None, description="Activation data if valid") + + +class ActivationResponse(BaseModel): + result: str = Field(description="Operation result") + + +register_schema_models(console_ns, ActivateCheckQuery, ActivatePayload, ActivationCheckResponse, ActivationResponse) @console_ns.route("/activate/check") @@ -51,13 +58,7 @@ class ActivateCheckApi(Resource): @console_ns.response( 200, "Success", - console_ns.model( - "ActivationCheckResponse", - { - "is_valid": fields.Boolean(description="Whether token is valid"), - "data": fields.Raw(description="Activation data if valid"), - }, - ), + console_ns.models[ActivationCheckResponse.__name__], ) def get(self): args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore @@ -95,12 +96,7 @@ class ActivateApi(Resource): @console_ns.response( 200, "Account activated successfully", - console_ns.model( - "ActivationResponse", - { - "result": fields.String(description="Operation result"), - }, - ), + console_ns.models[ActivationResponse.__name__], ) @console_ns.response(400, "Already activated or invalid token") def post(self): diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index 9e7faa09c5..1fd781b4fc 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -1,7 +1,6 @@ from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from sqlalchemy.orm import sessionmaker from configs import dify_config from constants.languages import languages @@ -14,7 +13,6 @@ from controllers.console.auth.error import ( InvalidTokenError, PasswordMismatchError, ) -from extensions.ext_database import db from libs.helper import EmailStr, extract_remote_ip from libs.password import valid_password from models import Account @@ -73,8 +71,7 @@ class EmailRegisterSendEmailApi(Resource): if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email): raise AccountInFreezeError() - with sessionmaker(db.engine).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session) + account = AccountService.get_account_by_email_with_case_fallback(args.email) token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language) return {"result": "success", "data": token} @@ -145,17 +142,16 @@ class EmailRegisterResetApi(Resource): email = register_data.get("email", "") normalized_email = email.lower() - with sessionmaker(db.engine).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(email, session=session) + account = AccountService.get_account_by_email_with_case_fallback(email) - if account: - raise EmailAlreadyInUseError() - else: - account = self._create_new_account(normalized_email, args.password_confirm) - if not account: - raise AccountNotFoundError() - token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request)) - AccountService.reset_login_error_rate_limit(normalized_email) + if account: + raise EmailAlreadyInUseError() + else: + account = self._create_new_account(normalized_email, args.password_confirm) + if not account: + raise AccountNotFoundError() + token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request)) + AccountService.reset_login_error_rate_limit(normalized_email) return {"result": "success", "data": token_pair.model_dump()} diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py index 63bc98b53f..ed390a5f89 100644 --- a/api/controllers/console/auth/forgot_password.py +++ b/api/controllers/console/auth/forgot_password.py @@ -4,7 +4,6 @@ import secrets from flask import request from flask_restx import Resource from pydantic import BaseModel, Field -from sqlalchemy.orm import sessionmaker from controllers.common.schema import register_schema_models from controllers.console import console_ns @@ -85,8 +84,7 @@ class ForgotPasswordSendEmailApi(Resource): else: language = "en-US" - with sessionmaker(db.engine).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session) + account = AccountService.get_account_by_email_with_case_fallback(args.email) token = AccountService.send_reset_password_email( account=account, @@ -184,17 +182,18 @@ class ForgotPasswordResetApi(Resource): password_hashed = hash_password(args.new_password, salt) email = reset_data.get("email", "") - with sessionmaker(db.engine).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(email, session=session) + account = AccountService.get_account_by_email_with_case_fallback(email) - if account: - self._update_existing_account(account, password_hashed, salt, session) - else: - raise AccountNotFound() + if account: + account = db.session.merge(account) + self._update_existing_account(account, password_hashed, salt) + db.session.commit() + else: + raise AccountNotFound() return {"result": "success"} - def _update_existing_account(self, account, password_hashed, salt, session): + def _update_existing_account(self, account, password_hashed, salt): # Update existing account credentials account.password = base64.b64encode(password_hashed).decode() account.password_salt = base64.b64encode(salt).decode() diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 962cc83b0e..8216b3d0da 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -1,7 +1,10 @@ +import logging + import flask_login from flask import make_response, request from flask_restx import Resource from pydantic import BaseModel, Field +from werkzeug.exceptions import Unauthorized import services from configs import dify_config @@ -42,12 +45,13 @@ from libs.token import ( ) from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService from services.billing_service import BillingService -from services.entities.auth_entities import LoginPayloadBase +from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase from services.errors.account import AccountRegisterError from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" +logger = logging.getLogger(__name__) class LoginPayload(LoginPayloadBase): @@ -91,10 +95,12 @@ class LoginApi(Resource): normalized_email = request_email.lower() if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email): + _log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE) raise AccountInFreezeError() is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email) if is_login_error_rate_limit: + _log_console_login_failure(email=normalized_email, reason=LoginFailureReason.LOGIN_RATE_LIMITED) raise EmailPasswordLoginLimitError() invite_token = args.invite_token @@ -110,14 +116,20 @@ class LoginApi(Resource): invitee_email = data.get("email") if data else None invitee_email_normalized = invitee_email.lower() if isinstance(invitee_email, str) else invitee_email if invitee_email_normalized != normalized_email: + _log_console_login_failure( + email=normalized_email, + reason=LoginFailureReason.INVALID_INVITATION_EMAIL, + ) raise InvalidEmailError() account = _authenticate_account_with_case_fallback( request_email, normalized_email, args.password, invite_token ) except services.errors.account.AccountLoginError: + _log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_BANNED) raise AccountBannedError() except services.errors.account.AccountPasswordError as exc: AccountService.add_login_error_rate_limit(normalized_email) + _log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS) raise AuthenticationFailedError() from exc # SELF_HOSTED only have one workspace tenants = TenantService.get_join_tenants(account) @@ -240,20 +252,27 @@ class EmailCodeLoginApi(Resource): token_data = AccountService.get_email_code_login_data(args.token) if token_data is None: + _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN) raise InvalidTokenError() token_email = token_data.get("email") normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email if normalized_token_email != user_email: + _log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH) raise InvalidEmailError() if token_data["code"] != args.code: + _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE) raise EmailCodeError() AccountService.revoke_email_code_login_token(args.token) try: account = _get_account_with_case_fallback(original_email) + except Unauthorized as exc: + _log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED) + raise AccountBannedError() from exc except AccountRegisterError: + _log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE) raise AccountInFreezeError() if account: tenants = TenantService.get_join_tenants(account) @@ -279,6 +298,7 @@ class EmailCodeLoginApi(Resource): except WorkSpaceNotAllowedCreateError: raise NotAllowedCreateWorkspace() except AccountRegisterError: + _log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE) raise AccountInFreezeError() except WorkspacesLimitExceededError: raise WorkspacesLimitExceeded() @@ -336,3 +356,12 @@ def _authenticate_account_with_case_fallback( if original_email == normalized_email: raise return AccountService.authenticate(normalized_email, password, invite_token) + + +def _log_console_login_failure(*, email: str, reason: LoginFailureReason) -> None: + logger.warning( + "Console login failed: email=%s reason=%s ip_address=%s", + email, + reason, + extract_remote_ip(request), + ) diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 5c7011fd22..d31fb4a46c 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -4,7 +4,6 @@ import urllib.parse import httpx from flask import current_app, redirect, request from flask_restx import Resource -from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Unauthorized from configs import dify_config @@ -180,8 +179,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> account: Account | None = Account.get_by_openid(provider, user_info.id) if not account: - with sessionmaker(db.engine).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(user_info.email, session=session) + account = AccountService.get_account_by_email_with_case_fallback(user_info.email) return account diff --git a/api/controllers/console/billing/billing.py b/api/controllers/console/billing/billing.py index 23c01eedb1..45de338559 100644 --- a/api/controllers/console/billing/billing.py +++ b/api/controllers/console/billing/billing.py @@ -2,18 +2,17 @@ import base64 from typing import Literal from flask import request -from flask_restx import Resource, fields +from flask_restx import Resource from pydantic import BaseModel, Field from werkzeug.exceptions import BadRequest +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required from enums.cloud_plan import CloudPlan from libs.login import current_account_with_tenant, login_required from services.billing_service import BillingService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class SubscriptionQuery(BaseModel): plan: Literal[CloudPlan.PROFESSIONAL, CloudPlan.TEAM] = Field(..., description="Subscription plan") @@ -24,8 +23,7 @@ class PartnerTenantsPayload(BaseModel): click_id: str = Field(..., description="Click Id from partner referral link") -for model in (SubscriptionQuery, PartnerTenantsPayload): - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) +register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload) @console_ns.route("/billing/subscription") @@ -58,12 +56,7 @@ class PartnerTenants(Resource): @console_ns.doc("sync_partner_tenants_bindings") @console_ns.doc(description="Sync partner tenants bindings") @console_ns.doc(params={"partner_key": "Partner key"}) - @console_ns.expect( - console_ns.model( - "SyncPartnerTenantsBindingsRequest", - {"click_id": fields.String(required=True, description="Click Id from partner referral link")}, - ) - ) + @console_ns.expect(console_ns.models[PartnerTenantsPayload.__name__]) @console_ns.response(200, "Tenants synced to partner successfully") @console_ns.response(400, "Invalid partner information") @setup_required diff --git a/api/controllers/console/datasets/data_source.py b/api/controllers/console/datasets/data_source.py index e623722b23..ed3c1a59d4 100644 --- a/api/controllers/console/datasets/data_source.py +++ b/api/controllers/console/datasets/data_source.py @@ -162,7 +162,9 @@ class DataSourceApi(Resource): binding_id = str(binding_id) with sessionmaker(db.engine, expire_on_commit=False).begin() as session: data_source_binding = session.execute( - select(DataSourceOauthBinding).filter_by(id=binding_id, tenant_id=current_tenant_id) + select(DataSourceOauthBinding).where( + DataSourceOauthBinding.id == binding_id, DataSourceOauthBinding.tenant_id == current_tenant_id + ) ).scalar_one_or_none() if data_source_binding is None: raise NotFound("Data source binding not found.") @@ -222,11 +224,11 @@ class DataSourceNotionListApi(Resource): raise ValueError("Dataset is not notion type.") documents = session.scalars( - select(Document).filter_by( - dataset_id=query.dataset_id, - tenant_id=current_tenant_id, - data_source_type="notion_import", - enabled=True, + select(Document).where( + Document.dataset_id == query.dataset_id, + Document.tenant_id == current_tenant_id, + Document.data_source_type == "notion_import", + Document.enabled.is_(True), ) ).all() if documents: diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index f23c7eb431..b2a905366a 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -11,10 +11,7 @@ import services from configs import dify_config from controllers.common.schema import get_or_create_model, register_schema_models from controllers.console import console_ns -from controllers.console.apikey import ( - api_key_item_model, - api_key_list_model, -) +from controllers.console.apikey import ApiKeyItem, ApiKeyList from controllers.console.app.error import ProviderNotInitializeError from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError from controllers.console.wraps import ( @@ -785,23 +782,23 @@ class DatasetApiKeyApi(Resource): @console_ns.doc("get_dataset_api_keys") @console_ns.doc(description="Get dataset API keys") - @console_ns.response(200, "API keys retrieved successfully", api_key_list_model) + @console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__]) @setup_required @login_required @account_initialization_required - @marshal_with(api_key_list_model) def get(self): _, current_tenant_id = current_account_with_tenant() keys = db.session.scalars( select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id) ).all() - return {"items": keys} + return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json") + @console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) + @console_ns.response(400, "Maximum keys exceeded") @setup_required @login_required @is_admin_or_owner_required @account_initialization_required - @marshal_with(api_key_item_model) def post(self): _, current_tenant_id = current_account_with_tenant() @@ -828,7 +825,7 @@ class DatasetApiKeyApi(Resource): api_token.type = self.resource_type db.session.add(api_token) db.session.commit() - return api_token, 200 + return ApiKeyItem.model_validate(api_token, from_attributes=True).model_dump(mode="json"), 200 @console_ns.route("/datasets/api-keys/") diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index ab367d8483..de8fe1c0e2 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -4,7 +4,6 @@ from argparse import ArgumentTypeError from collections.abc import Sequence from contextlib import ExitStack from typing import Any, Literal, cast -from uuid import UUID import sqlalchemy as sa from flask import request, send_file @@ -16,6 +15,7 @@ from sqlalchemy import asc, desc, func, select from werkzeug.exceptions import Forbidden, NotFound import services +from controllers.common.controller_schemas import DocumentBatchDownloadZipPayload from controllers.common.schema import get_or_create_model, register_schema_models from controllers.console import console_ns from core.errors.error import ( @@ -71,9 +71,6 @@ from ..wraps import ( logger = logging.getLogger(__name__) -# NOTE: Keep constants near the top of the module for discoverability. -DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100 - # Register models for flask_restx to avoid dict type issues in Swagger dataset_model = get_or_create_model("Dataset", dataset_fields) @@ -110,12 +107,6 @@ class GenerateSummaryPayload(BaseModel): document_list: list[str] -class DocumentBatchDownloadZipPayload(BaseModel): - """Request payload for bulk downloading documents as a zip archive.""" - - document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS) - - class DocumentDatasetListParam(BaseModel): page: int = Field(1, title="Page", description="Page number.") limit: int = Field(20, title="Limit", description="Page size.") @@ -280,7 +271,7 @@ class DatasetDocumentListApi(Resource): except services.errors.account.NoPermissionError as e: raise Forbidden(str(e)) - query = select(Document).filter_by(dataset_id=str(dataset_id), tenant_id=current_tenant_id) + query = select(Document).where(Document.dataset_id == str(dataset_id), Document.tenant_id == current_tenant_id) if status: query = DocumentService.apply_display_status_filter(query, status) diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py index c5f4e3a6e2..354c299bef 100644 --- a/api/controllers/console/datasets/datasets_segments.py +++ b/api/controllers/console/datasets/datasets_segments.py @@ -10,6 +10,7 @@ from werkzeug.exceptions import Forbidden, NotFound import services from configs import dify_config +from controllers.common.controller_schemas import ChildChunkCreatePayload, ChildChunkUpdatePayload from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ProviderNotInitializeError @@ -82,14 +83,6 @@ class BatchImportPayload(BaseModel): upload_file_id: str -class ChildChunkCreatePayload(BaseModel): - content: str - - -class ChildChunkUpdatePayload(BaseModel): - content: str - - class ChildChunkBatchUpdatePayload(BaseModel): chunks: list[ChildChunkUpdateArgs] diff --git a/api/controllers/console/datasets/external.py b/api/controllers/console/datasets/external.py index f3866f6aef..e513e8c8f9 100644 --- a/api/controllers/console/datasets/external.py +++ b/api/controllers/console/datasets/external.py @@ -227,10 +227,11 @@ class ExternalApiUseCheckApi(Resource): @login_required @account_initialization_required def get(self, external_knowledge_api_id): + _, current_tenant_id = current_account_with_tenant() external_knowledge_api_id = str(external_knowledge_api_id) external_knowledge_api_is_using, count = ExternalDatasetService.external_knowledge_api_use_check( - external_knowledge_api_id + external_knowledge_api_id, current_tenant_id ) return {"is_using": external_knowledge_api_is_using, "count": count}, 200 diff --git a/api/controllers/console/datasets/metadata.py b/api/controllers/console/datasets/metadata.py index 2e69ddc5ab..d966e1629e 100644 --- a/api/controllers/console/datasets/metadata.py +++ b/api/controllers/console/datasets/metadata.py @@ -1,9 +1,9 @@ from typing import Literal from flask_restx import Resource, marshal_with -from pydantic import BaseModel from werkzeug.exceptions import NotFound +from controllers.common.controller_schemas import MetadataUpdatePayload from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required @@ -18,11 +18,6 @@ from services.entities.knowledge_entities.knowledge_entities import ( ) from services.metadata_service import MetadataService - -class MetadataUpdatePayload(BaseModel): - name: str - - register_schema_models( console_ns, MetadataArgs, MetadataOperationData, MetadataUpdatePayload, DocumentMetadataOperation, MetadataDetail ) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py index 93feec0019..3549f9542d 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py @@ -223,24 +223,27 @@ class RagPipelineVariableApi(Resource): new_value = None if raw_value is not None: - if variable.value_type == SegmentType.FILE: - if not isinstance(raw_value, dict): - raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( - mapping=raw_value, - tenant_id=pipeline.tenant_id, - access_controller=_file_access_controller, - ) - elif variable.value_type == SegmentType.ARRAY_FILE: - if not isinstance(raw_value, list): - raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( - mappings=raw_value, - tenant_id=pipeline.tenant_id, - access_controller=_file_access_controller, - ) + match variable.value_type: + case SegmentType.FILE: + if not isinstance(raw_value, dict): + raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") + raw_value = build_from_mapping( + mapping=raw_value, + tenant_id=pipeline.tenant_id, + access_controller=_file_access_controller, + ) + case SegmentType.ARRAY_FILE: + if not isinstance(raw_value, list): + raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") + if len(raw_value) > 0 and not isinstance(raw_value[0], dict): + raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") + raw_value = build_from_mappings( + mappings=raw_value, + tenant_id=pipeline.tenant_id, + access_controller=_file_access_controller, + ) + case _: + pass new_value = build_segment_with_type(variable.value_type, raw_value) draft_var_srv.update_variable(variable, name=new_name, value=new_value) db.session.commit() diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py index 732a6dc446..aa27458176 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py @@ -19,7 +19,7 @@ from fields.rag_pipeline_fields import ( ) from libs.login import current_account_with_tenant, login_required from models.dataset import Pipeline -from services.app_dsl_service import ImportStatus +from services.entities.dsl_entities import ImportStatus from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineDslService @@ -83,11 +83,13 @@ class RagPipelineImportApi(Resource): # Return appropriate status code based on result status = result.status - if status == ImportStatus.FAILED: - return result.model_dump(mode="json"), 400 - elif status == ImportStatus.PENDING: - return result.model_dump(mode="json"), 202 - return result.model_dump(mode="json"), 200 + match status: + case ImportStatus.FAILED: + return result.model_dump(mode="json"), 400 + case ImportStatus.PENDING: + return result.model_dump(mode="json"), 202 + case ImportStatus.COMPLETED | ImportStatus.COMPLETED_WITH_WARNINGS: + return result.model_dump(mode="json"), 200 @console_ns.route("/rag/pipelines/imports//confirm") diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 70dfe47d7f..a8077d9eb0 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services +from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( @@ -94,22 +95,6 @@ class PublishedWorkflowRunPayload(DraftWorkflowRunPayload): original_document_id: str | None = None -class DefaultBlockConfigQuery(BaseModel): - q: str | None = None - - -class WorkflowListQuery(BaseModel): - page: int = Field(default=1, ge=1, le=99999) - limit: int = Field(default=10, ge=1, le=100) - user_id: str | None = None - named_only: bool = False - - -class WorkflowUpdatePayload(BaseModel): - marked_name: str | None = Field(default=None, max_length=20) - marked_comment: str | None = Field(default=None, max_length=100) - - class NodeIdQuery(BaseModel): node_id: str @@ -361,89 +346,6 @@ class PublishedRagPipelineRunApi(Resource): raise InvokeRateLimitHttpError(ex.description) -# class RagPipelinePublishedDatasourceNodeRunStatusApi(Resource): -# @setup_required -# @login_required -# @account_initialization_required -# @get_rag_pipeline -# def post(self, pipeline: Pipeline, node_id: str): -# """ -# Run rag pipeline datasource -# """ -# # The role of the current user in the ta table must be admin, owner, or editor -# if not current_user.has_edit_permission: -# raise Forbidden() -# -# if not isinstance(current_user, Account): -# raise Forbidden() -# -# parser = (reqparse.RequestParser() -# .add_argument("job_id", type=str, required=True, nullable=False, location="json") -# .add_argument("datasource_type", type=str, required=True, location="json") -# ) -# args = parser.parse_args() -# -# job_id = args.get("job_id") -# if job_id == None: -# raise ValueError("missing job_id") -# datasource_type = args.get("datasource_type") -# if datasource_type == None: -# raise ValueError("missing datasource_type") -# -# rag_pipeline_service = RagPipelineService() -# result = rag_pipeline_service.run_datasource_workflow_node_status( -# pipeline=pipeline, -# node_id=node_id, -# job_id=job_id, -# account=current_user, -# datasource_type=datasource_type, -# is_published=True -# ) -# -# return result - - -# class RagPipelineDraftDatasourceNodeRunStatusApi(Resource): -# @setup_required -# @login_required -# @account_initialization_required -# @get_rag_pipeline -# def post(self, pipeline: Pipeline, node_id: str): -# """ -# Run rag pipeline datasource -# """ -# # The role of the current user in the ta table must be admin, owner, or editor -# if not current_user.has_edit_permission: -# raise Forbidden() -# -# if not isinstance(current_user, Account): -# raise Forbidden() -# -# parser = (reqparse.RequestParser() -# .add_argument("job_id", type=str, required=True, nullable=False, location="json") -# .add_argument("datasource_type", type=str, required=True, location="json") -# ) -# args = parser.parse_args() -# -# job_id = args.get("job_id") -# if job_id == None: -# raise ValueError("missing job_id") -# datasource_type = args.get("datasource_type") -# if datasource_type == None: -# raise ValueError("missing datasource_type") -# -# rag_pipeline_service = RagPipelineService() -# result = rag_pipeline_service.run_datasource_workflow_node_status( -# pipeline=pipeline, -# node_id=node_id, -# job_id=job_id, -# account=current_user, -# datasource_type=datasource_type, -# is_published=False -# ) -# -# return result -# @console_ns.route("/rag/pipelines//workflows/published/datasource/nodes//run") class RagPipelinePublishedDatasourceNodeRunApi(Resource): @console_ns.expect(console_ns.models[DatasourceNodeRunPayload.__name__]) diff --git a/api/controllers/console/explore/audio.py b/api/controllers/console/explore/audio.py index b1b01b5f51..a37077af42 100644 --- a/api/controllers/console/explore/audio.py +++ b/api/controllers/console/explore/audio.py @@ -2,10 +2,10 @@ import logging from flask import request from graphon.model_runtime.errors.invoke import InvokeError -from pydantic import BaseModel, Field from werkzeug.exceptions import InternalServerError import services +from controllers.common.controller_schemas import TextToAudioPayload from controllers.common.schema import register_schema_model from controllers.console.app.error import ( AppUnavailableError, @@ -32,14 +32,6 @@ from .. import console_ns logger = logging.getLogger(__name__) - -class TextToAudioPayload(BaseModel): - message_id: str | None = None - voice: str | None = None - text: str | None = None - streaming: bool | None = Field(default=None, description="Enable streaming response") - - register_schema_model(console_ns, TextToAudioPayload) diff --git a/api/controllers/console/explore/conversation.py b/api/controllers/console/explore/conversation.py index 092f509f1c..2eb2054e64 100644 --- a/api/controllers/console/explore/conversation.py +++ b/api/controllers/console/explore/conversation.py @@ -1,10 +1,11 @@ from typing import Any from flask import request -from pydantic import BaseModel, Field, TypeAdapter, model_validator +from pydantic import BaseModel, Field, TypeAdapter from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound +from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import register_schema_models from controllers.console.explore.error import NotChatAppError from controllers.console.explore.wraps import InstalledAppResource @@ -32,18 +33,6 @@ class ConversationListQuery(BaseModel): pinned: bool | None = None -class ConversationRenamePayload(BaseModel): - name: str | None = None - auto_generate: bool = False - - @model_validator(mode="after") - def validate_name_requirement(self): - if not self.auto_generate: - if self.name is None or not self.name.strip(): - raise ValueError("name is required when auto_generate is false") - return self - - register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayload) diff --git a/api/controllers/console/explore/message.py b/api/controllers/console/explore/message.py index fcbefcda33..64d55d7ca3 100644 --- a/api/controllers/console/explore/message.py +++ b/api/controllers/console/explore/message.py @@ -3,9 +3,10 @@ from typing import Literal from flask import request from graphon.model_runtime.errors.invoke import InvokeError -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import BaseModel, TypeAdapter from werkzeug.exceptions import InternalServerError, NotFound +from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery from controllers.common.schema import register_schema_models from controllers.console.app.error import ( AppMoreLikeThisDisabledError, @@ -25,7 +26,6 @@ from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotIni from fields.conversation_fields import ResultResponse from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem, SuggestedQuestionsResponse from libs import helper -from libs.helper import UUIDStrOrEmpty from libs.login import current_account_with_tenant from models.enums import FeedbackRating from models.model import AppMode @@ -44,17 +44,6 @@ from .. import console_ns logger = logging.getLogger(__name__) -class MessageListQuery(BaseModel): - conversation_id: UUIDStrOrEmpty - first_id: UUIDStrOrEmpty | None = None - limit: int = Field(default=20, ge=1, le=100) - - -class MessageFeedbackPayload(BaseModel): - rating: Literal["like", "dislike"] | None = None - content: str | None = None - - class MoreLikeThisQuery(BaseModel): response_mode: Literal["blocking", "streaming"] diff --git a/api/controllers/console/explore/saved_message.py b/api/controllers/console/explore/saved_message.py index ea3de91741..9ec4e82324 100644 --- a/api/controllers/console/explore/saved_message.py +++ b/api/controllers/console/explore/saved_message.py @@ -1,28 +1,18 @@ from flask import request -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import TypeAdapter from werkzeug.exceptions import NotFound +from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.explore.error import NotCompletionAppError from controllers.console.explore.wraps import InstalledAppResource from fields.conversation_fields import ResultResponse from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem -from libs.helper import UUIDStrOrEmpty from libs.login import current_account_with_tenant from services.errors.message import MessageNotExistsError from services.saved_message_service import SavedMessageService - -class SavedMessageListQuery(BaseModel): - last_id: UUIDStrOrEmpty | None = None - limit: int = Field(default=20, ge=1, le=100) - - -class SavedMessageCreatePayload(BaseModel): - message_id: UUIDStrOrEmpty - - register_schema_models(console_ns, SavedMessageListQuery, SavedMessageCreatePayload) diff --git a/api/controllers/console/explore/workflow.py b/api/controllers/console/explore/workflow.py index 42cafc7193..da88de6776 100644 --- a/api/controllers/console/explore/workflow.py +++ b/api/controllers/console/explore/workflow.py @@ -1,11 +1,10 @@ import logging -from typing import Any from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.errors.invoke import InvokeError -from pydantic import BaseModel from werkzeug.exceptions import InternalServerError +from controllers.common.controller_schemas import WorkflowRunPayload from controllers.common.schema import register_schema_model from controllers.console.app.error import ( CompletionRequestError, @@ -34,12 +33,6 @@ from .. import console_ns logger = logging.getLogger(__name__) - -class WorkflowRunPayload(BaseModel): - inputs: dict[str, Any] - files: list[dict[str, Any]] | None = None - - register_schema_model(console_ns, WorkflowRunPayload) diff --git a/api/controllers/console/human_input_form.py b/api/controllers/console/human_input_form.py index e37e78c966..845af37365 100644 --- a/api/controllers/console/human_input_form.py +++ b/api/controllers/console/human_input_form.py @@ -7,7 +7,8 @@ import logging from collections.abc import Generator from flask import Response, jsonify, request -from flask_restx import Resource, reqparse +from flask_restx import Resource +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker @@ -33,6 +34,11 @@ from services.workflow_event_snapshot_service import build_workflow_event_stream logger = logging.getLogger(__name__) +class HumanInputFormSubmitPayload(BaseModel): + inputs: dict + action: str + + def _jsonify_form_definition(form: Form) -> Response: payload = form.get_definition().model_dump() payload["expiration_time"] = int(form.expiration_time.timestamp()) @@ -84,10 +90,7 @@ class ConsoleHumanInputFormApi(Resource): "action": "Approve" } """ - parser = reqparse.RequestParser() - parser.add_argument("inputs", type=dict, required=True, location="json") - parser.add_argument("action", type=str, required=True, location="json") - args = parser.parse_args() + payload = HumanInputFormSubmitPayload.model_validate(request.get_json()) current_user, _ = current_account_with_tenant() service = HumanInputService(db.engine) @@ -107,8 +110,8 @@ class ConsoleHumanInputFormApi(Resource): service.submit_form_by_token( recipient_type=recipient_type, form_token=form_token, - selected_action_id=args["action"], - form_data=args["inputs"], + selected_action_id=payload.action, + form_data=payload.inputs, submission_user_id=current_user.id, ) @@ -168,12 +171,13 @@ class ConsoleWorkflowEventsApi(Resource): else: msg_generator = MessageGenerator() generator: BaseAppGenerator - if app.mode == AppMode.ADVANCED_CHAT: - generator = AdvancedChatAppGenerator() - elif app.mode == AppMode.WORKFLOW: - generator = WorkflowAppGenerator() - else: - raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}") + match app.mode: + case AppMode.ADVANCED_CHAT: + generator = AdvancedChatAppGenerator() + case AppMode.WORKFLOW: + generator = WorkflowAppGenerator() + case _: + raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}") include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" diff --git a/api/controllers/console/notification.py b/api/controllers/console/notification.py index 180167402a..5d46470173 100644 --- a/api/controllers/console/notification.py +++ b/api/controllers/console/notification.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import TypedDict from flask import request @@ -13,6 +14,14 @@ from services.billing_service import BillingService _FALLBACK_LANG = "en-US" +class NotificationLangContent(TypedDict, total=False): + lang: str + title: str + subtitle: str + body: str + titlePicUrl: str + + class NotificationItemDict(TypedDict): notification_id: str | None frequency: str | None @@ -28,9 +37,11 @@ class NotificationResponseDict(TypedDict): notifications: list[NotificationItemDict] -def _pick_lang_content(contents: dict, lang: str) -> dict: +def _pick_lang_content(contents: Mapping[str, NotificationLangContent], lang: str) -> NotificationLangContent: """Return the single LangContent for *lang*, falling back to English.""" - return contents.get(lang) or contents.get(_FALLBACK_LANG) or next(iter(contents.values()), {}) + return ( + contents.get(lang) or contents.get(_FALLBACK_LANG) or next(iter(contents.values()), NotificationLangContent()) + ) class DismissNotificationPayload(BaseModel): @@ -71,7 +82,7 @@ class NotificationApi(Resource): notifications: list[NotificationItemDict] = [] for notification in result.get("notifications") or []: - contents: dict = notification.get("contents") or {} + contents: Mapping[str, NotificationLangContent] = notification.get("contents") or {} lang_content = _pick_lang_content(contents, lang) item: NotificationItemDict = { "notification_id": notification.get("notificationId"), diff --git a/api/controllers/console/workspace/__init__.py b/api/controllers/console/workspace/__init__.py index 60f712e476..59dd29fdac 100644 --- a/api/controllers/console/workspace/__init__.py +++ b/api/controllers/console/workspace/__init__.py @@ -35,22 +35,24 @@ def plugin_permission_required( return view(*args, **kwargs) if install_required: - if permission.install_permission == TenantPluginPermission.InstallPermission.NOBODY: - raise Forbidden() - if permission.install_permission == TenantPluginPermission.InstallPermission.ADMINS: - if not user.is_admin_or_owner: + match permission.install_permission: + case TenantPluginPermission.InstallPermission.NOBODY: raise Forbidden() - if permission.install_permission == TenantPluginPermission.InstallPermission.EVERYONE: - pass + case TenantPluginPermission.InstallPermission.ADMINS: + if not user.is_admin_or_owner: + raise Forbidden() + case TenantPluginPermission.InstallPermission.EVERYONE: + pass if debug_required: - if permission.debug_permission == TenantPluginPermission.DebugPermission.NOBODY: - raise Forbidden() - if permission.debug_permission == TenantPluginPermission.DebugPermission.ADMINS: - if not user.is_admin_or_owner: + match permission.debug_permission: + case TenantPluginPermission.DebugPermission.NOBODY: raise Forbidden() - if permission.debug_permission == TenantPluginPermission.DebugPermission.EVERYONE: - pass + case TenantPluginPermission.DebugPermission.ADMINS: + if not user.is_admin_or_owner: + raise Forbidden() + case TenantPluginPermission.DebugPermission.EVERYONE: + pass return view(*args, **kwargs) diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 626d330e9d..c35006a7ee 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -1,14 +1,13 @@ from __future__ import annotations from datetime import datetime -from typing import Literal +from typing import Any, Literal import pytz from flask import request from flask_restx import Resource, fields, marshal_with from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import select -from sqlalchemy.orm import sessionmaker from configs import dify_config from constants.languages import supported_language @@ -175,7 +174,7 @@ reg(CheckEmailUniquePayload) register_schema_models(console_ns, AccountResponse) -def _serialize_account(account) -> dict: +def _serialize_account(account) -> dict[str, Any]: return AccountResponse.model_validate(account, from_attributes=True).model_dump(mode="json") @@ -562,8 +561,7 @@ class ChangeEmailSendEmailApi(Resource): user_email = current_user.email else: - with sessionmaker(db.engine).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session) + account = AccountService.get_account_by_email_with_case_fallback(args.email) if account is None: raise AccountNotFound() email_for_sending = account.email diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 4b5fb7ca5b..ef2931ce9b 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -20,7 +20,7 @@ from models.account import AccountStatus from models.dataset import RateLimitLog from models.model import DifySetup from services.feature_service import FeatureService, LicenseStatus -from services.operation_service import OperationService +from services.operation_service import OperationService, UtmInfo from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout @@ -205,7 +205,7 @@ def cloud_utm_record[**P, R](view: Callable[P, R]) -> Callable[P, R]: utm_info = request.cookies.get("utm_info") if utm_info: - utm_info_dict: dict = json.loads(utm_info) + utm_info_dict: UtmInfo = json.loads(utm_info) OperationService.record_utm(current_tenant_id, utm_info_dict) return view(*args, **kwargs) diff --git a/api/controllers/inner_api/app/dsl.py b/api/controllers/inner_api/app/dsl.py index b1986b2557..6c15f9aa8b 100644 --- a/api/controllers/inner_api/app/dsl.py +++ b/api/controllers/inner_api/app/dsl.py @@ -18,7 +18,8 @@ from controllers.inner_api.wraps import enterprise_inner_api_only from extensions.ext_database import db from models import Account, App from models.account import AccountStatus -from services.app_dsl_service import AppDslService, ImportMode, ImportStatus +from services.app_dsl_service import AppDslService +from services.entities.dsl_entities import ImportMode, ImportStatus class InnerAppDSLImportPayload(BaseModel): diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index 1d378c754c..a5846e2815 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -94,10 +94,9 @@ def get_user_tenant[**P, R](view_func: Callable[P, R]) -> Callable[P, R]: def plugin_data[**P, R]( - view: Callable[P, R] | None = None, *, payload_type: type[BaseModel], -) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: +) -> Callable[[Callable[P, R]], Callable[P, R]]: def decorator(view_func: Callable[P, R]) -> Callable[P, R]: @wraps(view_func) def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R: @@ -116,7 +115,4 @@ def plugin_data[**P, R]( return decorated_view - if view is None: - return decorator - else: - return decorator(view) + return decorator diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py index d2ce0ea543..8066f198bb 100644 --- a/api/controllers/mcp/mcp.py +++ b/api/controllers/mcp/mcp.py @@ -2,7 +2,7 @@ from typing import Any, Union from flask import Response from flask_restx import Resource -from graphon.variables.input_entities import VariableEntity +from graphon.variables.input_entities import VariableEntity, VariableEntityType from pydantic import BaseModel, Field, ValidationError from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker @@ -158,14 +158,20 @@ class MCPAppApi(Resource): except ValidationError as e: raise MCPRequestError(mcp_types.INVALID_PARAMS, f"Invalid user_input_form: {str(e)}") - def _convert_user_input_form(self, raw_form: list[dict]) -> list[VariableEntity]: + def _convert_user_input_form(self, raw_form: list[dict[str, Any]]) -> list[VariableEntity]: """Convert raw user input form to VariableEntity objects""" return [self._create_variable_entity(item) for item in raw_form] - def _create_variable_entity(self, item: dict) -> VariableEntity: + def _create_variable_entity(self, item: dict[str, Any]) -> VariableEntity: """Create a single VariableEntity from raw form item""" - variable_type = item.get("type", "") or list(item.keys())[0] - variable = item[variable_type] + variable_type_raw: str = item.get("type", "") or list(item.keys())[0] + try: + variable_type = VariableEntityType(variable_type_raw) + except ValueError as e: + raise MCPRequestError( + mcp_types.INVALID_PARAMS, f"Invalid user_input_form variable type: {variable_type_raw}" + ) from e + variable = item[variable_type_raw] return VariableEntity( type=variable_type, @@ -178,7 +184,7 @@ class MCPAppApi(Resource): json_schema=variable.get("json_schema"), ) - def _parse_mcp_request(self, args: dict) -> mcp_types.ClientRequest | mcp_types.ClientNotification: + def _parse_mcp_request(self, args: dict[str, Any]) -> mcp_types.ClientRequest | mcp_types.ClientNotification: """Parse and validate MCP request""" try: return mcp_types.ClientRequest.model_validate(args) diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py index c22190cbc9..00bb9aa463 100644 --- a/api/controllers/service_api/app/annotation.py +++ b/api/controllers/service_api/app/annotation.py @@ -12,7 +12,12 @@ from controllers.service_api.wraps import validate_app_token from extensions.ext_redis import redis_client from fields.annotation_fields import Annotation, AnnotationList from models.model import App -from services.annotation_service import AppAnnotationService +from services.annotation_service import ( + AppAnnotationService, + EnableAnnotationArgs, + InsertAnnotationArgs, + UpdateAnnotationArgs, +) class AnnotationCreatePayload(BaseModel): @@ -46,10 +51,15 @@ class AnnotationReplyActionApi(Resource): @validate_app_token def post(self, app_model: App, action: Literal["enable", "disable"]): """Enable or disable annotation reply feature.""" - args = AnnotationReplyActionPayload.model_validate(service_api_ns.payload or {}).model_dump() + payload = AnnotationReplyActionPayload.model_validate(service_api_ns.payload or {}) match action: case "enable": - result = AppAnnotationService.enable_app_annotation(args, app_model.id) + enable_args: EnableAnnotationArgs = { + "score_threshold": payload.score_threshold, + "embedding_provider_name": payload.embedding_provider_name, + "embedding_model_name": payload.embedding_model_name, + } + result = AppAnnotationService.enable_app_annotation(enable_args, app_model.id) case "disable": result = AppAnnotationService.disable_app_annotation(app_model.id) return result, 200 @@ -135,8 +145,9 @@ class AnnotationListApi(Resource): @validate_app_token def post(self, app_model: App): """Create a new annotation.""" - args = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}).model_dump() - annotation = AppAnnotationService.insert_app_annotation_directly(args, app_model.id) + payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}) + insert_args: InsertAnnotationArgs = {"question": payload.question, "answer": payload.answer} + annotation = AppAnnotationService.insert_app_annotation_directly(insert_args, app_model.id) response = Annotation.model_validate(annotation, from_attributes=True) return response.model_dump(mode="json"), HTTPStatus.CREATED @@ -164,8 +175,9 @@ class AnnotationUpdateDeleteApi(Resource): @edit_permission_required def put(self, app_model: App, annotation_id: str): """Update an existing annotation.""" - args = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}).model_dump() - annotation = AppAnnotationService.update_app_annotation_directly(args, app_model.id, annotation_id) + payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}) + update_args: UpdateAnnotationArgs = {"question": payload.question, "answer": payload.answer} + annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_model.id, annotation_id) response = Annotation.model_validate(annotation, from_attributes=True) return response.model_dump(mode="json") diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py index 6228cfc25b..907dd1b06d 100644 --- a/api/controllers/service_api/app/audio.py +++ b/api/controllers/service_api/app/audio.py @@ -3,10 +3,10 @@ import logging from flask import request from flask_restx import Resource from graphon.model_runtime.errors.invoke import InvokeError -from pydantic import BaseModel, Field from werkzeug.exceptions import InternalServerError import services +from controllers.common.controller_schemas import TextToAudioPayload from controllers.common.schema import register_schema_model from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( @@ -86,13 +86,6 @@ class AudioApi(Resource): raise InternalServerError() -class TextToAudioPayload(BaseModel): - message_id: str | None = Field(default=None, description="Message ID") - voice: str | None = Field(default=None, description="Voice to use for TTS") - text: str | None = Field(default=None, description="Text to convert to audio") - streaming: bool | None = Field(default=None, description="Enable streaming response") - - register_schema_model(service_api_ns, TextToAudioPayload) diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index 8c9a3eb5e9..1ec289e2a2 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -2,11 +2,12 @@ from typing import Any, Literal from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator +from pydantic import BaseModel, Field, TypeAdapter, field_validator from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, NotFound import services +from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError @@ -34,18 +35,6 @@ class ConversationListQuery(BaseModel): ) -class ConversationRenamePayload(BaseModel): - name: str | None = Field(default=None, description="New conversation name (required if auto_generate is false)") - auto_generate: bool = Field(default=False, description="Auto-generate conversation name") - - @model_validator(mode="after") - def validate_name_requirement(self): - if not self.auto_generate: - if self.name is None or not self.name.strip(): - raise ValueError("name is required when auto_generate is false") - return self - - class ConversationVariablesQuery(BaseModel): last_id: UUIDStrOrEmpty | None = Field(default=None, description="Last variable ID for pagination") limit: int = Field(default=20, ge=1, le=100, description="Number of variables to return") diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py index 77fee9c142..b75b299f6f 100644 --- a/api/controllers/service_api/app/message.py +++ b/api/controllers/service_api/app/message.py @@ -1,5 +1,4 @@ import logging -from typing import Literal from flask import request from flask_restx import Resource @@ -7,6 +6,7 @@ from pydantic import BaseModel, Field, TypeAdapter from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services +from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery from controllers.common.schema import register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError @@ -14,7 +14,6 @@ from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate from core.app.entities.app_invoke_entities import InvokeFrom from fields.conversation_fields import ResultResponse from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem -from libs.helper import UUIDStrOrEmpty from models.enums import FeedbackRating from models.model import App, AppMode, EndUser from services.errors.message import ( @@ -27,17 +26,6 @@ from services.message_service import MessageService logger = logging.getLogger(__name__) -class MessageListQuery(BaseModel): - conversation_id: UUIDStrOrEmpty - first_id: UUIDStrOrEmpty | None = None - limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return") - - -class MessageFeedbackPayload(BaseModel): - rating: Literal["like", "dislike"] | None = Field(default=None, description="Feedback rating") - content: str | None = Field(default=None, description="Feedback content") - - class FeedbackListQuery(BaseModel): page: int = Field(default=1, ge=1, description="Page number") limit: int = Field(default=20, ge=1, le=101, description="Number of feedbacks per page") diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index d7992a2a3a..e0a64ffe26 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Literal +from typing import Literal from dateutil.parser import isoparse from flask import request @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, InternalServerError, NotFound +from controllers.common.controller_schemas import WorkflowRunPayload as WorkflowRunPayloadBase from controllers.common.schema import register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( @@ -46,9 +47,7 @@ from services.workflow_app_service import WorkflowAppService logger = logging.getLogger(__name__) -class WorkflowRunPayload(BaseModel): - inputs: dict[str, Any] - files: list[dict[str, Any]] | None = None +class WorkflowRunPayload(WorkflowRunPayloadBase): response_mode: Literal["blocking", "streaming"] | None = None diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 9f1ce17ed9..6db047567f 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -10,6 +10,7 @@ from sqlalchemy import desc, func, select from werkzeug.exceptions import Forbidden, NotFound import services +from controllers.common.controller_schemas import DocumentBatchDownloadZipPayload from controllers.common.errors import ( FilenameNotExistsError, FileTooLargeError, @@ -100,15 +101,6 @@ class DocumentListQuery(BaseModel): status: str | None = Field(default=None, description="Document status filter") -DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100 - - -class DocumentBatchDownloadZipPayload(BaseModel): - """Request payload for bulk downloading uploaded documents as a ZIP archive.""" - - document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS) - - register_enum_models(service_api_ns, RetrievalMethod) register_schema_models( @@ -527,7 +519,7 @@ class DocumentListApi(DatasetApiResource): if not dataset: raise NotFound("Dataset not found.") - query = select(Document).filter_by(dataset_id=str(dataset_id), tenant_id=tenant_id) + query = select(Document).where(Document.dataset_id == dataset_id, Document.tenant_id == tenant_id) if query_params.status: query = DocumentService.apply_display_status_filter(query, query_params.status) diff --git a/api/controllers/service_api/dataset/metadata.py b/api/controllers/service_api/dataset/metadata.py index 52166f7fcc..21db7d0cb8 100644 --- a/api/controllers/service_api/dataset/metadata.py +++ b/api/controllers/service_api/dataset/metadata.py @@ -2,9 +2,9 @@ from typing import Literal from flask_login import current_user from flask_restx import marshal -from pydantic import BaseModel from werkzeug.exceptions import NotFound +from controllers.common.controller_schemas import MetadataUpdatePayload from controllers.common.schema import register_schema_model, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check @@ -18,11 +18,6 @@ from services.entities.knowledge_entities.knowledge_entities import ( ) from services.metadata_service import MetadataService - -class MetadataUpdatePayload(BaseModel): - name: str - - register_schema_model(service_api_ns, MetadataUpdatePayload) register_schema_models( service_api_ns, diff --git a/api/controllers/service_api/dataset/segment.py b/api/controllers/service_api/dataset/segment.py index 5b16da81e0..971b63577c 100644 --- a/api/controllers/service_api/dataset/segment.py +++ b/api/controllers/service_api/dataset/segment.py @@ -8,6 +8,7 @@ from sqlalchemy import select from werkzeug.exceptions import NotFound from configs import dify_config +from controllers.common.controller_schemas import ChildChunkCreatePayload, ChildChunkUpdatePayload from controllers.common.schema import register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import ProviderNotInitializeError @@ -32,25 +33,25 @@ from services.errors.chunk import ChildChunkIndexingError as ChildChunkIndexingS from services.summary_index_service import SummaryIndexService -def _marshal_segment_with_summary(segment, dataset_id: str) -> dict: +def _marshal_segment_with_summary(segment, dataset_id: str) -> dict[str, Any]: """Marshal a single segment and enrich it with summary content.""" - segment_dict = dict(marshal(segment, segment_fields)) # type: ignore[arg-type] + segment_dict: dict[str, Any] = dict(marshal(segment, segment_fields)) # type: ignore[arg-type] summary = SummaryIndexService.get_segment_summary(segment_id=segment.id, dataset_id=dataset_id) segment_dict["summary"] = summary.summary_content if summary else None return segment_dict -def _marshal_segments_with_summary(segments, dataset_id: str) -> list[dict]: +def _marshal_segments_with_summary(segments, dataset_id: str) -> list[dict[str, Any]]: """Marshal multiple segments and enrich them with summary content (batch query).""" segment_ids = [segment.id for segment in segments] - summaries: dict = {} + summaries: dict[str, str | None] = {} if segment_ids: summary_records = SummaryIndexService.get_segments_summaries(segment_ids=segment_ids, dataset_id=dataset_id) summaries = {chunk_id: record.summary_content for chunk_id, record in summary_records.items()} - result = [] + result: list[dict[str, Any]] = [] for segment in segments: - segment_dict = dict(marshal(segment, segment_fields)) # type: ignore[arg-type] + segment_dict: dict[str, Any] = dict(marshal(segment, segment_fields)) # type: ignore[arg-type] segment_dict["summary"] = summaries.get(segment.id) result.append(segment_dict) return result @@ -69,20 +70,12 @@ class SegmentUpdatePayload(BaseModel): segment: SegmentUpdateArgs -class ChildChunkCreatePayload(BaseModel): - content: str - - class ChildChunkListQuery(BaseModel): limit: int = Field(default=20, ge=1) keyword: str | None = None page: int = Field(default=1, ge=1) -class ChildChunkUpdatePayload(BaseModel): - content: str - - register_schema_models( service_api_ns, SegmentCreatePayload, diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index 9ba1dc4a3a..0ef4471018 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -3,10 +3,11 @@ import logging from flask import request from flask_restx import fields, marshal_with from graphon.model_runtime.errors.invoke import InvokeError -from pydantic import BaseModel, field_validator +from pydantic import field_validator from werkzeug.exceptions import InternalServerError import services +from controllers.common.controller_schemas import TextToAudioPayload as TextToAudioPayloadBase from controllers.web import web_ns from controllers.web.error import ( AppUnavailableError, @@ -34,12 +35,7 @@ from services.errors.audio import ( from ..common.schema import register_schema_models -class TextToAudioPayload(BaseModel): - message_id: str | None = None - voice: str | None = None - text: str | None = None - streaming: bool | None = None - +class TextToAudioPayload(TextToAudioPayloadBase): @field_validator("message_id") @classmethod def validate_message_id(cls, value: str | None) -> str | None: diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py index d5baa5fb7d..3975dd85c8 100644 --- a/api/controllers/web/conversation.py +++ b/api/controllers/web/conversation.py @@ -1,10 +1,11 @@ from typing import Literal from flask import request -from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator +from pydantic import BaseModel, Field, TypeAdapter, field_validator from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound +from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import register_schema_models from controllers.web import web_ns from controllers.web.error import NotChatAppError @@ -37,18 +38,6 @@ class ConversationListQuery(BaseModel): return uuid_value(value) -class ConversationRenamePayload(BaseModel): - name: str | None = None - auto_generate: bool = False - - @model_validator(mode="after") - def validate_name_requirement(self): - if not self.auto_generate: - if self.name is None or not self.name.strip(): - raise ValueError("name is required when auto_generate is false") - return self - - register_schema_models(web_ns, ConversationListQuery, ConversationRenamePayload) diff --git a/api/controllers/web/forgot_password.py b/api/controllers/web/forgot_password.py index 80c3289fb4..61fd794c22 100644 --- a/api/controllers/web/forgot_password.py +++ b/api/controllers/web/forgot_password.py @@ -3,7 +3,6 @@ import secrets from flask import request from flask_restx import Resource -from sqlalchemy.orm import sessionmaker from controllers.common.schema import register_schema_models from controllers.console.auth.error import ( @@ -62,9 +61,7 @@ class ForgotPasswordSendEmailApi(Resource): else: language = "en-US" - with sessionmaker(db.engine).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(request_email, session=session) - token = None + account = AccountService.get_account_by_email_with_case_fallback(request_email) if account is None: raise AuthenticationFailedError() else: @@ -161,13 +158,14 @@ class ForgotPasswordResetApi(Resource): email = reset_data.get("email", "") - with sessionmaker(db.engine).begin() as session: - account = AccountService.get_account_by_email_with_case_fallback(email, session=session) + account = AccountService.get_account_by_email_with_case_fallback(email) - if account: - self._update_existing_account(account, password_hashed, salt) - else: - raise AuthenticationFailedError() + if account: + account = db.session.merge(account) + self._update_existing_account(account, password_hashed, salt) + db.session.commit() + else: + raise AuthenticationFailedError() return {"result": "success"} diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 36728a47d1..44876f8303 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -5,9 +5,11 @@ Web App Human Input Form APIs. import json import logging from datetime import datetime +from typing import Any, NotRequired, TypedDict from flask import Response, request -from flask_restx import Resource, reqparse +from flask_restx import Resource +from pydantic import BaseModel from sqlalchemy import select from werkzeug.exceptions import Forbidden @@ -23,6 +25,12 @@ from services.human_input_service import Form, FormNotFoundError, HumanInputServ logger = logging.getLogger(__name__) + +class HumanInputFormSubmitPayload(BaseModel): + inputs: dict + action: str + + _FORM_SUBMIT_RATE_LIMITER = RateLimiter( prefix="web_form_submit_rate_limit", max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, @@ -51,10 +59,19 @@ def _to_timestamp(value: datetime) -> int: return int(value.timestamp()) +class FormDefinitionPayload(TypedDict): + form_content: Any + inputs: Any + resolved_default_values: dict[str, str] + user_actions: Any + expiration_time: int + site: NotRequired[dict] + + def _jsonify_form_definition(form: Form, site_payload: dict | None = None) -> Response: """Return the form payload (optionally with site) as a JSON response.""" definition_payload = form.get_definition().model_dump() - payload = { + payload: FormDefinitionPayload = { "form_content": definition_payload["rendered_content"], "inputs": definition_payload["inputs"], "resolved_default_values": _stringify_default_values(definition_payload["default_values"]), @@ -85,7 +102,7 @@ class HumanInputFormApi(Resource): _FORM_ACCESS_RATE_LIMITER.increment_rate_limit(ip_address) service = HumanInputService(db.engine) - # TODO(QuantumGhost): forbid submision for form tokens + # TODO(QuantumGhost): forbid submission for form tokens # that are only for console. form = service.get_form_by_token(form_token) @@ -112,10 +129,7 @@ class HumanInputFormApi(Resource): "action": "Approve" } """ - parser = reqparse.RequestParser() - parser.add_argument("inputs", type=dict, required=True, location="json") - parser.add_argument("action", type=str, required=True, location="json") - args = parser.parse_args() + payload = HumanInputFormSubmitPayload.model_validate(request.get_json()) ip_address = extract_remote_ip(request) if _FORM_SUBMIT_RATE_LIMITER.is_rate_limited(ip_address): @@ -135,8 +149,8 @@ class HumanInputFormApi(Resource): service.submit_form_by_token( recipient_type=recipient_type, form_token=form_token, - selected_action_id=args["action"], - form_data=args["inputs"], + selected_action_id=payload.action, + form_data=payload.inputs, submission_end_user_id=None, # submission_end_user_id=_end_user.id, ) diff --git a/api/controllers/web/login.py b/api/controllers/web/login.py index ae0e6789ef..2255dd0332 100644 --- a/api/controllers/web/login.py +++ b/api/controllers/web/login.py @@ -1,7 +1,10 @@ +import logging + from flask import make_response, request from flask_restx import Resource from jwt import InvalidTokenError from pydantic import BaseModel, Field, field_validator +from werkzeug.exceptions import Unauthorized import services from configs import dify_config @@ -20,7 +23,7 @@ from controllers.console.wraps import ( ) from controllers.web import web_ns from controllers.web.wraps import decode_jwt_token -from libs.helper import EmailStr +from libs.helper import EmailStr, extract_remote_ip from libs.passport import PassportService from libs.password import valid_password from libs.token import ( @@ -29,9 +32,11 @@ from libs.token import ( ) from services.account_service import AccountService from services.app_service import AppService -from services.entities.auth_entities import LoginPayloadBase +from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase from services.webapp_auth_service import WebAppAuthService +logger = logging.getLogger(__name__) + class LoginPayload(LoginPayloadBase): @field_validator("password") @@ -76,14 +81,18 @@ class LoginApi(Resource): def post(self): """Authenticate user and login.""" payload = LoginPayload.model_validate(web_ns.payload or {}) + normalized_email = payload.email.lower() try: account = WebAppAuthService.authenticate(payload.email, payload.password) except services.errors.account.AccountLoginError: + _log_web_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_BANNED) raise AccountBannedError() except services.errors.account.AccountPasswordError: + _log_web_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS) raise AuthenticationFailedError() except services.errors.account.AccountNotFoundError: + _log_web_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_NOT_FOUND) raise AuthenticationFailedError() token = WebAppAuthService.login(account=account) @@ -212,21 +221,30 @@ class EmailCodeLoginApi(Resource): token_data = WebAppAuthService.get_email_code_login_data(payload.token) if token_data is None: + _log_web_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN) raise InvalidTokenError() token_email = token_data.get("email") if not isinstance(token_email, str): + _log_web_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH) raise InvalidEmailError() normalized_token_email = token_email.lower() if normalized_token_email != user_email: + _log_web_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH) raise InvalidEmailError() if token_data["code"] != payload.code: + _log_web_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE) raise EmailCodeError() WebAppAuthService.revoke_email_code_login_token(payload.token) - account = WebAppAuthService.get_user_through_email(token_email) + try: + account = WebAppAuthService.get_user_through_email(token_email) + except Unauthorized as exc: + _log_web_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED) + raise AccountBannedError() from exc if not account: + _log_web_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_NOT_FOUND) raise AuthenticationFailedError() token = WebAppAuthService.login(account=account) @@ -234,3 +252,12 @@ class EmailCodeLoginApi(Resource): response = make_response({"result": "success", "data": {"access_token": token}}) # set_access_token_to_cookie(request, response, token, samesite="None", httponly=False) return response + + +def _log_web_login_failure(*, email: str, reason: LoginFailureReason) -> None: + logger.warning( + "Web login failed: email=%s reason=%s ip_address=%s", + email, + reason, + extract_remote_ip(request), + ) diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py index c5505dd60d..39afdd843f 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -3,9 +3,10 @@ from typing import Literal from flask import request from graphon.model_runtime.errors.invoke import InvokeError -from pydantic import BaseModel, Field, TypeAdapter, field_validator +from pydantic import BaseModel, Field, TypeAdapter from werkzeug.exceptions import InternalServerError, NotFound +from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery from controllers.common.schema import register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -24,7 +25,6 @@ from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotIni from fields.conversation_fields import ResultResponse from fields.message_fields import SuggestedQuestionsResponse, WebMessageInfiniteScrollPagination, WebMessageListItem from libs import helper -from libs.helper import uuid_value from models.enums import FeedbackRating from models.model import AppMode from services.app_generate_service import AppGenerateService @@ -40,24 +40,6 @@ from services.message_service import MessageService logger = logging.getLogger(__name__) -class MessageListQuery(BaseModel): - conversation_id: str = Field(description="Conversation UUID") - first_id: str | None = Field(default=None, description="First message ID for pagination") - limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)") - - @field_validator("conversation_id", "first_id") - @classmethod - def validate_uuid(cls, value: str | None) -> str | None: - if value is None: - return value - return uuid_value(value) - - -class MessageFeedbackPayload(BaseModel): - rating: Literal["like", "dislike"] | None = Field(default=None, description="Feedback rating") - content: str | None = Field(default=None, description="Feedback content") - - class MessageMoreLikeThisQuery(BaseModel): response_mode: Literal["blocking", "streaming"] = Field( description="Response mode", diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py index 6a2e0b65fb..0293df74b0 100644 --- a/api/controllers/web/passport.py +++ b/api/controllers/web/passport.py @@ -1,5 +1,6 @@ import uuid from datetime import UTC, datetime, timedelta +from typing import Any from flask import make_response, request from flask_restx import Resource @@ -103,21 +104,23 @@ class PassportResource(Resource): return response -def decode_enterprise_webapp_user_id(jwt_token: str | None): +def decode_enterprise_webapp_user_id(jwt_token: str | None) -> dict[str, Any] | None: """ Decode the enterprise user session from the Authorization header. """ if not jwt_token: return None - decoded = PassportService().verify(jwt_token) + decoded: dict[str, Any] = PassportService().verify(jwt_token) source = decoded.get("token_source") if not source or source != "webapp_login_token": raise Unauthorized("Invalid token source. Expected 'webapp_login_token'.") return decoded -def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded: dict, auth_type: WebAppAuthType): +def exchange_token_for_existing_web_user( + app_code: str, enterprise_user_decoded: dict[str, Any], auth_type: WebAppAuthType +): """ Exchange a token for an existing web user session. """ @@ -138,12 +141,15 @@ def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded: if not app_model or app_model.status != "normal" or not app_model.enable_site: raise NotFound() - if auth_type == WebAppAuthType.PUBLIC: - return _exchange_for_public_app_token(app_model, site, enterprise_user_decoded) - elif auth_type == WebAppAuthType.EXTERNAL and user_auth_type != "external": - raise WebAppAuthRequiredError("Please login as external user.") - elif auth_type == WebAppAuthType.INTERNAL and user_auth_type != "internal": - raise WebAppAuthRequiredError("Please login as internal user.") + match auth_type: + case WebAppAuthType.PUBLIC: + return _exchange_for_public_app_token(app_model, site, enterprise_user_decoded) + case WebAppAuthType.EXTERNAL: + if user_auth_type != "external": + raise WebAppAuthRequiredError("Please login as external user.") + case WebAppAuthType.INTERNAL: + if user_auth_type != "internal": + raise WebAppAuthRequiredError("Please login as internal user.") end_user = None if end_user_id: diff --git a/api/controllers/web/saved_message.py b/api/controllers/web/saved_message.py index 29993100f6..5b206f9a98 100644 --- a/api/controllers/web/saved_message.py +++ b/api/controllers/web/saved_message.py @@ -1,27 +1,17 @@ from flask import request -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import TypeAdapter from werkzeug.exceptions import NotFound +from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery from controllers.common.schema import register_schema_models from controllers.web import web_ns from controllers.web.error import NotCompletionAppError from controllers.web.wraps import WebApiResource from fields.conversation_fields import ResultResponse from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem -from libs.helper import UUIDStrOrEmpty from services.errors.message import MessageNotExistsError from services.saved_message_service import SavedMessageService - -class SavedMessageListQuery(BaseModel): - last_id: UUIDStrOrEmpty | None = None - limit: int = Field(default=20, ge=1, le=100) - - -class SavedMessageCreatePayload(BaseModel): - message_id: UUIDStrOrEmpty - - register_schema_models(web_ns, SavedMessageListQuery, SavedMessageCreatePayload) diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 1a0c6d4252..7d2080dd91 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -1,4 +1,4 @@ -from typing import cast +from typing import Any, cast from flask_restx import fields, marshal, marshal_with from sqlalchemy import select @@ -113,12 +113,12 @@ class AppSiteInfo: } -def serialize_site(site: Site) -> dict: +def serialize_site(site: Site) -> dict[str, Any]: """Serialize Site model using the same schema as AppSiteApi.""" - return cast(dict, marshal(site, AppSiteApi.site_fields)) + return cast(dict[str, Any], marshal(site, AppSiteApi.site_fields)) -def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict: +def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]: can_replace_logo = FeatureService.get_features(app_model.tenant_id).can_replace_logo app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo) - return cast(dict, marshal(app_site_info, AppSiteApi.app_fields)) + return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields)) diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 7f5521f9f5..796e090976 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -1,11 +1,10 @@ import logging -from typing import Any from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.errors.invoke import InvokeError -from pydantic import BaseModel, Field from werkzeug.exceptions import InternalServerError +from controllers.common.controller_schemas import WorkflowRunPayload from controllers.common.schema import register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -30,12 +29,6 @@ from models.model import App, AppMode, EndUser from services.app_generate_service import AppGenerateService from services.errors.llm import InvokeRateLimitError - -class WorkflowRunPayload(BaseModel): - inputs: dict[str, Any] = Field(description="Input variables for the workflow") - files: list[dict[str, Any]] | None = Field(default=None, description="Files to be processed by the workflow") - - logger = logging.getLogger(__name__) register_schema_models(web_ns, WorkflowRunPayload) diff --git a/api/controllers/web/workflow_events.py b/api/controllers/web/workflow_events.py index 61568e70e6..474f9c0957 100644 --- a/api/controllers/web/workflow_events.py +++ b/api/controllers/web/workflow_events.py @@ -72,12 +72,13 @@ class WorkflowEventsApi(WebApiResource): app_mode = AppMode.value_of(app_model.mode) msg_generator = MessageGenerator() generator: BaseAppGenerator - if app_mode == AppMode.ADVANCED_CHAT: - generator = AdvancedChatAppGenerator() - elif app_mode == AppMode.WORKFLOW: - generator = WorkflowAppGenerator() - else: - raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}") + match app_mode: + case AppMode.ADVANCED_CHAT: + generator = AdvancedChatAppGenerator() + case AppMode.WORKFLOW: + generator = WorkflowAppGenerator() + case _: + raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}") include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" diff --git a/api/core/agent/cot_agent_runner.py b/api/core/agent/cot_agent_runner.py index 11e2aa062d..f07ac64498 100644 --- a/api/core/agent/cot_agent_runner.py +++ b/api/core/agent/cot_agent_runner.py @@ -2,7 +2,7 @@ import json import logging from abc import ABC, abstractmethod from collections.abc import Generator, Mapping, Sequence -from typing import Any +from typing import Any, TypedDict from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage from graphon.model_runtime.entities.message_entities import ( @@ -29,6 +29,13 @@ from models.model import Message logger = logging.getLogger(__name__) +class ActionDict(TypedDict): + """Shape produced by AgentScratchpadUnit.Action.to_dict().""" + + action: str + action_input: dict[str, Any] | str + + class CotAgentRunner(BaseAgentRunner, ABC): _is_first_iteration = True _ignore_observation_providers = ["wenxin"] @@ -331,7 +338,7 @@ class CotAgentRunner(BaseAgentRunner, ABC): return tool_invoke_response, tool_invoke_meta - def _convert_dict_to_action(self, action: dict) -> AgentScratchpadUnit.Action: + def _convert_dict_to_action(self, action: ActionDict) -> AgentScratchpadUnit.Action: """ convert dict to action """ diff --git a/api/core/agent/plugin_entities.py b/api/core/agent/plugin_entities.py index 90aa7b5fd4..8d25863a91 100644 --- a/api/core/agent/plugin_entities.py +++ b/api/core/agent/plugin_entities.py @@ -84,7 +84,7 @@ class AgentStrategyEntity(BaseModel): identity: AgentStrategyIdentity parameters: list[AgentStrategyParameter] = Field(default_factory=list) description: I18nObject = Field(..., description="The description of the agent strategy") - output_schema: dict | None = None + output_schema: dict[str, Any] | None = None features: list[AgentFeature] | None = None meta_version: str | None = None # pydantic configs diff --git a/api/core/app/app_config/common/sensitive_word_avoidance/manager.py b/api/core/app/app_config/common/sensitive_word_avoidance/manager.py index 7d1b11c008..c8ec7cb44d 100644 --- a/api/core/app/app_config/common/sensitive_word_avoidance/manager.py +++ b/api/core/app/app_config/common/sensitive_word_avoidance/manager.py @@ -22,8 +22,8 @@ class SensitiveWordAvoidanceConfigManager: @classmethod def validate_and_set_defaults( - cls, tenant_id: str, config: dict, only_structure_validate: bool = False - ) -> tuple[dict, list[str]]: + cls, tenant_id: str, config: dict[str, Any], only_structure_validate: bool = False + ) -> tuple[dict[str, Any], list[str]]: if not config.get("sensitive_word_avoidance"): config["sensitive_word_avoidance"] = {"enabled": False} diff --git a/api/core/app/app_config/easy_ui_based_app/dataset/manager.py b/api/core/app/app_config/easy_ui_based_app/dataset/manager.py index f04a8df119..3d857a4e9c 100644 --- a/api/core/app/app_config/easy_ui_based_app/dataset/manager.py +++ b/api/core/app/app_config/easy_ui_based_app/dataset/manager.py @@ -138,7 +138,9 @@ class DatasetConfigManager: ) @classmethod - def validate_and_set_defaults(cls, tenant_id: str, app_mode: AppMode, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults( + cls, tenant_id: str, app_mode: AppMode, config: dict[str, Any] + ) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for dataset feature @@ -172,7 +174,7 @@ class DatasetConfigManager: return config, ["agent_mode", "dataset_configs", "dataset_query_variable"] @classmethod - def extract_dataset_config_for_legacy_compatibility(cls, tenant_id: str, app_mode: AppMode, config: dict): + def extract_dataset_config_for_legacy_compatibility(cls, tenant_id: str, app_mode: AppMode, config: dict[str, Any]): """ Extract dataset config for legacy compatibility diff --git a/api/core/app/app_config/easy_ui_based_app/model_config/manager.py b/api/core/app/app_config/easy_ui_based_app/model_config/manager.py index 5cc385c378..9d980e5ca3 100644 --- a/api/core/app/app_config/easy_ui_based_app/model_config/manager.py +++ b/api/core/app/app_config/easy_ui_based_app/model_config/manager.py @@ -41,7 +41,7 @@ class ModelConfigManager: ) @classmethod - def validate_and_set_defaults(cls, tenant_id: str, config: Mapping[str, Any]) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, tenant_id: str, config: Mapping[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for model config @@ -108,7 +108,7 @@ class ModelConfigManager: return dict(config), ["model"] @classmethod - def validate_model_completion_params(cls, cp: dict): + def validate_model_completion_params(cls, cp: dict[str, Any]): # model.completion_params if not isinstance(cp, dict): raise ValueError("model.completion_params must be of object type") diff --git a/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py b/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py index 76196e7034..57c6d1c496 100644 --- a/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py +++ b/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py @@ -65,7 +65,7 @@ class PromptTemplateConfigManager: ) @classmethod - def validate_and_set_defaults(cls, app_mode: AppMode, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, app_mode: AppMode, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate pre_prompt and set defaults for prompt feature depending on the config['model'] @@ -130,7 +130,7 @@ class PromptTemplateConfigManager: return config, ["prompt_type", "pre_prompt", "chat_prompt_config", "completion_prompt_config"] @classmethod - def validate_post_prompt_and_set_defaults(cls, config: dict): + def validate_post_prompt_and_set_defaults(cls, config: dict[str, Any]): """ Validate post_prompt and set defaults for prompt feature diff --git a/api/core/app/app_config/easy_ui_based_app/variables/manager.py b/api/core/app/app_config/easy_ui_based_app/variables/manager.py index f0b71c5801..c89e1b3c3d 100644 --- a/api/core/app/app_config/easy_ui_based_app/variables/manager.py +++ b/api/core/app/app_config/easy_ui_based_app/variables/manager.py @@ -1,5 +1,5 @@ import re -from typing import cast +from typing import Any, cast from graphon.variables.input_entities import VariableEntity, VariableEntityType @@ -82,7 +82,7 @@ class BasicVariablesConfigManager: return variable_entities, external_data_variables @classmethod - def validate_and_set_defaults(cls, tenant_id: str, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, tenant_id: str, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for user input form @@ -99,7 +99,7 @@ class BasicVariablesConfigManager: return config, related_config_keys @classmethod - def validate_variables_and_set_defaults(cls, config: dict) -> tuple[dict, list[str]]: + def validate_variables_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for user input form @@ -164,7 +164,9 @@ class BasicVariablesConfigManager: return config, ["user_input_form"] @classmethod - def validate_external_data_tools_and_set_defaults(cls, tenant_id: str, config: dict) -> tuple[dict, list[str]]: + def validate_external_data_tools_and_set_defaults( + cls, tenant_id: str, config: dict[str, Any] + ) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for external data fetch feature diff --git a/api/core/app/app_config/features/file_upload/manager.py b/api/core/app/app_config/features/file_upload/manager.py index e96517c426..959c3868b4 100644 --- a/api/core/app/app_config/features/file_upload/manager.py +++ b/api/core/app/app_config/features/file_upload/manager.py @@ -30,7 +30,7 @@ class FileUploadConfigManager: return FileUploadConfig.model_validate(file_upload_dict) @classmethod - def validate_and_set_defaults(cls, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for file upload feature diff --git a/api/core/app/app_config/features/more_like_this/manager.py b/api/core/app/app_config/features/more_like_this/manager.py index ef71bb348a..b167c04ab5 100644 --- a/api/core/app/app_config/features/more_like_this/manager.py +++ b/api/core/app/app_config/features/more_like_this/manager.py @@ -1,3 +1,5 @@ +from typing import Any + from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -13,7 +15,7 @@ class AppConfigModel(BaseModel): class MoreLikeThisConfigManager: @classmethod - def convert(cls, config: dict) -> bool: + def convert(cls, config: dict[str, Any]) -> bool: """ Convert model config to model config @@ -23,7 +25,7 @@ class MoreLikeThisConfigManager: return AppConfigModel.model_validate(validated_config).more_like_this.enabled @classmethod - def validate_and_set_defaults(cls, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: try: return AppConfigModel.model_validate(config).model_dump(), ["more_like_this"] except ValidationError: diff --git a/api/core/app/app_config/features/opening_statement/manager.py b/api/core/app/app_config/features/opening_statement/manager.py index 92b4185abf..33f5aec183 100644 --- a/api/core/app/app_config/features/opening_statement/manager.py +++ b/api/core/app/app_config/features/opening_statement/manager.py @@ -1,6 +1,9 @@ +from typing import Any + + class OpeningStatementConfigManager: @classmethod - def convert(cls, config: dict) -> tuple[str, list]: + def convert(cls, config: dict[str, Any]) -> tuple[str, list[str]]: """ Convert model config to model config @@ -15,7 +18,7 @@ class OpeningStatementConfigManager: return opening_statement, suggested_questions_list @classmethod - def validate_and_set_defaults(cls, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for opening statement feature diff --git a/api/core/app/app_config/features/retrieval_resource/manager.py b/api/core/app/app_config/features/retrieval_resource/manager.py index d098abac2f..8157fb41db 100644 --- a/api/core/app/app_config/features/retrieval_resource/manager.py +++ b/api/core/app/app_config/features/retrieval_resource/manager.py @@ -1,6 +1,9 @@ +from typing import Any + + class RetrievalResourceConfigManager: @classmethod - def convert(cls, config: dict) -> bool: + def convert(cls, config: dict[str, Any]) -> bool: show_retrieve_source = False retriever_resource_dict = config.get("retriever_resource") if retriever_resource_dict: @@ -10,7 +13,7 @@ class RetrievalResourceConfigManager: return show_retrieve_source @classmethod - def validate_and_set_defaults(cls, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for retriever resource feature diff --git a/api/core/app/app_config/features/speech_to_text/manager.py b/api/core/app/app_config/features/speech_to_text/manager.py index e10ae03e04..679b8c343b 100644 --- a/api/core/app/app_config/features/speech_to_text/manager.py +++ b/api/core/app/app_config/features/speech_to_text/manager.py @@ -1,6 +1,9 @@ +from typing import Any + + class SpeechToTextConfigManager: @classmethod - def convert(cls, config: dict) -> bool: + def convert(cls, config: dict[str, Any]) -> bool: """ Convert model config to model config @@ -15,7 +18,7 @@ class SpeechToTextConfigManager: return speech_to_text @classmethod - def validate_and_set_defaults(cls, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for speech to text feature diff --git a/api/core/app/app_config/features/suggested_questions_after_answer/manager.py b/api/core/app/app_config/features/suggested_questions_after_answer/manager.py index 9ac5114d12..2dddce349c 100644 --- a/api/core/app/app_config/features/suggested_questions_after_answer/manager.py +++ b/api/core/app/app_config/features/suggested_questions_after_answer/manager.py @@ -1,6 +1,9 @@ +from typing import Any + + class SuggestedQuestionsAfterAnswerConfigManager: @classmethod - def convert(cls, config: dict) -> bool: + def convert(cls, config: dict[str, Any]) -> bool: """ Convert model config to model config @@ -15,7 +18,7 @@ class SuggestedQuestionsAfterAnswerConfigManager: return suggested_questions_after_answer @classmethod - def validate_and_set_defaults(cls, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for suggested questions feature diff --git a/api/core/app/app_config/features/text_to_speech/manager.py b/api/core/app/app_config/features/text_to_speech/manager.py index 1c75981785..ca84ec9c3b 100644 --- a/api/core/app/app_config/features/text_to_speech/manager.py +++ b/api/core/app/app_config/features/text_to_speech/manager.py @@ -1,9 +1,11 @@ +from typing import Any + from core.app.app_config.entities import TextToSpeechEntity class TextToSpeechConfigManager: @classmethod - def convert(cls, config: dict): + def convert(cls, config: dict[str, Any]): """ Convert model config to model config @@ -22,7 +24,7 @@ class TextToSpeechConfigManager: return text_to_speech @classmethod - def validate_and_set_defaults(cls, config: dict) -> tuple[dict, list[str]]: + def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """ Validate and set defaults for text to speech feature diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index a884a1c7f9..7b4cb98bd4 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -10,7 +10,7 @@ from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variable_loader import VariableLoader from graphon.variables.variables import Variable from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfig from core.app.apps.base_app_queue_manager import AppQueueManager @@ -363,7 +363,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): :return: List of conversation variables ready for use """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: existing_variables = self._load_existing_conversation_variables(session) if not existing_variables: @@ -376,7 +376,6 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): # Convert to Variable objects for use in the workflow conversation_variables = [var.to_variable() for var in existing_variables] - session.commit() return cast(list[Variable], conversation_variables) def _load_existing_conversation_variables(self, session: Session) -> list[ConversationVariable]: diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 5203de225c..0ce9ddce9e 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -16,7 +16,7 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder from graphon.nodes import BuiltinNodeTypes from graphon.runtime import GraphRuntimeState from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom @@ -328,13 +328,8 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): @contextmanager def _database_session(self): """Context manager for database sessions.""" - with Session(db.engine, expire_on_commit=False) as session: - try: - yield session - session.commit() - except Exception: - session.rollback() - raise + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: + yield session def _ensure_workflow_initialized(self): """Fluent validation for workflow state.""" diff --git a/api/core/app/apps/pipeline/generate_response_converter.py b/api/core/app/apps/pipeline/generate_response_converter.py index cfacd8640d..a77a978946 100644 --- a/api/core/app/apps/pipeline/generate_response_converter.py +++ b/api/core/app/apps/pipeline/generate_response_converter.py @@ -1,5 +1,5 @@ from collections.abc import Generator -from typing import cast +from typing import Any, cast from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( @@ -17,7 +17,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): _blocking_response_type = WorkflowAppBlockingResponse @classmethod - def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict: # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, Any]: # type: ignore[override] """ Convert blocking full response. :param blocking_response: blocking response @@ -26,7 +26,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): return dict(blocking_response.model_dump()) @classmethod - def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict: # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, Any]: # type: ignore[override] """ Convert blocking simple response. :param blocking_response: blocking response diff --git a/api/core/app/apps/pipeline/pipeline_config_manager.py b/api/core/app/apps/pipeline/pipeline_config_manager.py index 72b7f4bef6..8bbd745538 100644 --- a/api/core/app/apps/pipeline/pipeline_config_manager.py +++ b/api/core/app/apps/pipeline/pipeline_config_manager.py @@ -1,3 +1,5 @@ +from typing import Any + from core.app.app_config.base_app_config_manager import BaseAppConfigManager from core.app.app_config.common.sensitive_word_avoidance.manager import SensitiveWordAvoidanceConfigManager from core.app.app_config.entities import RagPipelineVariableEntity, WorkflowUIBasedAppConfig @@ -34,7 +36,9 @@ class PipelineConfigManager(BaseAppConfigManager): return pipeline_config @classmethod - def config_validate(cls, tenant_id: str, config: dict, only_structure_validate: bool = False) -> dict: + def config_validate( + cls, tenant_id: str, config: dict[str, Any], only_structure_validate: bool = False + ) -> dict[str, Any]: """ Validate for pipeline config diff --git a/api/core/app/apps/pipeline/pipeline_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index 139c7e73e0..83c74b86e5 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -782,7 +782,7 @@ class PipelineGenerator(BaseAppGenerator): user_id: str, all_files: list, datasource_info: Mapping[str, Any], - next_page_parameters: dict | None = None, + next_page_parameters: dict[str, Any] | None = None, ): """ Get files in a folder. diff --git a/api/core/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index b4d2310da8..36daaf09e9 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -2,7 +2,6 @@ import logging import time from typing import cast -from graphon.entities import GraphInitParams from graphon.enums import WorkflowType from graphon.graph import Graph from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent @@ -22,7 +21,7 @@ from core.app.entities.app_invoke_entities import ( ) from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository -from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id +from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.workflow_entry import WorkflowEntry @@ -265,22 +264,23 @@ class PipelineRunner(WorkflowBasedAppRunner): # graph_config["nodes"] = real_run_nodes # graph_config["edges"] = real_edges # init graph - # Create required parameters for Graph.init - graph_init_params = GraphInitParams( + # Create explicit graph init context for Graph.init. + run_context = build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=self._app_id, + user_id=self.application_generate_entity.user_id, + user_from=user_from, + invoke_from=invoke_from, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow.id, graph_config=graph_config, - run_context=build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=self._app_id, - user_id=self.application_generate_entity.user_id, - user_from=user_from, - invoke_from=invoke_from, - ), + run_context=run_context, call_depth=0, ) - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) if start_node_id is None: diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 49af169e88..f1b8b08eaa 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -7,7 +7,7 @@ from typing import Union from graphon.entities import WorkflowStartReason from graphon.enums import WorkflowExecutionStatus from graphon.runtime import GraphRuntimeState -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME from core.app.apps.base_app_queue_manager import AppQueueManager @@ -252,13 +252,8 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): @contextmanager def _database_session(self): """Context manager for database sessions.""" - with Session(db.engine, expire_on_commit=False) as session: - try: - yield session - session.commit() - except Exception: - session.rollback() - raise + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: + yield session def _ensure_workflow_initialized(self): """Fluent validation for workflow state.""" diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index caa6b82bab..437432611d 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -3,7 +3,6 @@ import time from collections.abc import Mapping, Sequence from typing import Any, cast -from graphon.entities import GraphInitParams from graphon.entities.graph_config import NodeConfigDictAdapter from graphon.entities.pause_reason import HumanInputRequired from graphon.graph import Graph @@ -67,7 +66,12 @@ from core.app.entities.queue_entities import ( QueueWorkflowSucceededEvent, ) from core.rag.entities import RetrievalSourceMetadata -from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id, resolve_workflow_node_class +from core.workflow.node_factory import ( + DifyGraphInitContext, + DifyNodeFactory, + get_default_root_node_id, + resolve_workflow_node_class, +) from core.workflow.system_variables import ( build_bootstrap_variables, default_system_variables, @@ -127,24 +131,25 @@ class WorkflowBasedAppRunner: if not isinstance(graph_config.get("edges"), list): raise ValueError("edges in workflow graph must be a list") - # Create required parameters for Graph.init - graph_init_params = GraphInitParams( + # Create explicit graph init context for Graph.init. + run_context = build_dify_run_context( + tenant_id=tenant_id or "", + app_id=self._app_id, + user_id=user_id, + user_from=user_from, + invoke_from=invoke_from, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow_id, graph_config=graph_config, - run_context=build_dify_run_context( - tenant_id=tenant_id or "", - app_id=self._app_id, - user_id=user_id, - user_from=user_from, - invoke_from=invoke_from, - ), + run_context=run_context, call_depth=0, ) # Use the provided graph_runtime_state for consistent state management - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) @@ -289,22 +294,23 @@ class WorkflowBasedAppRunner: typed_node_configs = [NodeConfigDictAdapter.validate_python(node) for node in node_configs] - # Create required parameters for Graph.init - graph_init_params = GraphInitParams( + # Create explicit graph init context for Graph.init. + run_context = build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=self._app_id, + user_id=user_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow.id, graph_config=graph_config, - run_context=build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=self._app_id, - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ), + run_context=run_context, call_depth=0, ) - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index 0cdbb5f50a..a3fb7b4c5d 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -1,6 +1,6 @@ from collections.abc import Mapping, Sequence from enum import StrEnum -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any from graphon.file import File, FileUploadConfig from graphon.model_runtime.entities.model_entities import AIModelEntity @@ -131,7 +131,7 @@ class AppGenerateEntity(BaseModel): extras: dict[str, Any] = Field(default_factory=dict) # tracing instance - trace_manager: Optional["TraceQueueManager"] = Field(default=None, exclude=True, repr=False) + trace_manager: "TraceQueueManager | None" = Field(default=None, exclude=True, repr=False) class EasyUIBasedAppGenerateEntity(AppGenerateEntity): diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 62df85b13f..88faf235d1 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -521,7 +521,7 @@ class IterationNodeStartStreamResponse(StreamResponse): node_type: str title: str created_at: int - extras: dict = Field(default_factory=dict) + extras: dict[str, Any] = Field(default_factory=dict) metadata: Mapping = {} inputs: Mapping = {} inputs_truncated: bool = False @@ -547,7 +547,7 @@ class IterationNodeNextStreamResponse(StreamResponse): title: str index: int created_at: int - extras: dict = Field(default_factory=dict) + extras: dict[str, Any] = Field(default_factory=dict) event: StreamEvent = StreamEvent.ITERATION_NEXT workflow_run_id: str @@ -571,7 +571,7 @@ class IterationNodeCompletedStreamResponse(StreamResponse): outputs: Mapping | None = None outputs_truncated: bool = False created_at: int - extras: dict | None = None + extras: dict[str, Any] | None = None inputs: Mapping | None = None inputs_truncated: bool = False status: WorkflowNodeExecutionStatus @@ -602,7 +602,7 @@ class LoopNodeStartStreamResponse(StreamResponse): node_type: str title: str created_at: int - extras: dict = Field(default_factory=dict) + extras: dict[str, Any] = Field(default_factory=dict) metadata: Mapping = {} inputs: Mapping = {} inputs_truncated: bool = False @@ -653,7 +653,7 @@ class LoopNodeCompletedStreamResponse(StreamResponse): outputs: Mapping | None = None outputs_truncated: bool = False created_at: int - extras: dict | None = None + extras: dict[str, Any] | None = None inputs: Mapping | None = None inputs_truncated: bool = False status: WorkflowNodeExecutionStatus diff --git a/api/core/app/llm/quota.py b/api/core/app/llm/quota.py index 182f1b767d..0bb10190c4 100644 --- a/api/core/app/llm/quota.py +++ b/api/core/app/llm/quota.py @@ -1,6 +1,6 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from sqlalchemy import update -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from configs import dify_config from core.entities.model_entities import ModelStatus @@ -57,37 +57,37 @@ def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LL used_quota = 1 if used_quota is not None and system_configuration.current_quota_type is not None: - if system_configuration.current_quota_type == ProviderQuotaType.TRIAL: - from services.credit_pool_service import CreditPoolService + match system_configuration.current_quota_type: + case ProviderQuotaType.TRIAL: + from services.credit_pool_service import CreditPoolService - CreditPoolService.check_and_deduct_credits( - tenant_id=tenant_id, - credits_required=used_quota, - ) - elif system_configuration.current_quota_type == ProviderQuotaType.PAID: - from services.credit_pool_service import CreditPoolService - - CreditPoolService.check_and_deduct_credits( - tenant_id=tenant_id, - credits_required=used_quota, - pool_type="paid", - ) - else: - with Session(db.engine) as session: - stmt = ( - update(Provider) - .where( - Provider.tenant_id == tenant_id, - # TODO: Use provider name with prefix after the data migration. - Provider.provider_name == ModelProviderID(model_instance.provider).provider_name, - Provider.provider_type == ProviderType.SYSTEM.value, - Provider.quota_type == system_configuration.current_quota_type, - Provider.quota_limit > Provider.quota_used, - ) - .values( - quota_used=Provider.quota_used + used_quota, - last_used=naive_utc_now(), - ) + CreditPoolService.check_and_deduct_credits( + tenant_id=tenant_id, + credits_required=used_quota, ) - session.execute(stmt) - session.commit() + case ProviderQuotaType.PAID: + from services.credit_pool_service import CreditPoolService + + CreditPoolService.check_and_deduct_credits( + tenant_id=tenant_id, + credits_required=used_quota, + pool_type="paid", + ) + case ProviderQuotaType.FREE: + with sessionmaker(bind=db.engine).begin() as session: + stmt = ( + update(Provider) + .where( + Provider.tenant_id == tenant_id, + # TODO: Use provider name with prefix after the data migration. + Provider.provider_name == ModelProviderID(model_instance.provider).provider_name, + Provider.provider_type == ProviderType.SYSTEM.value, + Provider.quota_type == system_configuration.current_quota_type, + Provider.quota_limit > Provider.quota_used, + ) + .values( + quota_used=Provider.quota_used + used_quota, + last_used=naive_utc_now(), + ) + ) + session.execute(stmt) diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py index 9df78a7830..6bb177fe02 100644 --- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py @@ -12,7 +12,7 @@ from graphon.model_runtime.entities.message_entities import ( ) from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom @@ -266,9 +266,8 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): event = message.event if isinstance(event, QueueErrorEvent): - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: err = self.handle_error(event=event, session=session, message_id=self._message_id) - session.commit() yield self.error_to_stream_response(err) break elif isinstance(event, QueueStopEvent | QueueMessageEndEvent): @@ -288,10 +287,9 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): answer=output_moderation_answer ) - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # Save message self._save_message(session=session, trace_manager=trace_manager) - session.commit() message_end_resp = self._message_end_to_stream_response() yield message_end_resp elif isinstance(event, QueueRetrieverResourcesEvent): diff --git a/api/core/app/task_pipeline/message_file_utils.py b/api/core/app/task_pipeline/message_file_utils.py index b23a33923b..77310baf74 100644 --- a/api/core/app/task_pipeline/message_file_utils.py +++ b/api/core/app/task_pipeline/message_file_utils.py @@ -40,41 +40,44 @@ def prepare_file_dict(message_file: MessageFile, upload_files_map: dict[str, Upl size = 0 extension = "" - if message_file.transfer_method == FileTransferMethod.REMOTE_URL: - url = message_file.url - if message_file.url: - filename = message_file.url.split("/")[-1].split("?")[0] - if "." in filename: - extension = "." + filename.rsplit(".", 1)[1] - elif message_file.transfer_method == FileTransferMethod.LOCAL_FILE: - if upload_file: - url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id)) - filename = upload_file.name - mime_type = upload_file.mime_type or "application/octet-stream" - size = upload_file.size or 0 - extension = f".{upload_file.extension}" if upload_file.extension else "" - elif message_file.upload_file_id: - url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id)) - elif message_file.transfer_method == FileTransferMethod.TOOL_FILE and message_file.url: - if message_file.url.startswith(("http://", "https://")): + match message_file.transfer_method: + case FileTransferMethod.REMOTE_URL: url = message_file.url - filename = message_file.url.split("/")[-1].split("?")[0] - if "." in filename: - extension = "." + filename.rsplit(".", 1)[1] - else: - url_parts = message_file.url.split("/") - if url_parts: - file_part = url_parts[-1].split("?")[0] - if "." in file_part: - tool_file_id, ext = file_part.rsplit(".", 1) - extension = f".{ext}" - if len(extension) > MAX_TOOL_FILE_EXTENSION_LENGTH: + if message_file.url: + filename = message_file.url.split("/")[-1].split("?")[0] + if "." in filename: + extension = "." + filename.rsplit(".", 1)[1] + case FileTransferMethod.LOCAL_FILE: + if upload_file: + url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id)) + filename = upload_file.name + mime_type = upload_file.mime_type or "application/octet-stream" + size = upload_file.size or 0 + extension = f".{upload_file.extension}" if upload_file.extension else "" + elif message_file.upload_file_id: + url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id)) + case FileTransferMethod.TOOL_FILE if message_file.url: + if message_file.url.startswith(("http://", "https://")): + url = message_file.url + filename = message_file.url.split("/")[-1].split("?")[0] + if "." in filename: + extension = "." + filename.rsplit(".", 1)[1] + else: + url_parts = message_file.url.split("/") + if url_parts: + file_part = url_parts[-1].split("?")[0] + if "." in file_part: + tool_file_id, ext = file_part.rsplit(".", 1) + extension = f".{ext}" + if len(extension) > MAX_TOOL_FILE_EXTENSION_LENGTH: + extension = ".bin" + else: + tool_file_id = file_part extension = ".bin" - else: - tool_file_id = file_part - extension = ".bin" - url = sign_tool_file(tool_file_id=tool_file_id, extension=extension) - filename = file_part + url = sign_tool_file(tool_file_id=tool_file_id, extension=extension) + filename = file_part + case FileTransferMethod.TOOL_FILE | FileTransferMethod.DATASOURCE_FILE: + pass transfer_method_value = message_file.transfer_method.value remote_url = message_file.url if message_file.transfer_method == FileTransferMethod.REMOTE_URL else "" diff --git a/api/core/datasource/entities/api_entities.py b/api/core/datasource/entities/api_entities.py index 14d1af2e8b..9c22d5e67c 100644 --- a/api/core/datasource/entities/api_entities.py +++ b/api/core/datasource/entities/api_entities.py @@ -1,10 +1,10 @@ -from typing import Literal, Optional +from typing import Any, Literal, TypedDict from graphon.model_runtime.utils.encoders import jsonable_encoder from pydantic import BaseModel, Field, field_validator from core.datasource.entities.datasource_entities import DatasourceParameter -from core.tools.entities.common_entities import I18nObject +from core.tools.entities.common_entities import I18nObject, I18nObjectDict class DatasourceApiEntity(BaseModel): @@ -14,10 +14,27 @@ class DatasourceApiEntity(BaseModel): description: I18nObject parameters: list[DatasourceParameter] | None = None labels: list[str] = Field(default_factory=list) - output_schema: dict | None = None + output_schema: dict[str, Any] | None = None -ToolProviderTypeApiLiteral = Optional[Literal["builtin", "api", "workflow"]] +ToolProviderTypeApiLiteral = Literal["builtin", "api", "workflow"] | None + + +class DatasourceProviderApiEntityDict(TypedDict): + id: str + author: str + name: str + plugin_id: str | None + plugin_unique_identifier: str | None + description: I18nObjectDict + icon: str | dict + label: I18nObjectDict + type: str + team_credentials: dict[str, Any] | None + is_team_authorization: bool + allow_delete: bool + datasources: list[Any] + labels: list[str] class DatasourceProviderApiEntity(BaseModel): @@ -28,8 +45,8 @@ class DatasourceProviderApiEntity(BaseModel): icon: str | dict label: I18nObject # label type: str - masked_credentials: dict | None = None - original_credentials: dict | None = None + masked_credentials: dict[str, Any] | None = None + original_credentials: dict[str, Any] | None = None is_team_authorization: bool = False allow_delete: bool = True plugin_id: str | None = Field(default="", description="The plugin id of the datasource") @@ -42,7 +59,7 @@ class DatasourceProviderApiEntity(BaseModel): def convert_none_to_empty_list(cls, v): return v if v is not None else [] - def to_dict(self) -> dict: + def to_dict(self) -> DatasourceProviderApiEntityDict: # ------------- # overwrite datasource parameter types for temp fix datasources = jsonable_encoder(self.datasources) @@ -53,7 +70,7 @@ class DatasourceProviderApiEntity(BaseModel): parameter["type"] = "files" # ------------- - return { + result: DatasourceProviderApiEntityDict = { "id": self.id, "author": self.author, "name": self.name, @@ -69,3 +86,4 @@ class DatasourceProviderApiEntity(BaseModel): "datasources": datasources, "labels": self.labels, } + return result diff --git a/api/core/datasource/entities/datasource_entities.py b/api/core/datasource/entities/datasource_entities.py index f20bab53f0..443b503a69 100644 --- a/api/core/datasource/entities/datasource_entities.py +++ b/api/core/datasource/entities/datasource_entities.py @@ -2,7 +2,7 @@ from __future__ import annotations import enum from enum import StrEnum -from typing import Any +from typing import Any, TypedDict from pydantic import BaseModel, Field, ValidationInfo, field_validator from yarl import URL @@ -129,7 +129,7 @@ class DatasourceEntity(BaseModel): identity: DatasourceIdentity parameters: list[DatasourceParameter] = Field(default_factory=list) description: I18nObject = Field(..., description="The label of the datasource") - output_schema: dict | None = None + output_schema: dict[str, Any] | None = None @field_validator("parameters", mode="before") @classmethod @@ -179,6 +179,12 @@ class DatasourceProviderEntityWithPlugin(DatasourceProviderEntity): datasources: list[DatasourceEntity] = Field(default_factory=list) +class DatasourceInvokeMetaDict(TypedDict): + time_cost: float + error: str | None + tool_config: dict[str, Any] | None + + class DatasourceInvokeMeta(BaseModel): """ Datasource invoke meta @@ -186,7 +192,7 @@ class DatasourceInvokeMeta(BaseModel): time_cost: float = Field(..., description="The time cost of the tool invoke") error: str | None = None - tool_config: dict | None = None + tool_config: dict[str, Any] | None = None @classmethod def empty(cls) -> DatasourceInvokeMeta: @@ -202,12 +208,13 @@ class DatasourceInvokeMeta(BaseModel): """ return cls(time_cost=0.0, error=error, tool_config={}) - def to_dict(self) -> dict: - return { + def to_dict(self) -> DatasourceInvokeMetaDict: + result: DatasourceInvokeMetaDict = { "time_cost": self.time_cost, "error": self.error, "tool_config": self.tool_config, } + return result class DatasourceLabel(BaseModel): @@ -235,7 +242,7 @@ class OnlineDocumentPage(BaseModel): page_id: str = Field(..., description="The page id") page_name: str = Field(..., description="The page title") - page_icon: dict | None = Field(None, description="The page icon") + page_icon: dict[str, Any] | None = Field(None, description="The page icon") type: str = Field(..., description="The type of the page") last_edited_time: str = Field(..., description="The last edited time") parent_id: str | None = Field(None, description="The parent page id") @@ -294,7 +301,7 @@ class GetWebsiteCrawlRequest(BaseModel): Get website crawl request """ - crawl_parameters: dict = Field(..., description="The crawl parameters") + crawl_parameters: dict[str, Any] = Field(..., description="The crawl parameters") class WebSiteInfoDetail(BaseModel): @@ -351,7 +358,7 @@ class OnlineDriveFileBucket(BaseModel): bucket: str | None = Field(None, description="The file bucket") files: list[OnlineDriveFile] = Field(..., description="The file list") is_truncated: bool = Field(False, description="Whether the result is truncated") - next_page_parameters: dict | None = Field(None, description="Parameters for fetching the next page") + next_page_parameters: dict[str, Any] | None = Field(None, description="Parameters for fetching the next page") class OnlineDriveBrowseFilesRequest(BaseModel): @@ -362,7 +369,7 @@ class OnlineDriveBrowseFilesRequest(BaseModel): bucket: str | None = Field(None, description="The file bucket") prefix: str = Field(..., description="The parent folder ID") max_keys: int = Field(20, description="Page size for pagination") - next_page_parameters: dict | None = Field(None, description="Parameters for fetching the next page") + next_page_parameters: dict[str, Any] | None = Field(None, description="Parameters for fetching the next page") class OnlineDriveBrowseFilesResponse(BaseModel): diff --git a/api/core/datasource/utils/message_transformer.py b/api/core/datasource/utils/message_transformer.py index 04f15dee31..c012e128f4 100644 --- a/api/core/datasource/utils/message_transformer.py +++ b/api/core/datasource/utils/message_transformer.py @@ -71,8 +71,8 @@ class DatasourceFileMessageTransformer: if not isinstance(message.message, DatasourceMessage.BlobMessage): raise ValueError("unexpected message type") - # FIXME: should do a type check here. - assert isinstance(message.message.blob, bytes) + if not isinstance(message.message.blob, bytes): + raise TypeError(f"Expected blob to be bytes, got {type(message.message.blob).__name__}") tool_file_manager = ToolFileManager() blob_tool_file: ToolFile | None = tool_file_manager.create_file_by_raw( user_id=user_id, diff --git a/api/core/entities/knowledge_entities.py b/api/core/entities/knowledge_entities.py index b1ba3c3e2a..a13938f3fb 100644 --- a/api/core/entities/knowledge_entities.py +++ b/api/core/entities/knowledge_entities.py @@ -1,3 +1,5 @@ +from typing import Any + from pydantic import BaseModel, Field, field_validator @@ -37,7 +39,7 @@ class PipelineDocument(BaseModel): id: str position: int data_source_type: str - data_source_info: dict | None = None + data_source_info: dict[str, Any] | None = None name: str indexing_status: str error: str | None = None diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py index f3b2c31465..d07f6f913a 100644 --- a/api/core/entities/provider_configuration.py +++ b/api/core/entities/provider_configuration.py @@ -6,6 +6,7 @@ import re from collections import defaultdict from collections.abc import Iterator, Sequence from json import JSONDecodeError +from typing import Any from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType from graphon.model_runtime.entities.provider_entities import ( @@ -111,7 +112,7 @@ class ProviderConfiguration(BaseModel): return ModelProviderFactory(model_runtime=self._bound_model_runtime) return create_plugin_model_provider_factory(tenant_id=self.tenant_id) - def get_current_credentials(self, model_type: ModelType, model: str) -> dict | None: + def get_current_credentials(self, model_type: ModelType, model: str) -> dict[str, Any] | None: """ Get current credentials. @@ -233,7 +234,7 @@ class ProviderConfiguration(BaseModel): return session.execute(stmt).scalar_one_or_none() - def _get_specific_provider_credential(self, credential_id: str) -> dict | None: + def _get_specific_provider_credential(self, credential_id: str) -> dict[str, Any] | None: """ Get a specific provider credential by ID. :param credential_id: Credential ID @@ -297,7 +298,7 @@ class ProviderConfiguration(BaseModel): stmt = stmt.where(ProviderCredential.id != exclude_id) return session.execute(stmt).scalar_one_or_none() is not None - def get_provider_credential(self, credential_id: str | None = None) -> dict | None: + def get_provider_credential(self, credential_id: str | None = None) -> dict[str, Any] | None: """ Get provider credentials. @@ -317,7 +318,9 @@ class ProviderConfiguration(BaseModel): else [], ) - def validate_provider_credentials(self, credentials: dict, credential_id: str = "", session: Session | None = None): + def validate_provider_credentials( + self, credentials: dict[str, Any], credential_id: str = "", session: Session | None = None + ): """ Validate custom credentials. :param credentials: provider credentials @@ -447,7 +450,7 @@ class ProviderConfiguration(BaseModel): provider_names.append(model_provider_id.provider_name) return provider_names - def create_provider_credential(self, credentials: dict, credential_name: str | None): + def create_provider_credential(self, credentials: dict[str, Any], credential_name: str | None): """ Add custom provider credentials. :param credentials: provider credentials @@ -515,7 +518,7 @@ class ProviderConfiguration(BaseModel): def update_provider_credential( self, - credentials: dict, + credentials: dict[str, Any], credential_id: str, credential_name: str | None, ): @@ -760,7 +763,7 @@ class ProviderConfiguration(BaseModel): def _get_specific_custom_model_credential( self, model_type: ModelType, model: str, credential_id: str - ) -> dict | None: + ) -> dict[str, Any] | None: """ Get a specific provider credential by ID. :param credential_id: Credential ID @@ -832,7 +835,9 @@ class ProviderConfiguration(BaseModel): stmt = stmt.where(ProviderModelCredential.id != exclude_id) return session.execute(stmt).scalar_one_or_none() is not None - def get_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str | None) -> dict | None: + def get_custom_model_credential( + self, model_type: ModelType, model: str, credential_id: str | None + ) -> dict[str, Any] | None: """ Get custom model credentials. @@ -872,7 +877,7 @@ class ProviderConfiguration(BaseModel): self, model_type: ModelType, model: str, - credentials: dict, + credentials: dict[str, Any], credential_id: str = "", session: Session | None = None, ): @@ -939,7 +944,7 @@ class ProviderConfiguration(BaseModel): return _validate(new_session) def create_custom_model_credential( - self, model_type: ModelType, model: str, credentials: dict, credential_name: str | None + self, model_type: ModelType, model: str, credentials: dict[str, Any], credential_name: str | None ) -> None: """ Create a custom model credential. @@ -1002,7 +1007,12 @@ class ProviderConfiguration(BaseModel): raise def update_custom_model_credential( - self, model_type: ModelType, model: str, credentials: dict, credential_name: str | None, credential_id: str + self, + model_type: ModelType, + model: str, + credentials: dict[str, Any], + credential_name: str | None, + credential_id: str, ) -> None: """ Update a custom model credential. @@ -1412,7 +1422,9 @@ class ProviderConfiguration(BaseModel): # Get model instance of LLM return model_provider_factory.get_model_type_instance(provider=self.provider.provider, model_type=model_type) - def get_model_schema(self, model_type: ModelType, model: str, credentials: dict | None) -> AIModelEntity | None: + def get_model_schema( + self, model_type: ModelType, model: str, credentials: dict[str, Any] | None + ) -> AIModelEntity | None: """ Get model schema """ @@ -1471,7 +1483,7 @@ class ProviderConfiguration(BaseModel): return secret_input_form_variables - def obfuscated_credentials(self, credentials: dict, credential_form_schemas: list[CredentialFormSchema]): + def obfuscated_credentials(self, credentials: dict[str, Any], credential_form_schemas: list[CredentialFormSchema]): """ Obfuscated credentials. diff --git a/api/core/entities/provider_entities.py b/api/core/entities/provider_entities.py index 2c8767a32b..95431c0e01 100644 --- a/api/core/entities/provider_entities.py +++ b/api/core/entities/provider_entities.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import StrEnum, auto -from typing import Union +from typing import Any, Union from graphon.model_runtime.entities.model_entities import ModelType from pydantic import BaseModel, ConfigDict, Field @@ -88,7 +88,7 @@ class SystemConfiguration(BaseModel): enabled: bool current_quota_type: ProviderQuotaType | None = None quota_configurations: list[QuotaConfiguration] = [] - credentials: dict | None = None + credentials: dict[str, Any] | None = None class CustomProviderConfiguration(BaseModel): @@ -96,7 +96,7 @@ class CustomProviderConfiguration(BaseModel): Model class for provider custom configuration. """ - credentials: dict + credentials: dict[str, Any] current_credential_id: str | None = None current_credential_name: str | None = None available_credentials: list[CredentialConfiguration] = [] @@ -109,7 +109,7 @@ class CustomModelConfiguration(BaseModel): model: str model_type: ModelType - credentials: dict | None + credentials: dict[str, Any] | None current_credential_id: str | None = None current_credential_name: str | None = None available_model_credentials: list[CredentialConfiguration] = [] @@ -145,7 +145,7 @@ class ModelLoadBalancingConfiguration(BaseModel): id: str name: str - credentials: dict + credentials: dict[str, Any] credential_source_type: str | None = None credential_id: str | None = None diff --git a/api/core/extension/api_based_extension_requestor.py b/api/core/extension/api_based_extension_requestor.py index f9e6099049..01139d07e2 100644 --- a/api/core/extension/api_based_extension_requestor.py +++ b/api/core/extension/api_based_extension_requestor.py @@ -1,4 +1,4 @@ -from typing import cast +from typing import Any, cast import httpx @@ -14,7 +14,7 @@ class APIBasedExtensionRequestor: self.api_endpoint = api_endpoint self.api_key = api_key - def request(self, point: APIBasedExtensionPoint, params: dict): + def request(self, point: APIBasedExtensionPoint, params: dict[str, Any]) -> dict[str, Any]: """ Request the api. @@ -49,4 +49,4 @@ class APIBasedExtensionRequestor: if response.status_code != 200: raise ValueError(f"request error, status_code: {response.status_code}, content: {response.text[:100]}") - return cast(dict, response.json()) + return cast(dict[str, Any], response.json()) diff --git a/api/core/extension/extensible.py b/api/core/extension/extensible.py index c2789a7a35..c08e319aac 100644 --- a/api/core/extension/extensible.py +++ b/api/core/extension/extensible.py @@ -21,8 +21,8 @@ class ExtensionModule(StrEnum): class ModuleExtension(BaseModel): extension_class: Any | None = None name: str - label: dict | None = None - form_schema: list | None = None + label: dict[str, Any] | None = None + form_schema: list[dict[str, Any]] | None = None builtin: bool = True position: int | None = None @@ -32,9 +32,9 @@ class Extensible: name: str tenant_id: str - config: dict | None = None + config: dict[str, Any] | None = None - def __init__(self, tenant_id: str, config: dict | None = None): + def __init__(self, tenant_id: str, config: dict[str, Any] | None = None): self.tenant_id = tenant_id self.config = config diff --git a/api/core/external_data_tool/api/api.py b/api/core/external_data_tool/api/api.py index 564801f189..8ce068cfbb 100644 --- a/api/core/external_data_tool/api/api.py +++ b/api/core/external_data_tool/api/api.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping +from typing import Any, TypedDict + from sqlalchemy import select from core.extension.api_based_extension_requestor import APIBasedExtensionRequestor @@ -7,6 +10,16 @@ from extensions.ext_database import db from models.api_based_extension import APIBasedExtension, APIBasedExtensionPoint +class ApiToolConfig(TypedDict, total=False): + """Expected config shape for ApiExternalDataTool. + + Not used directly in method signatures (base class accepts dict[str, Any]); + kept here to document the keys this tool reads from config. + """ + + api_based_extension_id: str + + class ApiExternalDataTool(ExternalDataTool): """ The api external data tool. @@ -16,7 +29,7 @@ class ApiExternalDataTool(ExternalDataTool): """the unique name of external data tool""" @classmethod - def validate_config(cls, tenant_id: str, config: dict): + def validate_config(cls, tenant_id: str, config: dict[str, Any]): """ Validate the incoming form config data. @@ -37,7 +50,7 @@ class ApiExternalDataTool(ExternalDataTool): if not api_based_extension: raise ValueError("api_based_extension_id is invalid") - def query(self, inputs: dict, query: str | None = None) -> str: + def query(self, inputs: Mapping[str, Any], query: str | None = None) -> str: """ Query the external data tool. diff --git a/api/core/external_data_tool/base.py b/api/core/external_data_tool/base.py index cbec2e4e42..12bea4e9e5 100644 --- a/api/core/external_data_tool/base.py +++ b/api/core/external_data_tool/base.py @@ -1,4 +1,6 @@ from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import Any from core.extension.extensible import Extensible, ExtensionModule @@ -15,14 +17,14 @@ class ExternalDataTool(Extensible, ABC): variable: str """the tool variable name of app tool""" - def __init__(self, tenant_id: str, app_id: str, variable: str, config: dict | None = None): + def __init__(self, tenant_id: str, app_id: str, variable: str, config: dict[str, Any] | None = None): super().__init__(tenant_id, config) self.app_id = app_id self.variable = variable @classmethod @abstractmethod - def validate_config(cls, tenant_id: str, config: dict): + def validate_config(cls, tenant_id: str, config: dict[str, Any]): """ Validate the incoming form config data. @@ -33,7 +35,7 @@ class ExternalDataTool(Extensible, ABC): raise NotImplementedError @abstractmethod - def query(self, inputs: dict, query: str | None = None) -> str: + def query(self, inputs: Mapping[str, Any], query: str | None = None) -> str: """ Query the external data tool. diff --git a/api/core/external_data_tool/factory.py b/api/core/external_data_tool/factory.py index 6c542d681b..f404aa7286 100644 --- a/api/core/external_data_tool/factory.py +++ b/api/core/external_data_tool/factory.py @@ -6,14 +6,14 @@ from extensions.ext_code_based_extension import code_based_extension class ExternalDataToolFactory: - def __init__(self, name: str, tenant_id: str, app_id: str, variable: str, config: dict): + def __init__(self, name: str, tenant_id: str, app_id: str, variable: str, config: dict[str, Any]): extension_class = code_based_extension.extension_class(ExtensionModule.EXTERNAL_DATA_TOOL, name) self.__extension_instance = extension_class( tenant_id=tenant_id, app_id=app_id, variable=variable, config=config ) @classmethod - def validate_config(cls, name: str, tenant_id: str, config: dict): + def validate_config(cls, name: str, tenant_id: str, config: dict[str, Any]) -> None: """ Validate the incoming form config data. diff --git a/api/core/helper/model_provider_cache.py b/api/core/helper/model_provider_cache.py index 00fcfe0b80..10d79a8239 100644 --- a/api/core/helper/model_provider_cache.py +++ b/api/core/helper/model_provider_cache.py @@ -1,6 +1,7 @@ import json from enum import StrEnum from json import JSONDecodeError +from typing import Any from extensions.ext_redis import redis_client @@ -15,7 +16,7 @@ class ProviderCredentialsCache: def __init__(self, tenant_id: str, identity_id: str, cache_type: ProviderCredentialsCacheType): self.cache_key = f"{cache_type}_credentials:tenant_id:{tenant_id}:id:{identity_id}" - def get(self) -> dict | None: + def get(self) -> dict[str, Any] | None: """ Get cached model provider credentials. @@ -33,7 +34,7 @@ class ProviderCredentialsCache: else: return None - def set(self, credentials: dict): + def set(self, credentials: dict[str, Any]): """ Cache model provider credentials. diff --git a/api/core/helper/provider_cache.py b/api/core/helper/provider_cache.py index ffb5148386..9f167ca49c 100644 --- a/api/core/helper/provider_cache.py +++ b/api/core/helper/provider_cache.py @@ -17,7 +17,7 @@ class ProviderCredentialsCache(ABC): """Generate cache key based on subclass implementation""" pass - def get(self) -> dict | None: + def get(self) -> dict[str, Any] | None: """Get cached provider credentials""" cached_credentials = redis_client.get(self.cache_key) if cached_credentials: @@ -71,7 +71,7 @@ class ToolProviderCredentialsCache(ProviderCredentialsCache): class NoOpProviderCredentialCache: """No-op provider credential cache""" - def get(self) -> dict | None: + def get(self) -> dict[str, Any] | None: """Get cached provider credentials""" return None diff --git a/api/core/helper/tool_parameter_cache.py b/api/core/helper/tool_parameter_cache.py index 54674d4ff6..bf5bf9af03 100644 --- a/api/core/helper/tool_parameter_cache.py +++ b/api/core/helper/tool_parameter_cache.py @@ -1,6 +1,7 @@ import json from enum import StrEnum from json import JSONDecodeError +from typing import Any from extensions.ext_redis import redis_client @@ -18,7 +19,7 @@ class ToolParameterCache: f":identity_id:{identity_id}" ) - def get(self) -> dict | None: + def get(self) -> dict[str, Any] | None: """ Get cached model provider credentials. @@ -36,7 +37,7 @@ class ToolParameterCache: else: return None - def set(self, parameters: dict): + def set(self, parameters: dict[str, Any]): """Cache model provider credentials.""" redis_client.setex(self.cache_key, 86400, json.dumps(parameters)) diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index b8d5ca2f50..8d0a8b99b4 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -735,7 +735,9 @@ class IndexingRunner: @staticmethod def _update_document_index_status( - document_id: str, after_indexing_status: IndexingStatus, extra_update_params: dict | None = None + document_id: str, + after_indexing_status: IndexingStatus, + extra_update_params: Mapping[Any, Any] | None = None, ): """ Update the document indexing status. @@ -762,7 +764,7 @@ class IndexingRunner: db.session.commit() @staticmethod - def _update_segments_by_document(dataset_document_id: str, update_params: dict): + def _update_segments_by_document(dataset_document_id: str, update_params: Mapping[Any, Any]): """ Update the document segment by document id. """ diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index aa258c9f89..c43c0274cd 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -2,7 +2,7 @@ import json import logging import re from collections.abc import Sequence -from typing import Protocol, TypedDict, cast +from typing import Any, Protocol, TypedDict, cast import json_repair from graphon.enums import WorkflowNodeExecutionMetadataKey @@ -533,7 +533,7 @@ class LLMGenerator: def __instruction_modify_common( tenant_id: str, model_config: ModelConfig, - last_run: dict | None, + last_run: dict[str, Any] | None, current: str | None, error_message: str | None, instruction: str, diff --git a/api/core/llm_generator/output_parser/structured_output.py b/api/core/llm_generator/output_parser/structured_output.py index a1710f11ac..a8ad7c9179 100644 --- a/api/core/llm_generator/output_parser/structured_output.py +++ b/api/core/llm_generator/output_parser/structured_output.py @@ -200,9 +200,9 @@ def _handle_native_json_schema( provider: str, model_schema: AIModelEntity, structured_output_schema: Mapping, - model_parameters: dict, + model_parameters: dict[str, Any], rules: list[ParameterRule], -): +) -> dict[str, Any]: """ Handle structured output for models with native JSON schema support. @@ -224,7 +224,7 @@ def _handle_native_json_schema( return model_parameters -def _set_response_format(model_parameters: dict, rules: list): +def _set_response_format(model_parameters: dict[str, Any], rules: list[ParameterRule]) -> None: """ Set the appropriate response format parameter based on model rules. @@ -326,7 +326,7 @@ def _prepare_schema_for_model(provider: str, model_schema: AIModelEntity, schema return {"schema": processed_schema, "name": "llm_response"} -def remove_additional_properties(schema: dict): +def remove_additional_properties(schema: dict[str, Any]) -> None: """ Remove additionalProperties fields from JSON schema. Used for models like Gemini that don't support this property. @@ -349,7 +349,7 @@ def remove_additional_properties(schema: dict): remove_additional_properties(item) -def convert_boolean_to_string(schema: dict): +def convert_boolean_to_string(schema: dict[str, Any]) -> None: """ Convert boolean type specifications to string in JSON schema. diff --git a/api/core/logging/structured_formatter.py b/api/core/logging/structured_formatter.py index 9baf6c4682..ae7be91c17 100644 --- a/api/core/logging/structured_formatter.py +++ b/api/core/logging/structured_formatter.py @@ -3,7 +3,7 @@ import logging import traceback from datetime import UTC, datetime -from typing import Any, TypedDict +from typing import Any, NotRequired, TypedDict import orjson @@ -16,6 +16,19 @@ class IdentityDict(TypedDict, total=False): user_type: str +class LogDict(TypedDict): + ts: str + severity: str + service: str + caller: str + message: str + trace_id: NotRequired[str] + span_id: NotRequired[str] + identity: NotRequired[IdentityDict] + attributes: NotRequired[dict[str, Any]] + stack_trace: NotRequired[str] + + class StructuredJSONFormatter(logging.Formatter): """ JSON log formatter following the specified schema: @@ -55,9 +68,9 @@ class StructuredJSONFormatter(logging.Formatter): return json.dumps(log_dict, default=str, ensure_ascii=False) - def _build_log_dict(self, record: logging.LogRecord) -> dict[str, Any]: + def _build_log_dict(self, record: logging.LogRecord) -> LogDict: # Core fields - log_dict: dict[str, Any] = { + log_dict: LogDict = { "ts": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"), "severity": self.SEVERITY_MAP.get(record.levelno, "INFO"), "service": self._service_name, diff --git a/api/core/mcp/auth/auth_flow.py b/api/core/mcp/auth/auth_flow.py index d015769b54..1d8356acf6 100644 --- a/api/core/mcp/auth/auth_flow.py +++ b/api/core/mcp/auth/auth_flow.py @@ -146,7 +146,7 @@ def discover_protected_resource_metadata( return ProtectedResourceMetadata.model_validate(response.json()) elif response.status_code == 404: continue # Try next URL - except (RequestError, ValidationError): + except (RequestError, ValidationError, json.JSONDecodeError): continue # Try next URL return None @@ -166,7 +166,7 @@ def discover_oauth_authorization_server_metadata( return OAuthMetadata.model_validate(response.json()) elif response.status_code == 404: continue # Try next URL - except (RequestError, ValidationError): + except (RequestError, ValidationError, json.JSONDecodeError): continue # Try next URL return None @@ -276,7 +276,7 @@ def check_support_resource_discovery(server_url: str) -> tuple[bool, str]: else: return False, "" return False, "" - except RequestError: + except (RequestError, json.JSONDecodeError, IndexError): # Not support resource discovery, fall back to well-known OAuth metadata return False, "" diff --git a/api/core/mcp/auth_client.py b/api/core/mcp/auth_client.py index d8724b8de5..173913196e 100644 --- a/api/core/mcp/auth_client.py +++ b/api/core/mcp/auth_client.py @@ -122,7 +122,7 @@ class MCPClientWithAuthRetry(MCPClient): logger.exception("Authentication retry failed") raise MCPAuthError(f"Authentication retry failed: {e}") from e - def _execute_with_retry(self, func: Callable[..., Any], *args, **kwargs) -> Any: + def _execute_with_retry[**P, R](self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: """ Execute a function with authentication retry logic. diff --git a/api/core/mcp/entities.py b/api/core/mcp/entities.py index d6d3a677c6..21edc86a57 100644 --- a/api/core/mcp/entities.py +++ b/api/core/mcp/entities.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import StrEnum -from typing import Any, TypeVar +from typing import Any from pydantic import BaseModel @@ -9,12 +9,9 @@ from core.mcp.types import LATEST_PROTOCOL_VERSION, OAuthClientInformation, OAut SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", LATEST_PROTOCOL_VERSION] -SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any]) -LifespanContextT = TypeVar("LifespanContextT") - @dataclass -class RequestContext[SessionT: BaseSession[Any, Any, Any, Any, Any], LifespanContextT]: +class RequestContext[SessionT: BaseSession, LifespanContextT]: request_id: RequestId meta: RequestParams.Meta | None session: SessionT diff --git a/api/core/mcp/server/streamable_http.py b/api/core/mcp/server/streamable_http.py index 8de002ae55..72171d1536 100644 --- a/api/core/mcp/server/streamable_http.py +++ b/api/core/mcp/server/streamable_http.py @@ -187,15 +187,16 @@ def build_parameter_schema( def prepare_tool_arguments(app: App, arguments: dict[str, Any]) -> ToolArgumentsDict: """Prepare arguments based on app mode""" - if app.mode == AppMode.WORKFLOW: - return {"inputs": arguments} - elif app.mode == AppMode.COMPLETION: - return {"query": "", "inputs": arguments} - else: - # Chat modes - create a copy to avoid modifying original dict - args_copy = arguments.copy() - query = args_copy.pop("query", "") - return {"query": query, "inputs": args_copy} + match app.mode: + case AppMode.WORKFLOW: + return {"inputs": arguments} + case AppMode.COMPLETION: + return {"query": "", "inputs": arguments} + case _: + # Chat modes - create a copy to avoid modifying original dict + args_copy = arguments.copy() + query = args_copy.pop("query", "") + return {"query": query, "inputs": args_copy} def extract_answer_from_response(app: App, response: Any) -> str: @@ -229,17 +230,13 @@ def process_streaming_response(response: RateLimitGenerator) -> str: def process_mapping_response(app: App, response: Mapping) -> str: """Process mapping response based on app mode""" - if app.mode in { - AppMode.ADVANCED_CHAT, - AppMode.COMPLETION, - AppMode.CHAT, - AppMode.AGENT_CHAT, - }: - return response.get("answer", "") - elif app.mode == AppMode.WORKFLOW: - return json.dumps(response["data"]["outputs"], ensure_ascii=False) - else: - raise ValueError("Invalid app mode: " + str(app.mode)) + match app.mode: + case AppMode.ADVANCED_CHAT | AppMode.COMPLETION | AppMode.CHAT | AppMode.AGENT_CHAT: + return response.get("answer", "") + case AppMode.WORKFLOW: + return json.dumps(response["data"]["outputs"], ensure_ascii=False) + case _: + raise ValueError("Invalid app mode: " + str(app.mode)) def convert_input_form_to_parameters( diff --git a/api/core/mcp/session/base_session.py b/api/core/mcp/session/base_session.py index 0b3aa79838..70d45b15c4 100644 --- a/api/core/mcp/session/base_session.py +++ b/api/core/mcp/session/base_session.py @@ -55,7 +55,7 @@ class RequestResponder[ReceiveRequestT: ClientRequest | ServerRequest, SendResul request: ReceiveRequestT _session: "BaseSession[Any, Any, SendResultT, ReceiveRequestT, Any]" - _on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any] + _on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], object] def __init__( self, @@ -63,7 +63,7 @@ class RequestResponder[ReceiveRequestT: ClientRequest | ServerRequest, SendResul request_meta: RequestParams.Meta | None, request: ReceiveRequestT, session: "BaseSession[Any, Any, SendResultT, ReceiveRequestT, Any]", - on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any], + on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], object], ): self.request_id = request_id self.request_meta = request_meta diff --git a/api/core/mcp/types.py b/api/core/mcp/types.py index 2653d20a7d..10e3082aa3 100644 --- a/api/core/mcp/types.py +++ b/api/core/mcp/types.py @@ -31,7 +31,6 @@ ProgressToken = str | int Cursor = str Role = Literal["user", "assistant"] RequestId = Annotated[int | str, Field(union_mode="left_to_right")] -type AnyFunction = Callable[..., Any] class RequestParams(BaseModel): diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py index 09c84538a9..5809d6f74a 100644 --- a/api/core/memory/token_buffer_memory.py +++ b/api/core/memory/token_buffer_memory.py @@ -61,27 +61,28 @@ class TokenBufferMemory: :param is_user_message: whether this is a user message :return: PromptMessage """ - if self.conversation.mode in {AppMode.AGENT_CHAT, AppMode.COMPLETION, AppMode.CHAT}: - file_extra_config = FileUploadConfigManager.convert(self.conversation.model_config) - elif self.conversation.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - app = self.conversation.app - if not app: - raise ValueError("App not found for conversation") + match self.conversation.mode: + case AppMode.AGENT_CHAT | AppMode.COMPLETION | AppMode.CHAT: + file_extra_config = FileUploadConfigManager.convert(self.conversation.model_config) + case AppMode.ADVANCED_CHAT | AppMode.WORKFLOW: + app = self.conversation.app + if not app: + raise ValueError("App not found for conversation") - if not message.workflow_run_id: - raise ValueError("Workflow run ID not found") + if not message.workflow_run_id: + raise ValueError("Workflow run ID not found") - workflow_run = self.workflow_run_repo.get_workflow_run_by_id( - tenant_id=app.tenant_id, app_id=app.id, run_id=message.workflow_run_id - ) - if not workflow_run: - raise ValueError(f"Workflow run not found: {message.workflow_run_id}") - workflow = db.session.scalar(select(Workflow).where(Workflow.id == workflow_run.workflow_id)) - if not workflow: - raise ValueError(f"Workflow not found: {workflow_run.workflow_id}") - file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False) - else: - raise AssertionError(f"Invalid app mode: {self.conversation.mode}") + workflow_run = self.workflow_run_repo.get_workflow_run_by_id( + tenant_id=app.tenant_id, app_id=app.id, run_id=message.workflow_run_id + ) + if not workflow_run: + raise ValueError(f"Workflow run not found: {message.workflow_run_id}") + workflow = db.session.scalar(select(Workflow).where(Workflow.id == workflow_run.workflow_id)) + if not workflow: + raise ValueError(f"Workflow not found: {workflow_run.workflow_id}") + file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False) + case _: + raise AssertionError(f"Invalid app mode: {self.conversation.mode}") detail = ImagePromptMessageContent.DETAIL.HIGH if file_extra_config and app_record: diff --git a/api/core/model_manager.py b/api/core/model_manager.py index 7a214777bc..36beb55d7f 100644 --- a/api/core/model_manager.py +++ b/api/core/model_manager.py @@ -6,7 +6,7 @@ from graphon.model_runtime.callbacks.base_callback import Callback from graphon.model_runtime.entities.llm_entities import LLMResult from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelFeature, ModelType -from graphon.model_runtime.entities.rerank_entities import RerankResult +from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult from graphon.model_runtime.entities.text_embedding_entities import EmbeddingResult from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeConnectionError, InvokeRateLimitError from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel @@ -77,7 +77,7 @@ class ModelInstance: @staticmethod def _get_load_balancing_manager( - configuration: ProviderConfiguration, model_type: ModelType, model: str, credentials: dict + configuration: ProviderConfiguration, model_type: ModelType, model: str, credentials: dict[str, Any] ) -> Optional["LBModelManager"]: """ Get load balancing model credentials @@ -115,7 +115,7 @@ class ModelInstance: def invoke_llm( self, prompt_messages: Sequence[PromptMessage], - model_parameters: dict | None = None, + model_parameters: dict[str, Any] | None = None, tools: Sequence[PromptMessageTool] | None = None, stop: list[str] | None = None, stream: Literal[True] = True, @@ -126,7 +126,7 @@ class ModelInstance: def invoke_llm( self, prompt_messages: list[PromptMessage], - model_parameters: dict | None = None, + model_parameters: dict[str, Any] | None = None, tools: Sequence[PromptMessageTool] | None = None, stop: list[str] | None = None, stream: Literal[False] = False, @@ -137,7 +137,7 @@ class ModelInstance: def invoke_llm( self, prompt_messages: list[PromptMessage], - model_parameters: dict | None = None, + model_parameters: dict[str, Any] | None = None, tools: Sequence[PromptMessageTool] | None = None, stop: list[str] | None = None, stream: bool = True, @@ -147,7 +147,7 @@ class ModelInstance: def invoke_llm( self, prompt_messages: Sequence[PromptMessage], - model_parameters: dict | None = None, + model_parameters: dict[str, Any] | None = None, tools: Sequence[PromptMessageTool] | None = None, stop: Sequence[str] | None = None, stream: bool = True, @@ -172,10 +172,10 @@ class ModelInstance: function=self.model_type_instance.invoke, model=self.model_name, credentials=self.credentials, - prompt_messages=prompt_messages, + prompt_messages=list(prompt_messages), model_parameters=model_parameters, - tools=tools, - stop=stop, + tools=list(tools) if tools else None, + stop=list(stop) if stop else None, stream=stream, callbacks=callbacks, ), @@ -193,15 +193,12 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, LargeLanguageModel): raise Exception("Model type instance is not LargeLanguageModel") - return cast( - int, - self._round_robin_invoke( - function=self.model_type_instance.get_num_tokens, - model=self.model_name, - credentials=self.credentials, - prompt_messages=prompt_messages, - tools=tools, - ), + return self._round_robin_invoke( + function=self.model_type_instance.get_num_tokens, + model=self.model_name, + credentials=self.credentials, + prompt_messages=list(prompt_messages), + tools=list(tools) if tools else None, ) def invoke_text_embedding( @@ -216,15 +213,12 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, TextEmbeddingModel): raise Exception("Model type instance is not TextEmbeddingModel") - return cast( - EmbeddingResult, - self._round_robin_invoke( - function=self.model_type_instance.invoke, - model=self.model_name, - credentials=self.credentials, - texts=texts, - input_type=input_type, - ), + return self._round_robin_invoke( + function=self.model_type_instance.invoke, + model=self.model_name, + credentials=self.credentials, + texts=texts, + input_type=input_type, ) def invoke_multimodal_embedding( @@ -241,15 +235,12 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, TextEmbeddingModel): raise Exception("Model type instance is not TextEmbeddingModel") - return cast( - EmbeddingResult, - self._round_robin_invoke( - function=self.model_type_instance.invoke, - model=self.model_name, - credentials=self.credentials, - multimodel_documents=multimodel_documents, - input_type=input_type, - ), + return self._round_robin_invoke( + function=self.model_type_instance.invoke, + model=self.model_name, + credentials=self.credentials, + multimodel_documents=multimodel_documents, + input_type=input_type, ) def get_text_embedding_num_tokens(self, texts: list[str]) -> list[int]: @@ -261,14 +252,11 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, TextEmbeddingModel): raise Exception("Model type instance is not TextEmbeddingModel") - return cast( - list[int], - self._round_robin_invoke( - function=self.model_type_instance.get_num_tokens, - model=self.model_name, - credentials=self.credentials, - texts=texts, - ), + return self._round_robin_invoke( + function=self.model_type_instance.get_num_tokens, + model=self.model_name, + credentials=self.credentials, + texts=texts, ) def invoke_rerank( @@ -289,23 +277,20 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, RerankModel): raise Exception("Model type instance is not RerankModel") - return cast( - RerankResult, - self._round_robin_invoke( - function=self.model_type_instance.invoke, - model=self.model_name, - credentials=self.credentials, - query=query, - docs=docs, - score_threshold=score_threshold, - top_n=top_n, - ), + return self._round_robin_invoke( + function=self.model_type_instance.invoke, + model=self.model_name, + credentials=self.credentials, + query=query, + docs=docs, + score_threshold=score_threshold, + top_n=top_n, ) def invoke_multimodal_rerank( self, - query: dict, - docs: list[dict], + query: MultimodalRerankInput, + docs: list[MultimodalRerankInput], score_threshold: float | None = None, top_n: int | None = None, ) -> RerankResult: @@ -320,17 +305,14 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, RerankModel): raise Exception("Model type instance is not RerankModel") - return cast( - RerankResult, - self._round_robin_invoke( - function=self.model_type_instance.invoke_multimodal_rerank, - model=self.model_name, - credentials=self.credentials, - query=query, - docs=docs, - score_threshold=score_threshold, - top_n=top_n, - ), + return self._round_robin_invoke( + function=self.model_type_instance.invoke_multimodal_rerank, + model=self.model_name, + credentials=self.credentials, + query=query, + docs=docs, + score_threshold=score_threshold, + top_n=top_n, ) def invoke_moderation(self, text: str) -> bool: @@ -342,14 +324,11 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, ModerationModel): raise Exception("Model type instance is not ModerationModel") - return cast( - bool, - self._round_robin_invoke( - function=self.model_type_instance.invoke, - model=self.model_name, - credentials=self.credentials, - text=text, - ), + return self._round_robin_invoke( + function=self.model_type_instance.invoke, + model=self.model_name, + credentials=self.credentials, + text=text, ) def invoke_speech2text(self, file: IO[bytes]) -> str: @@ -361,14 +340,11 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, Speech2TextModel): raise Exception("Model type instance is not Speech2TextModel") - return cast( - str, - self._round_robin_invoke( - function=self.model_type_instance.invoke, - model=self.model_name, - credentials=self.credentials, - file=file, - ), + return self._round_robin_invoke( + function=self.model_type_instance.invoke, + model=self.model_name, + credentials=self.credentials, + file=file, ) def invoke_tts(self, content_text: str, voice: str = "") -> Iterable[bytes]: @@ -381,18 +357,15 @@ class ModelInstance: """ if not isinstance(self.model_type_instance, TTSModel): raise Exception("Model type instance is not TTSModel") - return cast( - Iterable[bytes], - self._round_robin_invoke( - function=self.model_type_instance.invoke, - model=self.model_name, - credentials=self.credentials, - content_text=content_text, - voice=voice, - ), + return self._round_robin_invoke( + function=self.model_type_instance.invoke, + model=self.model_name, + credentials=self.credentials, + content_text=content_text, + voice=voice, ) - def _round_robin_invoke(self, function: Callable[..., Any], *args, **kwargs): + def _round_robin_invoke[**P, R](self, function: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: """ Round-robin invoke :param function: function to invoke @@ -430,9 +403,8 @@ class ModelInstance: continue try: - if "credentials" in kwargs: - del kwargs["credentials"] - return function(*args, **kwargs, credentials=lb_config.credentials) + kwargs["credentials"] = lb_config.credentials + return function(*args, **kwargs) except InvokeRateLimitError as e: # expire in 60 seconds self.load_balancing_manager.cooldown(lb_config, expire=60) @@ -556,7 +528,7 @@ class LBModelManager: model_type: ModelType, model: str, load_balancing_configs: list[ModelLoadBalancingConfiguration], - managed_credentials: dict | None = None, + managed_credentials: dict[str, Any] | None = None, ): """ Load balancing model manager diff --git a/api/core/moderation/api/api.py b/api/core/moderation/api/api.py index 2d72b17a04..28165592fc 100644 --- a/api/core/moderation/api/api.py +++ b/api/core/moderation/api/api.py @@ -1,3 +1,5 @@ +from typing import Any + from pydantic import BaseModel, Field from sqlalchemy import select @@ -10,7 +12,7 @@ from models.api_based_extension import APIBasedExtension class ModerationInputParams(BaseModel): app_id: str = "" - inputs: dict = Field(default_factory=dict) + inputs: dict[str, Any] = Field(default_factory=dict) query: str = "" @@ -23,7 +25,7 @@ class ApiModeration(Moderation): name: str = "api" @classmethod - def validate_config(cls, tenant_id: str, config: dict): + def validate_config(cls, tenant_id: str, config: dict[str, Any]): """ Validate the incoming form config data. @@ -41,7 +43,7 @@ class ApiModeration(Moderation): if not extension: raise ValueError("API-based Extension not found. Please check it again.") - def moderation_for_inputs(self, inputs: dict, query: str = "") -> ModerationInputsResult: + def moderation_for_inputs(self, inputs: dict[str, Any], query: str = "") -> ModerationInputsResult: flagged = False preset_response = "" if self.config is None: @@ -73,7 +75,7 @@ class ApiModeration(Moderation): flagged=flagged, action=ModerationAction.DIRECT_OUTPUT, preset_response=preset_response ) - def _get_config_by_requestor(self, extension_point: APIBasedExtensionPoint, params: dict): + def _get_config_by_requestor(self, extension_point: APIBasedExtensionPoint, params: dict[str, Any]): if self.config is None: raise ValueError("The config is not set.") extension = self._get_api_based_extension(self.tenant_id, self.config.get("api_based_extension_id", "")) diff --git a/api/core/moderation/base.py b/api/core/moderation/base.py index 31dd0d5568..e090ee89ad 100644 --- a/api/core/moderation/base.py +++ b/api/core/moderation/base.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from enum import StrEnum, auto +from typing import Any from pydantic import BaseModel, Field @@ -15,7 +16,7 @@ class ModerationInputsResult(BaseModel): flagged: bool = False action: ModerationAction preset_response: str = "" - inputs: dict = Field(default_factory=dict) + inputs: dict[str, Any] = Field(default_factory=dict) query: str = "" @@ -33,13 +34,13 @@ class Moderation(Extensible, ABC): module: ExtensionModule = ExtensionModule.MODERATION - def __init__(self, app_id: str, tenant_id: str, config: dict | None = None): + def __init__(self, app_id: str, tenant_id: str, config: dict[str, Any] | None = None): super().__init__(tenant_id, config) self.app_id = app_id @classmethod @abstractmethod - def validate_config(cls, tenant_id: str, config: dict) -> None: + def validate_config(cls, tenant_id: str, config: dict[str, Any]) -> None: """ Validate the incoming form config data. @@ -50,7 +51,7 @@ class Moderation(Extensible, ABC): raise NotImplementedError @abstractmethod - def moderation_for_inputs(self, inputs: dict, query: str = "") -> ModerationInputsResult: + def moderation_for_inputs(self, inputs: dict[str, Any], query: str = "") -> ModerationInputsResult: """ Moderation for inputs. After the user inputs, this method will be called to perform sensitive content review @@ -75,7 +76,7 @@ class Moderation(Extensible, ABC): raise NotImplementedError @classmethod - def _validate_inputs_and_outputs_config(cls, config: dict, is_preset_response_required: bool): + def _validate_inputs_and_outputs_config(cls, config: dict[str, Any], is_preset_response_required: bool): # inputs_config inputs_config = config.get("inputs_config") if not isinstance(inputs_config, dict): diff --git a/api/core/moderation/factory.py b/api/core/moderation/factory.py index c2c8be6d6d..c22306ac94 100644 --- a/api/core/moderation/factory.py +++ b/api/core/moderation/factory.py @@ -1,3 +1,5 @@ +from typing import Any + from core.extension.extensible import ExtensionModule from core.moderation.base import Moderation, ModerationInputsResult, ModerationOutputsResult from extensions.ext_code_based_extension import code_based_extension @@ -6,12 +8,12 @@ from extensions.ext_code_based_extension import code_based_extension class ModerationFactory: __extension_instance: Moderation - def __init__(self, name: str, app_id: str, tenant_id: str, config: dict): + def __init__(self, name: str, app_id: str, tenant_id: str, config: dict[str, Any]): extension_class = code_based_extension.extension_class(ExtensionModule.MODERATION, name) self.__extension_instance = extension_class(app_id, tenant_id, config) @classmethod - def validate_config(cls, name: str, tenant_id: str, config: dict): + def validate_config(cls, name: str, tenant_id: str, config: dict[str, Any]): """ Validate the incoming form config data. @@ -24,7 +26,7 @@ class ModerationFactory: # FIXME: mypy error, try to fix it instead of using type: ignore extension_class.validate_config(tenant_id, config) # type: ignore - def moderation_for_inputs(self, inputs: dict, query: str = "") -> ModerationInputsResult: + def moderation_for_inputs(self, inputs: dict[str, Any], query: str = "") -> ModerationInputsResult: """ Moderation for inputs. After the user inputs, this method will be called to perform sensitive content review diff --git a/api/core/moderation/keywords/keywords.py b/api/core/moderation/keywords/keywords.py index 8d8d153743..7d80d3a53c 100644 --- a/api/core/moderation/keywords/keywords.py +++ b/api/core/moderation/keywords/keywords.py @@ -8,7 +8,7 @@ class KeywordsModeration(Moderation): name: str = "keywords" @classmethod - def validate_config(cls, tenant_id: str, config: dict): + def validate_config(cls, tenant_id: str, config: dict[str, Any]): """ Validate the incoming form config data. @@ -28,7 +28,7 @@ class KeywordsModeration(Moderation): if len(keywords_row_len) > 100: raise ValueError("the number of rows for the keywords must be less than 100") - def moderation_for_inputs(self, inputs: dict, query: str = "") -> ModerationInputsResult: + def moderation_for_inputs(self, inputs: dict[str, Any], query: str = "") -> ModerationInputsResult: flagged = False preset_response = "" if self.config is None: @@ -66,7 +66,7 @@ class KeywordsModeration(Moderation): flagged=flagged, action=ModerationAction.DIRECT_OUTPUT, preset_response=preset_response ) - def _is_violated(self, inputs: dict, keywords_list: list) -> bool: + def _is_violated(self, inputs: dict[str, Any], keywords_list: list[str]) -> bool: return any(self._check_keywords_in_value(keywords_list, value) for value in inputs.values()) def _check_keywords_in_value(self, keywords_list: Sequence[str], value: Any) -> bool: diff --git a/api/core/moderation/openai_moderation/openai_moderation.py b/api/core/moderation/openai_moderation/openai_moderation.py index dd038c77f1..732803b332 100644 --- a/api/core/moderation/openai_moderation/openai_moderation.py +++ b/api/core/moderation/openai_moderation/openai_moderation.py @@ -1,3 +1,5 @@ +from typing import Any + from graphon.model_runtime.entities.model_entities import ModelType from core.model_manager import ModelManager @@ -8,7 +10,7 @@ class OpenAIModeration(Moderation): name: str = "openai_moderation" @classmethod - def validate_config(cls, tenant_id: str, config: dict): + def validate_config(cls, tenant_id: str, config: dict[str, Any]): """ Validate the incoming form config data. @@ -18,7 +20,7 @@ class OpenAIModeration(Moderation): """ cls._validate_inputs_and_outputs_config(config, True) - def moderation_for_inputs(self, inputs: dict, query: str = "") -> ModerationInputsResult: + def moderation_for_inputs(self, inputs: dict[str, Any], query: str = "") -> ModerationInputsResult: flagged = False preset_response = "" if self.config is None: @@ -49,7 +51,7 @@ class OpenAIModeration(Moderation): flagged=flagged, action=ModerationAction.DIRECT_OUTPUT, preset_response=preset_response ) - def _is_violated(self, inputs: dict): + def _is_violated(self, inputs: dict[str, Any]): text = "\n".join(str(inputs.values())) model_manager = ModelManager.for_tenant(tenant_id=self.tenant_id) model_instance = model_manager.get_model_instance( diff --git a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py index 66933cea28..dd5edde630 100644 --- a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py +++ b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py @@ -778,7 +778,7 @@ class ArizePhoenixDataTrace(BaseTraceInstance): logger.info("[Arize/Phoenix] Failed to construct project URL: %s", str(e), exc_info=True) raise ValueError(f"[Arize/Phoenix] Failed to construct project URL: {str(e)}") - def _construct_llm_attributes(self, prompts: dict | list | str | None) -> dict[str, str]: + def _construct_llm_attributes(self, prompts: dict[str, Any] | list[Any] | str | None) -> dict[str, str]: """Construct LLM attributes with passed prompts for Arize/Phoenix.""" attributes: dict[str, str] = {} @@ -797,7 +797,9 @@ class ArizePhoenixDataTrace(BaseTraceInstance): path = f"{SpanAttributes.LLM_INPUT_MESSAGES}.{message_index}.{key}" set_attribute(path, value) - def set_tool_call_attributes(message_index: int, tool_index: int, tool_call: dict | object | None) -> None: + def set_tool_call_attributes( + message_index: int, tool_index: int, tool_call: dict[str, Any] | object | None + ) -> None: """Extract and assign tool call details safely.""" if not tool_call: return diff --git a/api/core/ops/langfuse_trace/langfuse_trace.py b/api/core/ops/langfuse_trace/langfuse_trace.py index 9be2ce1bdf..d53aa84aed 100644 --- a/api/core/ops/langfuse_trace/langfuse_trace.py +++ b/api/core/ops/langfuse_trace/langfuse_trace.py @@ -59,6 +59,24 @@ class LangFuseDataTrace(BaseTraceInstance): ) self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001") + @staticmethod + def _get_completion_start_time( + start_time: datetime | None, time_to_first_token: float | int | None + ) -> datetime | None: + """Convert a relative TTFT value in seconds into Langfuse's absolute completion start time.""" + if start_time is None or time_to_first_token is None: + return None + + try: + ttft_seconds = float(time_to_first_token) + except (TypeError, ValueError): + return None + + if ttft_seconds < 0: + return None + + return start_time + timedelta(seconds=ttft_seconds) + def trace(self, trace_info: BaseTraceInfo): if isinstance(trace_info, WorkflowTraceInfo): self.workflow_trace(trace_info) @@ -189,10 +207,18 @@ class LangFuseDataTrace(BaseTraceInstance): total_token = metadata.get("total_tokens", 0) prompt_tokens = 0 completion_tokens = 0 + completion_start_time = None try: - usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + usage_data = process_data.get("usage") + if not isinstance(usage_data, dict): + usage_data = outputs.get("usage") + if not isinstance(usage_data, dict): + usage_data = {} prompt_tokens = usage_data.get("prompt_tokens", 0) completion_tokens = usage_data.get("completion_tokens", 0) + completion_start_time = self._get_completion_start_time( + created_at, usage_data.get("time_to_first_token") + ) except Exception: logger.error("Failed to extract usage", exc_info=True) @@ -210,6 +236,7 @@ class LangFuseDataTrace(BaseTraceInstance): trace_id=trace_id, model=process_data.get("model_name"), start_time=created_at, + completion_start_time=completion_start_time, end_time=finished_at, input=inputs, output=outputs, @@ -290,11 +317,16 @@ class LangFuseDataTrace(BaseTraceInstance): unit=UnitEnum.TOKENS, totalCost=message_data.total_price, ) + completion_start_time = self._get_completion_start_time( + trace_info.start_time, + trace_info.gen_ai_server_time_to_first_token, + ) langfuse_generation_data = LangfuseGeneration( name="llm", trace_id=trace_id, start_time=trace_info.start_time, + completion_start_time=completion_start_time, end_time=trace_info.end_time, model=message_data.model_id, input=trace_info.inputs, diff --git a/api/core/ops/mlflow_trace/mlflow_trace.py b/api/core/ops/mlflow_trace/mlflow_trace.py index 3d8c1dd038..c070a937be 100644 --- a/api/core/ops/mlflow_trace/mlflow_trace.py +++ b/api/core/ops/mlflow_trace/mlflow_trace.py @@ -242,7 +242,7 @@ class MLflowDataTrace(BaseTraceInstance): return inputs, attributes - def _parse_knowledge_retrieval_outputs(self, outputs: dict): + def _parse_knowledge_retrieval_outputs(self, outputs: dict[str, Any]): """Parse KR outputs and attributes from KR workflow node""" retrieved = outputs.get("result", []) @@ -319,7 +319,7 @@ class MLflowDataTrace(BaseTraceInstance): end_time_ns=datetime_to_nanoseconds(trace_info.end_time), ) - def _get_message_user_id(self, metadata: dict) -> str | None: + def _get_message_user_id(self, metadata: dict[str, Any]) -> str | None: if (end_user_id := metadata.get("from_end_user_id")) and ( end_user_data := db.session.get(EndUser, end_user_id) ): @@ -468,7 +468,7 @@ class MLflowDataTrace(BaseTraceInstance): } return node_type_mapping.get(node_type, "CHAIN") # type: ignore[arg-type,call-overload] - def _set_trace_metadata(self, span: Span, metadata: dict): + def _set_trace_metadata(self, span: Span, metadata: dict[str, Any]): token = None try: # NB: Set span in context such that we can use update_current_trace() API @@ -490,7 +490,7 @@ class MLflowDataTrace(BaseTraceInstance): return messages return prompts # Fallback to original format - def _parse_single_message(self, item: dict): + def _parse_single_message(self, item: dict[str, Any]): """Postprocess single message format to be standard chat message""" role = item.get("role", "user") msg = {"role": role, "content": item.get("text", "")} diff --git a/api/core/ops/opik_trace/opik_trace.py b/api/core/ops/opik_trace/opik_trace.py index 2215bdeb33..e0c7b9bfe5 100644 --- a/api/core/ops/opik_trace/opik_trace.py +++ b/api/core/ops/opik_trace/opik_trace.py @@ -3,7 +3,7 @@ import logging import os import uuid from datetime import datetime, timedelta -from typing import cast +from typing import Any, cast from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey from opik import Opik, Trace @@ -436,7 +436,7 @@ class OpikDataTrace(BaseTraceInstance): self.add_span(span_data) - def add_trace(self, opik_trace_data: dict) -> Trace: + def add_trace(self, opik_trace_data: dict[str, Any]) -> Trace: try: trace = self.opik_client.trace(**opik_trace_data) logger.debug("Opik Trace created successfully") @@ -444,7 +444,7 @@ class OpikDataTrace(BaseTraceInstance): except Exception as e: raise ValueError(f"Opik Failed to create trace: {str(e)}") - def add_span(self, opik_span_data: dict): + def add_span(self, opik_span_data: dict[str, Any]): try: self.opik_client.span(**opik_span_data) logger.debug("Opik Span created successfully") diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index fd235faf80..cd63951537 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -324,7 +324,7 @@ class OpsTraceManager: @classmethod def encrypt_tracing_config( - cls, tenant_id: str, tracing_provider: str, tracing_config: dict, current_trace_config=None + cls, tenant_id: str, tracing_provider: str, tracing_config: dict[str, Any], current_trace_config=None ): """ Encrypt tracing config. @@ -363,7 +363,7 @@ class OpsTraceManager: return encrypted_config.model_dump() @classmethod - def decrypt_tracing_config(cls, tenant_id: str, tracing_provider: str, tracing_config: dict): + def decrypt_tracing_config(cls, tenant_id: str, tracing_provider: str, tracing_config: dict[str, Any]): """ Decrypt tracing config :param tenant_id: tenant id @@ -408,7 +408,7 @@ class OpsTraceManager: return dict(decrypted_config) @classmethod - def obfuscated_decrypt_token(cls, tracing_provider: str, decrypt_tracing_config: dict): + def obfuscated_decrypt_token(cls, tracing_provider: str, decrypt_tracing_config: dict[str, Any]): """ Decrypt tracing config :param tracing_provider: tracing provider @@ -581,7 +581,7 @@ class OpsTraceManager: return app_trace_config @staticmethod - def check_trace_config_is_effective(tracing_config: dict, tracing_provider: str): + def check_trace_config_is_effective(tracing_config: dict[str, Any], tracing_provider: str): """ Check trace config is effective :param tracing_config: tracing config @@ -596,7 +596,7 @@ class OpsTraceManager: return trace_instance(config).api_check() @staticmethod - def get_trace_config_project_key(tracing_config: dict, tracing_provider: str): + def get_trace_config_project_key(tracing_config: dict[str, Any], tracing_provider: str): """ get trace config is project key :param tracing_config: tracing config @@ -611,7 +611,7 @@ class OpsTraceManager: return trace_instance(config).get_project_key() @staticmethod - def get_trace_config_project_url(tracing_config: dict, tracing_provider: str): + def get_trace_config_project_url(tracing_config: dict[str, Any], tracing_provider: str): """ get trace config is project key :param tracing_config: tracing config @@ -1322,8 +1322,8 @@ class TraceTask: error=error, ) - def node_execution_trace(self, **kwargs) -> WorkflowNodeTraceInfo | dict: - node_data: dict = kwargs.get("node_execution_data", {}) + def node_execution_trace(self, **kwargs) -> WorkflowNodeTraceInfo | dict[str, Any]: + node_data: dict[str, Any] = kwargs.get("node_execution_data", {}) if not node_data: return {} @@ -1431,7 +1431,7 @@ class TraceTask: return node_trace return DraftNodeExecutionTrace(**node_trace.model_dump()) - def _extract_streaming_metrics(self, message_data) -> dict: + def _extract_streaming_metrics(self, message_data) -> dict[str, Any]: if not message_data.message_metadata: return {} diff --git a/api/core/plugin/backwards_invocation/app.py b/api/core/plugin/backwards_invocation/app.py index be11d2223c..c76cb865c3 100644 --- a/api/core/plugin/backwards_invocation/app.py +++ b/api/core/plugin/backwards_invocation/app.py @@ -1,6 +1,6 @@ import uuid from collections.abc import Generator, Mapping -from typing import Any, Union, cast +from typing import Any, cast from sqlalchemy import select from sqlalchemy.orm import Session @@ -72,17 +72,18 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): conversation_id = conversation_id or "" - if app.mode in {AppMode.ADVANCED_CHAT, AppMode.AGENT_CHAT, AppMode.CHAT}: - if not query: - raise ValueError("missing query") + match app.mode: + case AppMode.ADVANCED_CHAT | AppMode.AGENT_CHAT | AppMode.CHAT: + if not query: + raise ValueError("missing query") - return cls.invoke_chat_app(app, user, conversation_id, query, stream, inputs, files) - elif app.mode == AppMode.WORKFLOW: - return cls.invoke_workflow_app(app, user, stream, inputs, files) - elif app.mode == AppMode.COMPLETION: - return cls.invoke_completion_app(app, user, stream, inputs, files) - - raise ValueError("unexpected app type") + return cls.invoke_chat_app(app, user, conversation_id, query, stream, inputs, files) + case AppMode.WORKFLOW: + return cls.invoke_workflow_app(app, user, stream, inputs, files) + case AppMode.COMPLETION: + return cls.invoke_completion_app(app, user, stream, inputs, files) + case _: + raise ValueError("unexpected app type") @classmethod def invoke_chat_app( @@ -98,60 +99,61 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): """ invoke chat app """ - if app.mode == AppMode.ADVANCED_CHAT: - workflow = app.workflow - if not workflow: + match app.mode: + case AppMode.ADVANCED_CHAT: + workflow = app.workflow + if not workflow: + raise ValueError("unexpected app type") + + pause_config = PauseStateLayerConfig( + session_factory=db.engine, + state_owner_user_id=workflow.created_by, + ) + + return AdvancedChatAppGenerator().generate( + app_model=app, + workflow=workflow, + user=user, + args={ + "inputs": inputs, + "query": query, + "files": files, + "conversation_id": conversation_id, + }, + invoke_from=InvokeFrom.SERVICE_API, + workflow_run_id=str(uuid.uuid4()), + streaming=stream, + pause_state_config=pause_config, + ) + case AppMode.AGENT_CHAT: + return AgentChatAppGenerator().generate( + app_model=app, + user=user, + args={ + "inputs": inputs, + "query": query, + "files": files, + "conversation_id": conversation_id, + }, + invoke_from=InvokeFrom.SERVICE_API, + streaming=stream, + ) + case AppMode.CHAT: + return ChatAppGenerator().generate( + app_model=app, + user=user, + args={ + "inputs": inputs, + "query": query, + "files": files, + "conversation_id": conversation_id, + }, + invoke_from=InvokeFrom.SERVICE_API, + streaming=stream, + ) + case _: raise ValueError("unexpected app type") - pause_config = PauseStateLayerConfig( - session_factory=db.engine, - state_owner_user_id=workflow.created_by, - ) - - return AdvancedChatAppGenerator().generate( - app_model=app, - workflow=workflow, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - workflow_run_id=str(uuid.uuid4()), - streaming=stream, - pause_state_config=pause_config, - ) - elif app.mode == AppMode.AGENT_CHAT: - return AgentChatAppGenerator().generate( - app_model=app, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - streaming=stream, - ) - elif app.mode == AppMode.CHAT: - return ChatAppGenerator().generate( - app_model=app, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - streaming=stream, - ) - else: - raise ValueError("unexpected app type") - @classmethod def invoke_workflow_app( cls, @@ -205,7 +207,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): ) @classmethod - def _get_user(cls, user_id: str) -> Union[EndUser, Account]: + def _get_user(cls, user_id: str) -> EndUser | Account: """ get the user by user id """ diff --git a/api/core/plugin/entities/endpoint.py b/api/core/plugin/entities/endpoint.py index e5bca140f8..6419963668 100644 --- a/api/core/plugin/entities/endpoint.py +++ b/api/core/plugin/entities/endpoint.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any from pydantic import BaseModel, Field, model_validator @@ -31,7 +32,7 @@ class EndpointEntity(BasePluginEntity): entity of an endpoint """ - settings: dict + settings: dict[str, Any] tenant_id: str plugin_id: str expired_at: datetime diff --git a/api/core/plugin/entities/marketplace.py b/api/core/plugin/entities/marketplace.py index 2177e8af90..fd2094228a 100644 --- a/api/core/plugin/entities/marketplace.py +++ b/api/core/plugin/entities/marketplace.py @@ -1,3 +1,5 @@ +from typing import Any + from graphon.model_runtime.entities.provider_entities import ProviderEntity from pydantic import BaseModel, Field, computed_field, model_validator @@ -40,7 +42,7 @@ class MarketplacePluginDeclaration(BaseModel): @model_validator(mode="before") @classmethod - def transform_declaration(cls, data: dict): + def transform_declaration(cls, data: dict[str, Any]) -> dict[str, Any]: if "endpoint" in data and not data["endpoint"]: del data["endpoint"] if "model" in data and not data["model"]: diff --git a/api/core/plugin/entities/plugin.py b/api/core/plugin/entities/plugin.py index b095b4998d..4d28032a57 100644 --- a/api/core/plugin/entities/plugin.py +++ b/api/core/plugin/entities/plugin.py @@ -123,7 +123,7 @@ class PluginDeclaration(BaseModel): @model_validator(mode="before") @classmethod - def validate_category(cls, values: dict): + def validate_category(cls, values: dict[str, Any]) -> dict[str, Any]: # auto detect category if values.get("tool"): values["category"] = PluginCategory.Tool diff --git a/api/core/plugin/entities/plugin_daemon.py b/api/core/plugin/entities/plugin_daemon.py index b57180690e..e0ddb746c7 100644 --- a/api/core/plugin/entities/plugin_daemon.py +++ b/api/core/plugin/entities/plugin_daemon.py @@ -73,7 +73,7 @@ class PluginBasicBooleanResponse(BaseModel): """ result: bool - credentials: dict | None = None + credentials: dict[str, Any] | None = None class PluginModelSchemaEntity(BaseModel): diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index 059f3fa9be..4a85952dcd 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -49,7 +49,7 @@ class RequestInvokeTool(BaseModel): tool_type: Literal["builtin", "workflow", "api", "mcp"] provider: str tool: str - tool_parameters: dict + tool_parameters: dict[str, Any] credential_id: str | None = None @@ -209,7 +209,7 @@ class RequestInvokeEncrypt(BaseModel): opt: Literal["encrypt", "decrypt", "clear"] namespace: Literal["endpoint"] identity: str - data: dict = Field(default_factory=dict) + data: dict[str, Any] = Field(default_factory=dict) config: list[BasicProviderConfig] = Field(default_factory=list) diff --git a/api/core/plugin/impl/datasource.py b/api/core/plugin/impl/datasource.py index ce1ef71494..56c08addba 100644 --- a/api/core/plugin/impl/datasource.py +++ b/api/core/plugin/impl/datasource.py @@ -26,7 +26,7 @@ class PluginDatasourceManager(BasePluginClient): Fetch datasource providers for the given tenant. """ - def transformer(json_response: dict[str, Any]) -> dict: + def transformer(json_response: dict[str, Any]) -> dict[str, Any]: if json_response.get("data"): for provider in json_response.get("data", []): declaration = provider.get("declaration", {}) or {} @@ -68,7 +68,7 @@ class PluginDatasourceManager(BasePluginClient): Fetch datasource providers for the given tenant. """ - def transformer(json_response: dict[str, Any]) -> dict: + def transformer(json_response: dict[str, Any]) -> dict[str, Any]: if json_response.get("data"): for provider in json_response.get("data", []): declaration = provider.get("declaration", {}) or {} @@ -110,7 +110,7 @@ class PluginDatasourceManager(BasePluginClient): tool_provider_id = DatasourceProviderID(provider_id) - def transformer(json_response: dict[str, Any]) -> dict: + def transformer(json_response: dict[str, Any]) -> dict[str, Any]: data = json_response.get("data") if data: for datasource in data.get("declaration", {}).get("datasources", []): diff --git a/api/core/plugin/impl/endpoint.py b/api/core/plugin/impl/endpoint.py index 2db5185a2c..b335b42763 100644 --- a/api/core/plugin/impl/endpoint.py +++ b/api/core/plugin/impl/endpoint.py @@ -1,3 +1,5 @@ +from typing import Any + from core.plugin.entities.endpoint import EndpointEntityWithInstance from core.plugin.impl.base import BasePluginClient from core.plugin.impl.exc import PluginDaemonInternalServerError @@ -5,7 +7,12 @@ from core.plugin.impl.exc import PluginDaemonInternalServerError class PluginEndpointClient(BasePluginClient): def create_endpoint( - self, tenant_id: str, user_id: str, plugin_unique_identifier: str, name: str, settings: dict + self, + tenant_id: str, + user_id: str, + plugin_unique_identifier: str, + name: str, + settings: dict[str, Any], ) -> bool: """ Create an endpoint for the given plugin. @@ -49,7 +56,9 @@ class PluginEndpointClient(BasePluginClient): params={"plugin_id": plugin_id, "page": page, "page_size": page_size}, ) - def update_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict): + def update_endpoint( + self, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict[str, Any] + ) -> bool: """ Update the settings of the given endpoint. """ diff --git a/api/core/plugin/impl/model.py b/api/core/plugin/impl/model.py index 1e38c24717..703af63f7c 100644 --- a/api/core/plugin/impl/model.py +++ b/api/core/plugin/impl/model.py @@ -50,7 +50,7 @@ class PluginModelClient(BasePluginClient): provider: str, model_type: str, model: str, - credentials: dict, + credentials: dict[str, Any], ) -> AIModelEntity | None: """ Get model schema @@ -80,7 +80,7 @@ class PluginModelClient(BasePluginClient): return None def validate_provider_credentials( - self, tenant_id: str, user_id: str | None, plugin_id: str, provider: str, credentials: dict + self, tenant_id: str, user_id: str | None, plugin_id: str, provider: str, credentials: dict[str, Any] ) -> bool: """ validate the credentials of the provider @@ -118,7 +118,7 @@ class PluginModelClient(BasePluginClient): provider: str, model_type: str, model: str, - credentials: dict, + credentials: dict[str, Any], ) -> bool: """ validate the credentials of the provider @@ -157,9 +157,9 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], prompt_messages: list[PromptMessage], - model_parameters: dict | None = None, + model_parameters: dict[str, Any] | None = None, tools: list[PromptMessageTool] | None = None, stop: list[str] | None = None, stream: bool = True, @@ -206,7 +206,7 @@ class PluginModelClient(BasePluginClient): provider: str, model_type: str, model: str, - credentials: dict, + credentials: dict[str, Any], prompt_messages: list[PromptMessage], tools: list[PromptMessageTool] | None = None, ) -> int: @@ -248,7 +248,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], texts: list[str], input_type: str, ) -> EmbeddingResult: @@ -290,7 +290,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], documents: list[dict], input_type: str, ) -> EmbeddingResult: @@ -332,7 +332,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], texts: list[str], ) -> list[int]: """ @@ -372,7 +372,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], query: str, docs: list[str], score_threshold: float | None = None, @@ -418,7 +418,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], query: MultimodalRerankInput, docs: list[MultimodalRerankInput], score_threshold: float | None = None, @@ -463,7 +463,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], content_text: str, voice: str, ) -> Generator[bytes, None, None]: @@ -508,7 +508,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], language: str | None = None, ): """ @@ -552,7 +552,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], file: IO[bytes], ) -> str: """ @@ -592,7 +592,7 @@ class PluginModelClient(BasePluginClient): plugin_id: str, provider: str, model: str, - credentials: dict, + credentials: dict[str, Any], text: str, ) -> bool: """ diff --git a/api/core/plugin/impl/plugin.py b/api/core/plugin/impl/plugin.py index ec4858ae2e..8a7175bb51 100644 --- a/api/core/plugin/impl/plugin.py +++ b/api/core/plugin/impl/plugin.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from typing import Any from requests import HTTPError @@ -209,7 +210,10 @@ class PluginInstaller(BasePluginClient): "GET", f"plugin/{tenant_id}/management/decode/from_identifier", PluginDecodeResponse, - params={"plugin_unique_identifier": plugin_unique_identifier}, + params={ + "plugin_unique_identifier": plugin_unique_identifier, + "PluginUniqueIdentifier": plugin_unique_identifier, # compat with daemon <= 0.5.4 + }, ) def fetch_plugin_installation_by_ids( @@ -260,7 +264,7 @@ class PluginInstaller(BasePluginClient): original_plugin_unique_identifier: str, new_plugin_unique_identifier: str, source: PluginInstallationSource, - meta: dict, + meta: dict[str, Any], ) -> PluginInstallTaskStartResponse: """ Upgrade a plugin. diff --git a/api/core/prompt/simple_prompt_transform.py b/api/core/prompt/simple_prompt_transform.py index c706353ffe..dc8391a6a5 100644 --- a/api/core/prompt/simple_prompt_transform.py +++ b/api/core/prompt/simple_prompt_transform.py @@ -2,7 +2,7 @@ import json import os from collections.abc import Mapping, Sequence from enum import StrEnum, auto -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast from graphon.file import file_manager from graphon.model_runtime.entities.message_entities import ( @@ -34,6 +34,13 @@ class ModelMode(StrEnum): prompt_file_contents: dict[str, Any] = {} +class PromptTemplateConfigDict(TypedDict): + prompt_template: PromptTemplateParser + custom_variable_keys: list[str] + special_variable_keys: list[str] + prompt_rules: dict[str, Any] + + class SimplePromptTransform(PromptTransform): """ Simple Prompt Transform for Chatbot App Basic Mode. @@ -89,11 +96,11 @@ class SimplePromptTransform(PromptTransform): app_mode: AppMode, model_config: ModelConfigWithCredentialsEntity, pre_prompt: str, - inputs: dict, + inputs: dict[str, Any], query: str | None = None, context: str | None = None, histories: str | None = None, - ) -> tuple[str, dict]: + ) -> tuple[str, dict[str, Any]]: # get prompt template prompt_template_config = self.get_prompt_template( app_mode=app_mode, @@ -105,18 +112,13 @@ class SimplePromptTransform(PromptTransform): with_memory_prompt=histories is not None, ) - custom_variable_keys_obj = prompt_template_config["custom_variable_keys"] - special_variable_keys_obj = prompt_template_config["special_variable_keys"] + custom_variable_keys = prompt_template_config["custom_variable_keys"] + if not isinstance(custom_variable_keys, list): + raise TypeError(f"Expected list for custom_variable_keys, got {type(custom_variable_keys)}") - # Type check for custom_variable_keys - if not isinstance(custom_variable_keys_obj, list): - raise TypeError(f"Expected list for custom_variable_keys, got {type(custom_variable_keys_obj)}") - custom_variable_keys = cast(list[str], custom_variable_keys_obj) - - # Type check for special_variable_keys - if not isinstance(special_variable_keys_obj, list): - raise TypeError(f"Expected list for special_variable_keys, got {type(special_variable_keys_obj)}") - special_variable_keys = cast(list[str], special_variable_keys_obj) + special_variable_keys = prompt_template_config["special_variable_keys"] + if not isinstance(special_variable_keys, list): + raise TypeError(f"Expected list for special_variable_keys, got {type(special_variable_keys)}") variables = {k: inputs[k] for k in custom_variable_keys if k in inputs} @@ -150,7 +152,7 @@ class SimplePromptTransform(PromptTransform): has_context: bool, query_in_prompt: bool, with_memory_prompt: bool = False, - ) -> dict[str, object]: + ) -> PromptTemplateConfigDict: prompt_rules = self._get_prompt_rule(app_mode=app_mode, provider=provider, model=model) custom_variable_keys: list[str] = [] @@ -173,18 +175,19 @@ class SimplePromptTransform(PromptTransform): prompt += prompt_rules.get("query_prompt", "{{#query#}}") special_variable_keys.append("#query#") - return { + result: PromptTemplateConfigDict = { "prompt_template": PromptTemplateParser(template=prompt), "custom_variable_keys": custom_variable_keys, "special_variable_keys": special_variable_keys, "prompt_rules": prompt_rules, } + return result def _get_chat_model_prompt_messages( self, app_mode: AppMode, pre_prompt: str, - inputs: dict, + inputs: dict[str, Any], query: str, context: str | None, files: Sequence["File"], @@ -231,7 +234,7 @@ class SimplePromptTransform(PromptTransform): self, app_mode: AppMode, pre_prompt: str, - inputs: dict, + inputs: dict[str, Any], query: str, context: str | None, files: Sequence["File"], @@ -310,7 +313,7 @@ class SimplePromptTransform(PromptTransform): return prompt_message - def _get_prompt_rule(self, app_mode: AppMode, provider: str, model: str): + def _get_prompt_rule(self, app_mode: AppMode, provider: str, model: str) -> dict[str, Any]: """ Get simple prompt rule. :param app_mode: app mode @@ -322,7 +325,7 @@ class SimplePromptTransform(PromptTransform): # Check if the prompt file is already loaded if prompt_file_name in prompt_file_contents: - return cast(dict, prompt_file_contents[prompt_file_name]) + return cast(dict[str, Any], prompt_file_contents[prompt_file_name]) # Get the absolute path of the subdirectory prompt_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "prompt_templates") @@ -335,7 +338,7 @@ class SimplePromptTransform(PromptTransform): # Store the content of the prompt file prompt_file_contents[prompt_file_name] = content - return cast(dict, content) + return cast(dict[str, Any], content) def _prompt_file_name(self, app_mode: AppMode, provider: str, model: str) -> str: # baichuan diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 552de66f8b..39ef31632e 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -856,7 +856,7 @@ class ProviderManager: secret_variables: list[str], cache_type: ProviderCredentialsCacheType, is_provider: bool = False, - ) -> dict: + ) -> dict[str, Any]: """Get and decrypt credentials with caching.""" credentials_cache = ProviderCredentialsCache( tenant_id=tenant_id, @@ -961,36 +961,37 @@ class ProviderManager: raise ValueError("quota_used is None") if provider_record.quota_limit is None: raise ValueError("quota_limit is None") - if provider_quota.quota_type == ProviderQuotaType.TRIAL and trail_pool is not None: - quota_configuration = QuotaConfiguration( - quota_type=provider_quota.quota_type, - quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, - quota_used=trail_pool.quota_used, - quota_limit=trail_pool.quota_limit, - is_valid=trail_pool.quota_limit > trail_pool.quota_used or trail_pool.quota_limit == -1, - restrict_models=provider_quota.restrict_models, - ) + match provider_quota.quota_type: + case ProviderQuotaType.TRIAL if trail_pool is not None: + quota_configuration = QuotaConfiguration( + quota_type=provider_quota.quota_type, + quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, + quota_used=trail_pool.quota_used, + quota_limit=trail_pool.quota_limit, + is_valid=trail_pool.quota_limit > trail_pool.quota_used or trail_pool.quota_limit == -1, + restrict_models=provider_quota.restrict_models, + ) - elif provider_quota.quota_type == ProviderQuotaType.PAID and paid_pool is not None: - quota_configuration = QuotaConfiguration( - quota_type=provider_quota.quota_type, - quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, - quota_used=paid_pool.quota_used, - quota_limit=paid_pool.quota_limit, - is_valid=paid_pool.quota_limit > paid_pool.quota_used or paid_pool.quota_limit == -1, - restrict_models=provider_quota.restrict_models, - ) + case ProviderQuotaType.PAID if paid_pool is not None: + quota_configuration = QuotaConfiguration( + quota_type=provider_quota.quota_type, + quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, + quota_used=paid_pool.quota_used, + quota_limit=paid_pool.quota_limit, + is_valid=paid_pool.quota_limit > paid_pool.quota_used or paid_pool.quota_limit == -1, + restrict_models=provider_quota.restrict_models, + ) - else: - quota_configuration = QuotaConfiguration( - quota_type=provider_quota.quota_type, - quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, - quota_used=provider_record.quota_used, - quota_limit=provider_record.quota_limit, - is_valid=provider_record.quota_limit > provider_record.quota_used - or provider_record.quota_limit == -1, - restrict_models=provider_quota.restrict_models, - ) + case _: + quota_configuration = QuotaConfiguration( + quota_type=provider_quota.quota_type, + quota_unit=provider_hosting_configuration.quota_unit or QuotaUnit.TOKENS, + quota_used=provider_record.quota_used, + quota_limit=provider_record.quota_limit, + is_valid=provider_record.quota_limit > provider_record.quota_used + or provider_record.quota_limit == -1, + restrict_models=provider_quota.restrict_models, + ) quota_configurations.append(quota_configuration) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index c1654ac130..f978e072f3 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -174,8 +174,8 @@ class RetrievalService: cls, dataset_id: str, query: str, - external_retrieval_model: dict | None = None, - metadata_filtering_conditions: dict | None = None, + external_retrieval_model: dict[str, Any] | None = None, + metadata_filtering_conditions: dict[str, Any] | None = None, ): stmt = select(Dataset).where(Dataset.id == dataset_id) dataset = db.session.scalar(stmt) diff --git a/api/core/rag/datasource/vdb/vector_backend_registry.py b/api/core/rag/datasource/vdb/vector_backend_registry.py new file mode 100644 index 0000000000..15f4357caf --- /dev/null +++ b/api/core/rag/datasource/vdb/vector_backend_registry.py @@ -0,0 +1,87 @@ +"""Vector store backend discovery. + +Backends live in workspace packages under ``api/packages/dify-vdb-*/src/dify_vdb_*``. Each package +declares third-party dependencies and registers ``importlib`` entry points in group +``dify.vector_backends`` (see each package's ``pyproject.toml``). + +Shared types and the :class:`~core.rag.datasource.vdb.vector_factory.AbstractVectorFactory` protocol +remain in this package (``vector_base``, ``vector_factory``, ``vector_type``, ``field``). + +Optional **built-in** targets in ``_BUILTIN_VECTOR_FACTORY_TARGETS`` (normally empty) load without a +distribution; entry points take precedence when both exist. + +After changing packages, run ``uv sync`` so installed dist-info entry points match ``pyproject.toml``. +""" + +from __future__ import annotations + +import importlib +import logging +from importlib.metadata import entry_points +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory + +logger = logging.getLogger(__name__) + +_VECTOR_FACTORY_CACHE: dict[str, type[AbstractVectorFactory]] = {} + +# module_path:class_name — optional fallback when no distribution registers the backend. +_BUILTIN_VECTOR_FACTORY_TARGETS: dict[str, str] = {} + + +def clear_vector_factory_cache() -> None: + """Drop lazily loaded factories (for tests or plugin reload).""" + _VECTOR_FACTORY_CACHE.clear() + + +def _vector_backend_entry_points(): + return entry_points().select(group="dify.vector_backends") + + +def _load_plugin_factory(vector_type: str) -> type[AbstractVectorFactory] | None: + for ep in _vector_backend_entry_points(): + if ep.name != vector_type: + continue + try: + loaded = ep.load() + except Exception: + logger.exception("Failed to load vector backend entry point %s", ep.name) + raise + return loaded # type: ignore[return-value] + return None + + +def _unsupported(vector_type: str) -> ValueError: + installed = sorted(ep.name for ep in _vector_backend_entry_points()) + available_msg = f" Installed backends: {', '.join(installed)}." if installed else " No backends installed." + return ValueError( + f"Vector store {vector_type!r} is not supported.{available_msg} " + "Install a plugin (uv sync --group vdb-all, or vdb- per api/pyproject.toml), " + "or register a dify.vector_backends entry point." + ) + + +def _load_builtin_factory(vector_type: str) -> type[AbstractVectorFactory]: + target = _BUILTIN_VECTOR_FACTORY_TARGETS.get(vector_type) + if not target: + raise _unsupported(vector_type) + module_path, _, attr = target.partition(":") + module = importlib.import_module(module_path) + return getattr(module, attr) # type: ignore[no-any-return] + + +def get_vector_factory_class(vector_type: str) -> type[AbstractVectorFactory]: + """Resolve :class:`AbstractVectorFactory` for a :class:`~VectorType` string value.""" + if vector_type in _VECTOR_FACTORY_CACHE: + return _VECTOR_FACTORY_CACHE[vector_type] + + plugin_cls = _load_plugin_factory(vector_type) + if plugin_cls is not None: + _VECTOR_FACTORY_CACHE[vector_type] = plugin_cls + return plugin_cls + + cls = _load_builtin_factory(vector_type) + _VECTOR_FACTORY_CACHE[vector_type] = cls + return cls diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py index 0ef88e1010..dddd5fc994 100644 --- a/api/core/rag/datasource/vdb/vector_factory.py +++ b/api/core/rag/datasource/vdb/vector_factory.py @@ -9,6 +9,7 @@ from sqlalchemy import select from configs import dify_config from core.model_manager import ModelManager +from core.rag.datasource.vdb.vector_backend_registry import get_vector_factory_class from core.rag.datasource.vdb.vector_base import BaseVector, VectorIndexStructDict from core.rag.datasource.vdb.vector_type import VectorType from core.rag.embedding.cached_embedding import CacheEmbedding @@ -41,7 +42,23 @@ class AbstractVectorFactory(ABC): class Vector: def __init__(self, dataset: Dataset, attributes: list | None = None): if attributes is None: - attributes = ["doc_id", "dataset_id", "document_id", "doc_hash", "doc_type"] + # `is_summary` and `original_chunk_id` are stored on summary vectors + # by `SummaryIndexService` and read back by `RetrievalService` to + # route summary hits through their original parent chunks. They + # must be listed here so vector backends that use this list as an + # explicit return-properties projection (notably Weaviate) actually + # return those fields; without them, summary hits silently + # collapse into `is_summary = False` branches and the summary + # retrieval path is a no-op. See #34884. + attributes = [ + "doc_id", + "dataset_id", + "document_id", + "doc_hash", + "doc_type", + "is_summary", + "original_chunk_id", + ] self._dataset = dataset self._embeddings = self._get_embeddings() self._attributes = attributes @@ -69,137 +86,7 @@ class Vector: @staticmethod def get_vector_factory(vector_type: str) -> type[AbstractVectorFactory]: - match vector_type: - case VectorType.CHROMA: - from core.rag.datasource.vdb.chroma.chroma_vector import ChromaVectorFactory - - return ChromaVectorFactory - case VectorType.MILVUS: - from core.rag.datasource.vdb.milvus.milvus_vector import MilvusVectorFactory - - return MilvusVectorFactory - case VectorType.ALIBABACLOUD_MYSQL: - from core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector import ( - AlibabaCloudMySQLVectorFactory, - ) - - return AlibabaCloudMySQLVectorFactory - case VectorType.MYSCALE: - from core.rag.datasource.vdb.myscale.myscale_vector import MyScaleVectorFactory - - return MyScaleVectorFactory - case VectorType.PGVECTOR: - from core.rag.datasource.vdb.pgvector.pgvector import PGVectorFactory - - return PGVectorFactory - case VectorType.VASTBASE: - from core.rag.datasource.vdb.pyvastbase.vastbase_vector import VastbaseVectorFactory - - return VastbaseVectorFactory - case VectorType.PGVECTO_RS: - from core.rag.datasource.vdb.pgvecto_rs.pgvecto_rs import PGVectoRSFactory - - return PGVectoRSFactory - case VectorType.QDRANT: - from core.rag.datasource.vdb.qdrant.qdrant_vector import QdrantVectorFactory - - return QdrantVectorFactory - case VectorType.RELYT: - from core.rag.datasource.vdb.relyt.relyt_vector import RelytVectorFactory - - return RelytVectorFactory - case VectorType.ELASTICSEARCH: - from core.rag.datasource.vdb.elasticsearch.elasticsearch_vector import ElasticSearchVectorFactory - - return ElasticSearchVectorFactory - case VectorType.ELASTICSEARCH_JA: - from core.rag.datasource.vdb.elasticsearch.elasticsearch_ja_vector import ( - ElasticSearchJaVectorFactory, - ) - - return ElasticSearchJaVectorFactory - case VectorType.TIDB_VECTOR: - from core.rag.datasource.vdb.tidb_vector.tidb_vector import TiDBVectorFactory - - return TiDBVectorFactory - case VectorType.WEAVIATE: - from core.rag.datasource.vdb.weaviate.weaviate_vector import WeaviateVectorFactory - - return WeaviateVectorFactory - case VectorType.TENCENT: - from core.rag.datasource.vdb.tencent.tencent_vector import TencentVectorFactory - - return TencentVectorFactory - case VectorType.ORACLE: - from core.rag.datasource.vdb.oracle.oraclevector import OracleVectorFactory - - return OracleVectorFactory - case VectorType.OPENSEARCH: - from core.rag.datasource.vdb.opensearch.opensearch_vector import OpenSearchVectorFactory - - return OpenSearchVectorFactory - case VectorType.ANALYTICDB: - from core.rag.datasource.vdb.analyticdb.analyticdb_vector import AnalyticdbVectorFactory - - return AnalyticdbVectorFactory - case VectorType.COUCHBASE: - from core.rag.datasource.vdb.couchbase.couchbase_vector import CouchbaseVectorFactory - - return CouchbaseVectorFactory - case VectorType.BAIDU: - from core.rag.datasource.vdb.baidu.baidu_vector import BaiduVectorFactory - - return BaiduVectorFactory - case VectorType.VIKINGDB: - from core.rag.datasource.vdb.vikingdb.vikingdb_vector import VikingDBVectorFactory - - return VikingDBVectorFactory - case VectorType.UPSTASH: - from core.rag.datasource.vdb.upstash.upstash_vector import UpstashVectorFactory - - return UpstashVectorFactory - case VectorType.TIDB_ON_QDRANT: - from core.rag.datasource.vdb.tidb_on_qdrant.tidb_on_qdrant_vector import TidbOnQdrantVectorFactory - - return TidbOnQdrantVectorFactory - case VectorType.LINDORM: - from core.rag.datasource.vdb.lindorm.lindorm_vector import LindormVectorStoreFactory - - return LindormVectorStoreFactory - case VectorType.OCEANBASE | VectorType.SEEKDB: - from core.rag.datasource.vdb.oceanbase.oceanbase_vector import OceanBaseVectorFactory - - return OceanBaseVectorFactory - case VectorType.OPENGAUSS: - from core.rag.datasource.vdb.opengauss.opengauss import OpenGaussFactory - - return OpenGaussFactory - case VectorType.TABLESTORE: - from core.rag.datasource.vdb.tablestore.tablestore_vector import TableStoreVectorFactory - - return TableStoreVectorFactory - case VectorType.HUAWEI_CLOUD: - from core.rag.datasource.vdb.huawei.huawei_cloud_vector import HuaweiCloudVectorFactory - - return HuaweiCloudVectorFactory - case VectorType.MATRIXONE: - from core.rag.datasource.vdb.matrixone.matrixone_vector import MatrixoneVectorFactory - - return MatrixoneVectorFactory - case VectorType.CLICKZETTA: - from core.rag.datasource.vdb.clickzetta.clickzetta_vector import ClickzettaVectorFactory - - return ClickzettaVectorFactory - case VectorType.IRIS: - from core.rag.datasource.vdb.iris.iris_vector import IrisVectorFactory - - return IrisVectorFactory - case VectorType.HOLOGRES: - from core.rag.datasource.vdb.hologres.hologres_vector import HologresVectorFactory - - return HologresVectorFactory - case _: - raise ValueError(f"Vector store {vector_type} is not supported.") + return get_vector_factory_class(vector_type) def create(self, texts: list | None = None, **kwargs): if texts: diff --git a/api/tests/integration_tests/vdb/test_vector_store.py b/api/core/rag/datasource/vdb/vector_integration_test_support.py similarity index 83% rename from api/tests/integration_tests/vdb/test_vector_store.py rename to api/core/rag/datasource/vdb/vector_integration_test_support.py index a033443cf8..3148b7d5c1 100644 --- a/api/tests/integration_tests/vdb/test_vector_store.py +++ b/api/core/rag/datasource/vdb/vector_integration_test_support.py @@ -1,10 +1,19 @@ +"""Shared helpers for vector DB integration tests (used by workspace packages under ``api/packages``). + +:class:`AbstractVectorTest` and helper functions live here so package tests can import +``core.rag.datasource.vdb.vector_integration_test_support`` without relying on the +``tests.*`` package. + +The ``setup_mock_redis`` fixture lives in ``api/packages/conftest.py`` and is +auto-discovered by pytest for all package tests. +""" + import uuid -from unittest.mock import MagicMock import pytest +from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.models.document import Document -from extensions import ext_redis from models.dataset import Dataset @@ -25,24 +34,10 @@ def get_example_document(doc_id: str) -> Document: return doc -@pytest.fixture -def setup_mock_redis(): - # get - ext_redis.redis_client.get = MagicMock(return_value=None) - - # set - ext_redis.redis_client.set = MagicMock(return_value=None) - - # lock - mock_redis_lock = MagicMock() - mock_redis_lock.__enter__ = MagicMock() - mock_redis_lock.__exit__ = MagicMock() - ext_redis.redis_client.lock = mock_redis_lock - - class AbstractVectorTest: + vector: BaseVector + def __init__(self): - self.vector = None self.dataset_id = str(uuid.uuid4()) self.collection_name = Dataset.gen_collection_name_by_id(self.dataset_id) + "_test" self.example_doc_id = str(uuid.uuid4()) diff --git a/api/core/rag/docstore/dataset_docstore.py b/api/core/rag/docstore/dataset_docstore.py index 40f45953af..8e9ebdd17a 100644 --- a/api/core/rag/docstore/dataset_docstore.py +++ b/api/core/rag/docstore/dataset_docstore.py @@ -244,7 +244,7 @@ class DatasetDocumentStore: return document_segment def add_multimodel_documents_binding(self, segment_id: str, multimodel_documents: list[AttachmentDocument] | None): - if multimodel_documents: + if multimodel_documents and self._document_id is not None: for multimodel_document in multimodel_documents: binding = SegmentAttachmentBinding( tenant_id=self._dataset.tenant_id, diff --git a/api/core/rag/embedding/cached_embedding.py b/api/core/rag/embedding/cached_embedding.py index 8d1c0da392..9f1c73ec88 100644 --- a/api/core/rag/embedding/cached_embedding.py +++ b/api/core/rag/embedding/cached_embedding.py @@ -106,7 +106,7 @@ class CacheEmbedding(Embeddings): return text_embeddings - def embed_multimodal_documents(self, multimodel_documents: list[dict]) -> list[list[float]]: + def embed_multimodal_documents(self, multimodel_documents: list[dict[str, Any]]) -> list[list[float]]: """Embed file documents.""" # use doc embedding cache or store if not exists multimodel_embeddings: list[Any] = [None for _ in range(len(multimodel_documents))] @@ -232,7 +232,7 @@ class CacheEmbedding(Embeddings): return embedding_results # type: ignore - def embed_multimodal_query(self, multimodel_document: dict) -> list[float]: + def embed_multimodal_query(self, multimodel_document: dict[str, Any]) -> list[float]: """Embed multimodal documents.""" # use doc embedding cache or store if not exists file_id = multimodel_document["file_id"] diff --git a/api/core/rag/embedding/embedding_base.py b/api/core/rag/embedding/embedding_base.py index 1be55bda80..7ae5c09ab7 100644 --- a/api/core/rag/embedding/embedding_base.py +++ b/api/core/rag/embedding/embedding_base.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any class Embeddings(ABC): @@ -10,7 +11,7 @@ class Embeddings(ABC): raise NotImplementedError @abstractmethod - def embed_multimodal_documents(self, multimodel_documents: list[dict]) -> list[list[float]]: + def embed_multimodal_documents(self, multimodel_documents: list[dict[str, Any]]) -> list[list[float]]: """Embed file documents.""" raise NotImplementedError @@ -20,7 +21,7 @@ class Embeddings(ABC): raise NotImplementedError @abstractmethod - def embed_multimodal_query(self, multimodel_document: dict) -> list[float]: + def embed_multimodal_query(self, multimodel_document: dict[str, Any]) -> list[float]: """Embed multimodal query.""" raise NotImplementedError diff --git a/api/core/rag/entities/__init__.py b/api/core/rag/entities/__init__.py index ce48496110..373b68894b 100644 --- a/api/core/rag/entities/__init__.py +++ b/api/core/rag/entities/__init__.py @@ -1,19 +1,34 @@ from core.rag.entities.citation_metadata import RetrievalSourceMetadata from core.rag.entities.context_entities import DocumentContext +from core.rag.entities.event import DatasourceCompletedEvent, DatasourceErrorEvent, DatasourceProcessingEvent +from core.rag.entities.index_entities import EconomySetting, EmbeddingSetting, IndexMethod from core.rag.entities.metadata_entities import Condition, MetadataFilteringCondition, SupportedComparisonOperator from core.rag.entities.processing_entities import ParentMode, PreProcessingRule, Rule, Segmentation -from core.rag.entities.retrieval_settings import KeywordSetting, VectorSetting +from core.rag.entities.retrieval_settings import ( + KeywordSetting, + RerankingModelConfig, + VectorSetting, + WeightedScoreConfig, +) __all__ = [ "Condition", + "DatasourceCompletedEvent", + "DatasourceErrorEvent", + "DatasourceProcessingEvent", "DocumentContext", + "EconomySetting", + "EmbeddingSetting", + "IndexMethod", "KeywordSetting", "MetadataFilteringCondition", "ParentMode", "PreProcessingRule", + "RerankingModelConfig", "RetrievalSourceMetadata", "Rule", "Segmentation", "SupportedComparisonOperator", "VectorSetting", + "WeightedScoreConfig", ] diff --git a/api/core/rag/entities/index_entities.py b/api/core/rag/entities/index_entities.py new file mode 100644 index 0000000000..f86a04fa9f --- /dev/null +++ b/api/core/rag/entities/index_entities.py @@ -0,0 +1,30 @@ +from typing import Literal + +from pydantic import BaseModel + + +class EmbeddingSetting(BaseModel): + """ + Embedding Setting. + """ + + embedding_provider_name: str + embedding_model_name: str + + +class EconomySetting(BaseModel): + """ + Economy Setting. + """ + + keyword_number: int + + +class IndexMethod(BaseModel): + """ + Knowledge Index Setting. + """ + + indexing_technique: Literal["high_quality", "economy"] + embedding_setting: EmbeddingSetting + economy_setting: EconomySetting diff --git a/api/core/rag/entities/retrieval_settings.py b/api/core/rag/entities/retrieval_settings.py index f52e0f0142..8d40ab68fd 100644 --- a/api/core/rag/entities/retrieval_settings.py +++ b/api/core/rag/entities/retrieval_settings.py @@ -1,4 +1,27 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field + + +class RerankingModelConfig(BaseModel): + """ + Canonical reranking model configuration. + + Accepts both naming conventions: + - reranking_provider_name / reranking_model_name (services layer) + - provider / model (workflow layer via validation_alias) + """ + + model_config = ConfigDict(populate_by_name=True) + + reranking_provider_name: str = Field(validation_alias="provider") + reranking_model_name: str = Field(validation_alias="model") + + @property + def provider(self) -> str: + return self.reranking_provider_name + + @property + def model(self) -> str: + return self.reranking_model_name class VectorSetting(BaseModel): @@ -17,3 +40,12 @@ class KeywordSetting(BaseModel): """ keyword_weight: float + + +class WeightedScoreConfig(BaseModel): + """ + Weighted score Config. + """ + + vector_setting: VectorSetting + keyword_setting: KeywordSetting diff --git a/api/core/rag/extractor/csv_extractor.py b/api/core/rag/extractor/csv_extractor.py index 3bfae9d6bd..19bc9cec84 100644 --- a/api/core/rag/extractor/csv_extractor.py +++ b/api/core/rag/extractor/csv_extractor.py @@ -1,6 +1,7 @@ """Abstract interface for document loader implementations.""" import csv +from typing import Any import pandas as pd @@ -23,7 +24,7 @@ class CSVExtractor(BaseExtractor): encoding: str | None = None, autodetect_encoding: bool = False, source_column: str | None = None, - csv_args: dict | None = None, + csv_args: dict[str, Any] | None = None, ): """Initialize with file path.""" self._file_path = file_path diff --git a/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py b/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py index 7dd8beaa46..f9fbfbc409 100644 --- a/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py +++ b/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py @@ -19,12 +19,15 @@ class UnstructuredWordExtractor(BaseExtractor): def extract(self) -> list[Document]: from unstructured.__version__ import __version__ as __unstructured_version__ - from unstructured.file_utils.filetype import FileType, detect_filetype + from unstructured.file_utils.filetype import ( # pyright: ignore[reportPrivateImportUsage] + FileType, + detect_filetype, + ) unstructured_version = tuple(int(x) for x in __unstructured_version__.split(".")) # check the file extension try: - import magic # noqa: F401 + import magic # noqa: F401 # pyright: ignore[reportUnusedImport] is_doc = detect_filetype(self._file_path) == FileType.DOC except ImportError: diff --git a/api/core/rag/extractor/watercrawl/client.py b/api/core/rag/extractor/watercrawl/client.py index 7b4a388df9..d1ce142dbd 100644 --- a/api/core/rag/extractor/watercrawl/client.py +++ b/api/core/rag/extractor/watercrawl/client.py @@ -54,8 +54,8 @@ class BaseAPIClient: self, method: str, endpoint: str, - query_params: dict | None = None, - data: dict | None = None, + query_params: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, **kwargs, ) -> Response: stream = kwargs.pop("stream", False) @@ -66,19 +66,25 @@ class BaseAPIClient: return self.session.request(method, url, params=query_params, json=data, **kwargs) - def _get(self, endpoint: str, query_params: dict | None = None, **kwargs): + def _get(self, endpoint: str, query_params: dict[str, Any] | None = None, **kwargs): return self._request("GET", endpoint, query_params=query_params, **kwargs) - def _post(self, endpoint: str, query_params: dict | None = None, data: dict | None = None, **kwargs): + def _post( + self, endpoint: str, query_params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, **kwargs + ): return self._request("POST", endpoint, query_params=query_params, data=data, **kwargs) - def _put(self, endpoint: str, query_params: dict | None = None, data: dict | None = None, **kwargs): + def _put( + self, endpoint: str, query_params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, **kwargs + ): return self._request("PUT", endpoint, query_params=query_params, data=data, **kwargs) - def _delete(self, endpoint: str, query_params: dict | None = None, **kwargs): + def _delete(self, endpoint: str, query_params: dict[str, Any] | None = None, **kwargs): return self._request("DELETE", endpoint, query_params=query_params, **kwargs) - def _patch(self, endpoint: str, query_params: dict | None = None, data: dict | None = None, **kwargs): + def _patch( + self, endpoint: str, query_params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, **kwargs + ): return self._request("PATCH", endpoint, query_params=query_params, data=data, **kwargs) @@ -99,7 +105,7 @@ class WaterCrawlAPIClient(BaseAPIClient): finally: response.close() - def process_response(self, response: Response) -> dict | bytes | list | None | Generator: + def process_response(self, response: Response) -> dict[str, Any] | bytes | list[Any] | None | Generator: if response.status_code == 401: raise WaterCrawlAuthenticationError(response) @@ -186,7 +192,7 @@ class WaterCrawlAPIClient(BaseAPIClient): yield from generator def get_crawl_request_results( - self, item_id: str, page: int = 1, page_size: int = 25, query_params: dict | None = None + self, item_id: str, page: int = 1, page_size: int = 25, query_params: dict[str, Any] | None = None ): query_params = query_params or {} query_params.update({"page": page or 1, "page_size": page_size or 25}) @@ -210,7 +216,7 @@ class WaterCrawlAPIClient(BaseAPIClient): if event_data["type"] == "result": return event_data["data"] - def download_result(self, result_object: dict): + def download_result(self, result_object: dict[str, Any]): response = httpx.get(result_object["result"], timeout=None) try: response.raise_for_status() diff --git a/api/core/rag/extractor/watercrawl/provider.py b/api/core/rag/extractor/watercrawl/provider.py index 2a9403eda0..ae7bebcb9b 100644 --- a/api/core/rag/extractor/watercrawl/provider.py +++ b/api/core/rag/extractor/watercrawl/provider.py @@ -120,7 +120,7 @@ class WaterCrawlProvider: } def _get_results( - self, crawl_request_id: str, query_params: dict | None = None + self, crawl_request_id: str, query_params: dict[str, Any] | None = None ) -> Generator[WatercrawlDocumentData, None, None]: page = 0 page_size = 100 diff --git a/api/core/rag/index_processor/index_processor.py b/api/core/rag/index_processor/index_processor.py index 813a84cbbd..aded5315bd 100644 --- a/api/core/rag/index_processor/index_processor.py +++ b/api/core/rag/index_processor/index_processor.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from typing import Any from flask import current_app -from sqlalchemy import delete, func, select +from sqlalchemy import delete, func, select, update from core.db.session_factory import session_factory from core.rag.index_processor.constant.index_type import IndexTechniqueType @@ -63,11 +63,11 @@ class IndexProcessor: summary_index_setting: SummaryIndexSettingDict | None = None, ) -> IndexingResultDict: with session_factory.create_session() as session: - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if not document: raise KnowledgeIndexNodeError(f"Document {document_id} not found.") - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise KnowledgeIndexNodeError(f"Dataset {dataset_id} not found.") @@ -104,12 +104,12 @@ class IndexProcessor: document.indexing_status = "completed" document.completed_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) document.word_count = ( - session.query(func.sum(DocumentSegment.word_count)) - .where( - DocumentSegment.document_id == document_id, - DocumentSegment.dataset_id == dataset_id, + session.scalar( + select(func.sum(DocumentSegment.word_count)).where( + DocumentSegment.document_id == document_id, + DocumentSegment.dataset_id == dataset_id, + ) ) - .scalar() ) or 0 # Update need_summary based on dataset's summary_index_setting if summary_index_setting and summary_index_setting.get("enable") is True: @@ -118,15 +118,17 @@ class IndexProcessor: document.need_summary = False session.add(document) # update document segment status - session.query(DocumentSegment).where( - DocumentSegment.document_id == document_id, - DocumentSegment.dataset_id == dataset_id, - ).update( - { - DocumentSegment.status: "completed", - DocumentSegment.enabled: True, - DocumentSegment.completed_at: datetime.datetime.now(datetime.UTC).replace(tzinfo=None), - } + session.execute( + update(DocumentSegment) + .where( + DocumentSegment.document_id == document_id, + DocumentSegment.dataset_id == dataset_id, + ) + .values( + status="completed", + enabled=True, + completed_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None), + ) ) result: IndexingResultDict = { @@ -151,11 +153,11 @@ class IndexProcessor: doc_language = None with session_factory.create_session() as session: if document_id: - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) else: document = None - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise KnowledgeIndexNodeError(f"Dataset {dataset_id} not found.") diff --git a/api/core/rag/index_processor/processor/paragraph_index_processor.py b/api/core/rag/index_processor/processor/paragraph_index_processor.py index 4a731bf277..a487c49053 100644 --- a/api/core/rag/index_processor/processor/paragraph_index_processor.py +++ b/api/core/rag/index_processor/processor/paragraph_index_processor.py @@ -3,8 +3,7 @@ import logging import re import uuid -from collections.abc import Mapping -from typing import Any, cast +from typing import Any, TypedDict, cast logger = logging.getLogger(__name__) @@ -55,6 +54,12 @@ from services.summary_index_service import SummaryIndexService _file_access_controller = DatabaseFileAccessController() +class ParagraphFormatPreviewDict(TypedDict): + chunk_structure: str + preview: list[dict[str, Any]] + total_segments: int + + class ParagraphIndexProcessor(BaseIndexProcessor): def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]: text_docs = ExtractProcessor.extract( @@ -266,16 +271,17 @@ class ParagraphIndexProcessor(BaseIndexProcessor): keyword = Keyword(dataset) keyword.add_texts(documents) - def format_preview(self, chunks: Any) -> Mapping[str, Any]: + def format_preview(self, chunks: Any) -> ParagraphFormatPreviewDict: if isinstance(chunks, list): preview = [] for content in chunks: preview.append({"content": content}) - return { + result: ParagraphFormatPreviewDict = { "chunk_structure": IndexStructureType.PARAGRAPH_INDEX, "preview": preview, "total_segments": len(chunks), } + return result else: raise ValueError("Chunks is not a list") diff --git a/api/core/rag/index_processor/processor/parent_child_index_processor.py b/api/core/rag/index_processor/processor/parent_child_index_processor.py index 53596b5de8..ba277d5018 100644 --- a/api/core/rag/index_processor/processor/parent_child_index_processor.py +++ b/api/core/rag/index_processor/processor/parent_child_index_processor.py @@ -3,8 +3,7 @@ import json import logging import uuid -from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict from sqlalchemy import delete, select @@ -36,6 +35,13 @@ from services.summary_index_service import SummaryIndexService logger = logging.getLogger(__name__) +class ParentChildFormatPreviewDict(TypedDict): + chunk_structure: str + parent_mode: str + preview: list[dict[str, Any]] + total_segments: int + + class ParentChildIndexProcessor(BaseIndexProcessor): def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]: text_docs = ExtractProcessor.extract( @@ -153,14 +159,12 @@ class ParentChildIndexProcessor(BaseIndexProcessor): if node_ids: # Find segments by index_node_id with session_factory.create_session() as session: - segments = ( - session.query(DocumentSegment) - .filter( + segments = session.scalars( + select(DocumentSegment).where( DocumentSegment.dataset_id == dataset.id, DocumentSegment.index_node_id.in_(node_ids), ) - .all() - ) + ).all() segment_ids = [segment.id for segment in segments] if segment_ids: SummaryIndexService.delete_summaries_for_segments(dataset, segment_ids) @@ -351,17 +355,18 @@ class ParentChildIndexProcessor(BaseIndexProcessor): if all_multimodal_documents and dataset.is_multimodal: vector.create_multimodal(all_multimodal_documents) - def format_preview(self, chunks: Any) -> Mapping[str, Any]: + def format_preview(self, chunks: Any) -> ParentChildFormatPreviewDict: parent_childs = ParentChildStructureChunk.model_validate(chunks) preview = [] for parent_child in parent_childs.parent_child_chunks: preview.append({"content": parent_child.parent_content, "child_chunks": parent_child.child_contents}) - return { + result: ParentChildFormatPreviewDict = { "chunk_structure": IndexStructureType.PARENT_CHILD_INDEX, "parent_mode": parent_childs.parent_mode, "preview": preview, "total_segments": len(parent_childs.parent_child_chunks), } + return result def generate_summary_preview( self, diff --git a/api/core/rag/index_processor/processor/qa_index_processor.py b/api/core/rag/index_processor/processor/qa_index_processor.py index 273ea0f852..d3f311b08e 100644 --- a/api/core/rag/index_processor/processor/qa_index_processor.py +++ b/api/core/rag/index_processor/processor/qa_index_processor.py @@ -4,11 +4,11 @@ import logging import re import threading import uuid -from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict import pandas as pd from flask import Flask, current_app +from sqlalchemy import select from werkzeug.datastructures import FileStorage from core.db.session_factory import session_factory @@ -36,6 +36,12 @@ from services.summary_index_service import SummaryIndexService logger = logging.getLogger(__name__) +class QAFormatPreviewDict(TypedDict): + chunk_structure: str + qa_preview: list[dict[str, Any]] + total_segments: int + + class QAIndexProcessor(BaseIndexProcessor): def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]: text_docs = ExtractProcessor.extract( @@ -158,14 +164,12 @@ class QAIndexProcessor(BaseIndexProcessor): if node_ids: # Find segments by index_node_id with session_factory.create_session() as session: - segments = ( - session.query(DocumentSegment) - .filter( + segments = session.scalars( + select(DocumentSegment).where( DocumentSegment.dataset_id == dataset.id, DocumentSegment.index_node_id.in_(node_ids), ) - .all() - ) + ).all() segment_ids = [segment.id for segment in segments] if segment_ids: SummaryIndexService.delete_summaries_for_segments(dataset, segment_ids) @@ -230,16 +234,17 @@ class QAIndexProcessor(BaseIndexProcessor): else: raise ValueError("Indexing technique must be high quality.") - def format_preview(self, chunks: Any) -> Mapping[str, Any]: + def format_preview(self, chunks: Any) -> QAFormatPreviewDict: qa_chunks = QAStructureChunk.model_validate(chunks) preview = [] for qa_chunk in qa_chunks.qa_chunks: preview.append({"question": qa_chunk.question, "answer": qa_chunk.answer}) - return { + result: QAFormatPreviewDict = { "chunk_structure": IndexStructureType.QA_INDEX, "qa_preview": preview, "total_segments": len(qa_chunks.qa_chunks), } + return result def generate_summary_preview( self, diff --git a/api/core/rag/rerank/rerank_model.py b/api/core/rag/rerank/rerank_model.py index 8283be19f9..a8d37845a5 100644 --- a/api/core/rag/rerank/rerank_model.py +++ b/api/core/rag/rerank/rerank_model.py @@ -1,7 +1,7 @@ import base64 from graphon.model_runtime.entities.model_entities import ModelType -from graphon.model_runtime.entities.rerank_entities import RerankResult +from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult from core.model_manager import ModelInstance, ModelManager from core.rag.index_processor.constant.doc_type import DocType @@ -123,7 +123,7 @@ class RerankModelRunner(BaseRerankRunner): :param query_type: query type :return: rerank result """ - docs = [] + docs: list[MultimodalRerankInput] = [] doc_ids = set() unique_documents = [] for document in documents: @@ -138,26 +138,28 @@ class RerankModelRunner(BaseRerankRunner): if upload_file: blob = storage.load_once(upload_file.key) document_file_base64 = base64.b64encode(blob).decode() - document_file_dict = { - "content": document_file_base64, - "content_type": document.metadata["doc_type"], - } - docs.append(document_file_dict) + docs.append( + MultimodalRerankInput( + content=document_file_base64, + content_type=document.metadata["doc_type"], + ) + ) else: - document_text_dict = { - "content": document.page_content, - "content_type": document.metadata.get("doc_type") or DocType.TEXT, - } - docs.append(document_text_dict) + docs.append( + MultimodalRerankInput( + content=document.page_content, + content_type=document.metadata.get("doc_type") or DocType.TEXT, + ) + ) doc_ids.add(document.metadata["doc_id"]) unique_documents.append(document) elif document.provider == "external": if document not in unique_documents: docs.append( - { - "content": document.page_content, - "content_type": document.metadata.get("doc_type") or DocType.TEXT, - } + MultimodalRerankInput( + content=document.page_content, + content_type=document.metadata.get("doc_type") or DocType.TEXT, + ) ) unique_documents.append(document) @@ -171,12 +173,12 @@ class RerankModelRunner(BaseRerankRunner): if upload_file: blob = storage.load_once(upload_file.key) file_query = base64.b64encode(blob).decode() - file_query_dict = { - "content": file_query, - "content_type": DocType.IMAGE, - } + file_query_input = MultimodalRerankInput( + content=file_query, + content_type=DocType.IMAGE, + ) rerank_result = self.rerank_model_instance.invoke_multimodal_rerank( - query=file_query_dict, docs=docs, score_threshold=score_threshold, top_n=top_n + query=file_query_input, docs=docs, score_threshold=score_threshold, top_n=top_n ) return rerank_result, unique_documents else: diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index 4e9b53b83e..8ebc840b99 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -14,8 +14,8 @@ 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 sqlalchemy import and_, func, literal, or_, select -from sqlalchemy.orm import Session +from sqlalchemy import and_, func, literal, or_, select, update +from sqlalchemy.orm import sessionmaker from core.app.app_config.entities import ( DatasetEntity, @@ -276,8 +276,8 @@ class DatasetRetrieval: document_ids = [i.segment.document_id for i in records] with session_factory.create_session() as session: - datasets = session.query(Dataset).where(Dataset.id.in_(dataset_ids)).all() - documents = session.query(DatasetDocument).where(DatasetDocument.id.in_(document_ids)).all() + datasets = session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all() + documents = session.scalars(select(DatasetDocument).where(DatasetDocument.id.in_(document_ids))).all() dataset_map = {i.id: i for i in datasets} document_map = {i.id: i for i in documents} @@ -875,7 +875,11 @@ class DatasetRetrieval: return retrieval_resource_list def _on_retrieval_end( - self, flask_app: Flask, documents: list[Document], message_id: str | None = None, timer: dict | None = None + self, + flask_app: Flask, + documents: list[Document], + message_id: str | None = None, + timer: dict[str, Any] | None = None, ): """Handle retrieval end.""" with flask_app.app_context(): @@ -884,7 +888,7 @@ class DatasetRetrieval: self._send_trace_task(message_id, documents, timer) return - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # Collect all document_ids and batch fetch DatasetDocuments document_ids = { doc.metadata["document_id"] @@ -971,15 +975,16 @@ class DatasetRetrieval: # Batch update hit_count for all segments if segment_ids_to_update: - session.query(DocumentSegment).where(DocumentSegment.id.in_(segment_ids_to_update)).update( - {DocumentSegment.hit_count: DocumentSegment.hit_count + 1}, - synchronize_session=False, + session.execute( + update(DocumentSegment) + .where(DocumentSegment.id.in_(segment_ids_to_update)) + .values(hit_count=DocumentSegment.hit_count + 1) + .execution_options(synchronize_session=False) ) - session.commit() self._send_trace_task(message_id, documents, timer) - def _send_trace_task(self, message_id: str | None, documents: list[Document], timer: dict | None): + def _send_trace_task(self, message_id: str | None, documents: list[Document], timer: dict[str, Any] | None): """Send trace task if trace manager is available.""" trace_manager: TraceQueueManager | None = ( self.application_generate_entity.trace_manager if self.application_generate_entity else None @@ -1141,7 +1146,7 @@ class DatasetRetrieval: invoke_from: InvokeFrom, hit_callback: DatasetIndexToolCallbackHandler, user_id: str, - inputs: dict, + inputs: dict[str, Any], ) -> list[DatasetRetrieverBaseTool] | None: """ A dataset tool is a tool that can be used to retrieve information from a dataset @@ -1336,7 +1341,7 @@ class DatasetRetrieval: metadata_filtering_mode: str, metadata_model_config: ModelConfig, metadata_filtering_conditions: MetadataFilteringCondition | None, - inputs: dict, + inputs: dict[str, Any], ) -> tuple[dict[str, list[str]] | None, MetadataFilteringCondition | None]: document_query = select(DatasetDocument).where( DatasetDocument.dataset_id.in_(dataset_ids), @@ -1416,7 +1421,7 @@ class DatasetRetrieval: metadata_filter_document_ids[document.dataset_id].append(document.id) # type: ignore return metadata_filter_document_ids, metadata_condition - def _replace_metadata_filter_value(self, text: str, inputs: dict) -> str: + def _replace_metadata_filter_value(self, text: str, inputs: dict[str, Any]) -> str: if not inputs: return text @@ -1823,7 +1828,7 @@ class DatasetRetrieval: def _get_available_datasets(self, tenant_id: str, dataset_ids: list[str]) -> list[Dataset]: with session_factory.create_session() as session: subquery = ( - session.query(DocumentModel.dataset_id, func.count(DocumentModel.id).label("available_document_count")) + select(DocumentModel.dataset_id, func.count(DocumentModel.id).label("available_document_count")) .where( DocumentModel.indexing_status == "completed", DocumentModel.enabled == True, @@ -1835,13 +1840,12 @@ class DatasetRetrieval: .subquery() ) - results = ( - session.query(Dataset) + results = session.scalars( + select(Dataset) .outerjoin(subquery, Dataset.id == subquery.c.dataset_id) .where(Dataset.tenant_id == tenant_id, Dataset.id.in_(dataset_ids)) .where((subquery.c.available_document_count > 0) | (Dataset.provider == "external")) - .all() - ) + ).all() available_datasets = [] for dataset in results: diff --git a/api/core/rag/retrieval/output_parser/react_output.py b/api/core/rag/retrieval/output_parser/react_output.py index 9a14d41716..29abae4280 100644 --- a/api/core/rag/retrieval/output_parser/react_output.py +++ b/api/core/rag/retrieval/output_parser/react_output.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import NamedTuple, Union +from typing import Any, NamedTuple, Union @dataclass @@ -10,7 +10,7 @@ class ReactAction: tool: str """The name of the Tool to execute.""" - tool_input: Union[str, dict] + tool_input: Union[str, dict[str, Any]] """The input to pass in to the Tool.""" log: str """Additional information to log about the action.""" @@ -19,7 +19,7 @@ class ReactAction: class ReactFinish(NamedTuple): """The final return value of an ReactFinish.""" - return_values: dict + return_values: dict[str, Any] """Dictionary of return values.""" log: str """Additional information to log about the return value""" diff --git a/api/core/rag/retrieval/router/multi_dataset_react_route.py b/api/core/rag/retrieval/router/multi_dataset_react_route.py index dd280cdf6a..9b223075d8 100644 --- a/api/core/rag/retrieval/router/multi_dataset_react_route.py +++ b/api/core/rag/retrieval/router/multi_dataset_react_route.py @@ -1,5 +1,5 @@ from collections.abc import Generator, Sequence -from typing import Union +from typing import Any, Union from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageRole, PromptMessageTool @@ -139,7 +139,7 @@ class ReactMultiDatasetRouter: def _invoke_llm( self, - completion_param: dict, + completion_param: dict[str, Any], model_instance: ModelInstance, prompt_messages: list[PromptMessage], stop: list[str], diff --git a/api/core/rag/splitter/text_splitter.py b/api/core/rag/splitter/text_splitter.py index 8977611f93..7f2117e2dd 100644 --- a/api/core/rag/splitter/text_splitter.py +++ b/api/core/rag/splitter/text_splitter.py @@ -63,7 +63,7 @@ class TextSplitter(BaseDocumentTransformer, ABC): def split_text(self, text: str) -> list[str]: """Split text into multiple components.""" - def create_documents(self, texts: list[str], metadatas: list[dict] | None = None) -> list[Document]: + def create_documents(self, texts: list[str], metadatas: list[dict[str, Any]] | None = None) -> list[Document]: """Create documents from a list of texts.""" _metadatas = metadatas or [{}] * len(texts) documents = [] diff --git a/api/core/rag/summary_index/summary_index.py b/api/core/rag/summary_index/summary_index.py index 6f120bd471..bff5f85dec 100644 --- a/api/core/rag/summary_index/summary_index.py +++ b/api/core/rag/summary_index/summary_index.py @@ -1,6 +1,8 @@ import concurrent.futures import logging +from sqlalchemy import select + from core.db.session_factory import session_factory from core.rag.index_processor.constant.index_type import IndexTechniqueType from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict @@ -21,7 +23,7 @@ class SummaryIndex: ) -> None: if is_preview: with session_factory.create_session() as session: - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset or dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY: return @@ -34,32 +36,31 @@ class SummaryIndex: if not document_id: return - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) # Skip qa_model documents if document is None or document.doc_form == "qa_model": return - query = session.query(DocumentSegment).filter_by( - dataset_id=dataset_id, - document_id=document_id, - status="completed", - enabled=True, - ) - segments = query.all() + segments = session.scalars( + select(DocumentSegment).where( + DocumentSegment.dataset_id == dataset_id, + DocumentSegment.document_id == document_id, + DocumentSegment.status == "completed", + DocumentSegment.enabled == True, + ) + ).all() segment_ids = [segment.id for segment in segments] if not segment_ids: return - existing_summaries = ( - session.query(DocumentSegmentSummary) - .filter( + existing_summaries = session.scalars( + select(DocumentSegmentSummary).where( DocumentSegmentSummary.chunk_id.in_(segment_ids), DocumentSegmentSummary.dataset_id == dataset_id, DocumentSegmentSummary.status == "completed", ) - .all() - ) + ).all() completed_summary_segment_ids = {i.chunk_id for i in existing_summaries} # Preview mode should process segments that are MISSING completed summaries pending_segment_ids = [sid for sid in segment_ids if sid not in completed_summary_segment_ids] @@ -73,7 +74,7 @@ class SummaryIndex: def process_segment(segment_id: str) -> None: """Process a single segment in a thread with a fresh DB session.""" with session_factory.create_session() as session: - segment = session.query(DocumentSegment).filter_by(id=segment_id).first() + segment = session.scalar(select(DocumentSegment).where(DocumentSegment.id == segment_id).limit(1)) if segment is None: return try: diff --git a/api/core/telemetry/gateway.py b/api/core/telemetry/gateway.py index 7b013d0563..812edeeb14 100644 --- a/api/core/telemetry/gateway.py +++ b/api/core/telemetry/gateway.py @@ -89,7 +89,7 @@ def _get_case_routing() -> dict[TelemetryCase, CaseRoute]: return _case_routing -def __getattr__(name: str) -> dict: +def __getattr__(name: str) -> Any: """Lazy module-level access to routing tables.""" if name == "CASE_ROUTING": return _get_case_routing() diff --git a/api/core/tools/__base/tool.py b/api/core/tools/__base/tool.py index 7bb2cdb876..ab0f73a9a2 100644 --- a/api/core/tools/__base/tool.py +++ b/api/core/tools/__base/tool.py @@ -198,7 +198,7 @@ class Tool(ABC): message=ToolInvokeMessage.TextMessage(text=text), ) - def create_blob_message(self, blob: bytes, meta: dict | None = None) -> ToolInvokeMessage: + def create_blob_message(self, blob: bytes, meta: dict[str, Any] | None = None) -> ToolInvokeMessage: """ create a blob message @@ -212,7 +212,7 @@ class Tool(ABC): meta=meta, ) - def create_json_message(self, object: dict, suppress_output: bool = False) -> ToolInvokeMessage: + def create_json_message(self, object: dict[str, Any], suppress_output: bool = False) -> ToolInvokeMessage: """ create a json message """ diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index d5d3d1b1d9..410ec72baf 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -75,22 +75,27 @@ class ToolProviderApiEntity(BaseModel): parameter.pop("input_schema", None) # ------------- optional_fields = self.optional_field("server_url", self.server_url) - if self.type == ToolProviderType.MCP: - optional_fields.update(self.optional_field("updated_at", self.updated_at)) - optional_fields.update(self.optional_field("server_identifier", self.server_identifier)) - optional_fields.update( - self.optional_field( - "configuration", self.configuration.model_dump() if self.configuration else MCPConfiguration() + match self.type: + case ToolProviderType.MCP: + optional_fields.update(self.optional_field("updated_at", self.updated_at)) + optional_fields.update(self.optional_field("server_identifier", self.server_identifier)) + optional_fields.update( + self.optional_field( + "configuration", self.configuration.model_dump() if self.configuration else MCPConfiguration() + ) ) - ) - optional_fields.update( - self.optional_field("authentication", self.authentication.model_dump() if self.authentication else None) - ) - optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration)) - optional_fields.update(self.optional_field("masked_headers", self.masked_headers)) - optional_fields.update(self.optional_field("original_headers", self.original_headers)) - elif self.type == ToolProviderType.WORKFLOW: - optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id)) + optional_fields.update( + self.optional_field( + "authentication", self.authentication.model_dump() if self.authentication else None + ) + ) + optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration)) + optional_fields.update(self.optional_field("masked_headers", self.masked_headers)) + optional_fields.update(self.optional_field("original_headers", self.original_headers)) + case ToolProviderType.WORKFLOW: + optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id)) + case _: + pass return { "id": self.id, "author": self.author, diff --git a/api/core/tools/entities/tool_bundle.py b/api/core/tools/entities/tool_bundle.py index 10710c4376..4e07b7157a 100644 --- a/api/core/tools/entities/tool_bundle.py +++ b/api/core/tools/entities/tool_bundle.py @@ -1,4 +1,5 @@ from collections.abc import Mapping +from typing import Any from pydantic import BaseModel, Field @@ -26,6 +27,6 @@ class ApiToolBundle(BaseModel): # icon icon: str | None = None # openapi operation - openapi: dict + openapi: dict[str, Any] # output schema output_schema: Mapping[str, object] = Field(default_factory=dict) diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index 31e879add2..0c77693dde 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -149,7 +149,7 @@ class ToolInvokeMessage(BaseModel): text: str class JsonMessage(BaseModel): - json_object: dict | list + json_object: dict[str, Any] | list[Any] suppress_output: bool = Field(default=False, description="Whether to suppress JSON output in result string") class BlobMessage(BaseModel): @@ -337,7 +337,7 @@ class ToolParameter(PluginParameter): form: ToolParameterForm = Field(..., description="The form of the parameter, schema/form/llm") llm_description: str | None = None # MCP object and array type parameters use this field to store the schema - input_schema: dict | None = None + input_schema: dict[str, Any] | None = None @classmethod def get_simple_instance( @@ -450,6 +450,12 @@ class WorkflowToolParameterConfiguration(BaseModel): form: ToolParameter.ToolParameterForm = Field(..., description="The form of the parameter") +class ToolInvokeMetaDict(TypedDict): + time_cost: float + error: str | None + tool_config: dict[str, Any] | None + + class ToolInvokeMeta(BaseModel): """ Tool invoke meta @@ -457,7 +463,7 @@ class ToolInvokeMeta(BaseModel): time_cost: float = Field(..., description="The time cost of the tool invoke") error: str | None = None - tool_config: dict | None = None + tool_config: dict[str, Any] | None = None @classmethod def empty(cls) -> ToolInvokeMeta: @@ -473,12 +479,13 @@ class ToolInvokeMeta(BaseModel): """ return cls(time_cost=0.0, error=error, tool_config={}) - def to_dict(self): - return { + def to_dict(self) -> ToolInvokeMetaDict: + result: ToolInvokeMetaDict = { "time_cost": self.time_cost, "error": self.error, "tool_config": self.tool_config, } + return result class ToolLabel(BaseModel): diff --git a/api/core/tools/tool_engine.py b/api/core/tools/tool_engine.py index 685d687d8c..d060fa8b49 100644 --- a/api/core/tools/tool_engine.py +++ b/api/core/tools/tool_engine.py @@ -47,7 +47,7 @@ class ToolEngine: @staticmethod def agent_invoke( tool: Tool, - tool_parameters: Union[str, dict], + tool_parameters: Union[str, dict[str, Any]], user_id: str, tenant_id: str, message: Message, @@ -85,7 +85,8 @@ class ToolEngine: invocation_meta_dict: dict[str, ToolInvokeMeta] = {} def message_callback( - invocation_meta_dict: dict, messages: Generator[ToolInvokeMessage | ToolInvokeMeta, None, None] + invocation_meta_dict: dict[str, ToolInvokeMeta], + messages: Generator[ToolInvokeMessage | ToolInvokeMeta, None, None], ): for message in messages: if isinstance(message, ToolInvokeMeta): @@ -200,7 +201,7 @@ class ToolEngine: @staticmethod def _invoke( tool: Tool, - tool_parameters: dict, + tool_parameters: dict[str, Any], user_id: str, conversation_id: str | None = None, app_id: str | None = None, @@ -262,6 +263,8 @@ class ToolEngine: ensure_ascii=False, ) ) + elif response.type == ToolInvokeMessage.MessageType.VARIABLE: + continue else: parts.append(str(response.message)) diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py index 7ac29cf069..d8674b3af9 100644 --- a/api/core/tools/tool_file_manager.py +++ b/api/core/tools/tool_file_manager.py @@ -6,11 +6,11 @@ import os import time from collections.abc import Generator from mimetypes import guess_extension, guess_type -from typing import Union from uuid import uuid4 import httpx from graphon.file import File, FileTransferMethod, get_file_type_by_mime_type +from sqlalchemy import select from configs import dify_config from core.db.session_factory import session_factory @@ -157,7 +157,7 @@ class ToolFileManager: return tool_file - def get_file_binary(self, id: str) -> Union[tuple[bytes, str], None]: + def get_file_binary(self, id: str) -> tuple[bytes, str] | None: """ get file binary @@ -166,13 +166,7 @@ class ToolFileManager: :return: the binary of the file, mime type """ with session_factory.create_session() as session: - tool_file: ToolFile | None = ( - session.query(ToolFile) - .where( - ToolFile.id == id, - ) - .first() - ) + tool_file: ToolFile | None = session.scalar(select(ToolFile).where(ToolFile.id == id).limit(1)) if not tool_file: return None @@ -181,7 +175,7 @@ class ToolFileManager: return blob, tool_file.mimetype - def get_file_binary_by_message_file_id(self, id: str) -> Union[tuple[bytes, str], None]: + def get_file_binary_by_message_file_id(self, id: str) -> tuple[bytes, str] | None: """ get file binary @@ -190,13 +184,7 @@ class ToolFileManager: :return: the binary of the file, mime type """ with session_factory.create_session() as session: - message_file: MessageFile | None = ( - session.query(MessageFile) - .where( - MessageFile.id == id, - ) - .first() - ) + message_file: MessageFile | None = session.scalar(select(MessageFile).where(MessageFile.id == id).limit(1)) # Check if message_file is not None if message_file is not None: @@ -210,13 +198,7 @@ class ToolFileManager: else: tool_file_id = None - tool_file: ToolFile | None = ( - session.query(ToolFile) - .where( - ToolFile.id == tool_file_id, - ) - .first() - ) + tool_file: ToolFile | None = session.scalar(select(ToolFile).where(ToolFile.id == tool_file_id).limit(1)) if not tool_file: return None @@ -234,13 +216,7 @@ class ToolFileManager: :return: the binary of the file, mime type """ with session_factory.create_session() as session: - tool_file: ToolFile | None = ( - session.query(ToolFile) - .where( - ToolFile.id == tool_file_id, - ) - .first() - ) + tool_file: ToolFile | None = session.scalar(select(ToolFile).where(ToolFile.id == tool_file_id).limit(1)) if not tool_file: return None, None diff --git a/api/core/tools/tool_label_manager.py b/api/core/tools/tool_label_manager.py index 58190d1089..d8969a3391 100644 --- a/api/core/tools/tool_label_manager.py +++ b/api/core/tools/tool_label_manager.py @@ -1,4 +1,5 @@ from sqlalchemy import delete, select +from sqlalchemy.orm import Session, sessionmaker from core.tools.__base.tool_provider import ToolProviderController from core.tools.builtin_tool.provider import BuiltinToolProviderController @@ -19,10 +20,18 @@ class ToolLabelManager: return list(set(tool_labels)) @classmethod - def update_tool_labels(cls, controller: ToolProviderController, labels: list[str]): + def update_tool_labels( + cls, controller: ToolProviderController, labels: list[str], session: Session | None = None + ) -> None: """ Update tool labels + + :param controller: tool provider controller + :param labels: list of tool labels + :param session: database session, if None, a new session will be created + :return: None """ + labels = cls.filter_tool_labels(labels) if isinstance(controller, ApiToolProviderController | WorkflowToolProviderController): @@ -30,26 +39,46 @@ class ToolLabelManager: else: raise ValueError("Unsupported tool type") + if session is not None: + cls._update_tool_labels_logics(session, provider_id, controller, labels) + else: + with sessionmaker(db.engine).begin() as _session: + cls._update_tool_labels_logics(_session, provider_id, controller, labels) + + @classmethod + def _update_tool_labels_logics( + cls, session: Session, provider_id: str, controller: ToolProviderController, labels: list[str] + ) -> None: + """ + Update tool labels logics + + :param session: database session + :param provider_id: tool provider ID + :param controller: tool provider controller + :param labels: list of tool labels + :return: None + """ + # delete old labels - db.session.execute(delete(ToolLabelBinding).where(ToolLabelBinding.tool_id == provider_id)) + _ = session.execute( + delete(ToolLabelBinding).where( + ToolLabelBinding.tool_id == provider_id, ToolLabelBinding.tool_type == controller.provider_type + ) + ) # insert new labels for label in labels: - db.session.add( - ToolLabelBinding( - tool_id=provider_id, - tool_type=controller.provider_type, - label_name=label, - ) - ) - - db.session.commit() + session.add(ToolLabelBinding(tool_id=provider_id, tool_type=controller.provider_type, label_name=label)) @classmethod def get_tool_labels(cls, controller: ToolProviderController) -> list[str]: """ Get tool labels + + :param controller: tool provider controller + :return: list of tool labels (str) """ + if isinstance(controller, ApiToolProviderController | WorkflowToolProviderController): provider_id = controller.provider_id elif isinstance(controller, BuiltinToolProviderController): @@ -60,9 +89,11 @@ class ToolLabelManager: ToolLabelBinding.tool_id == provider_id, ToolLabelBinding.tool_type == controller.provider_type, ) - labels = db.session.scalars(stmt).all() - return list(labels) + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + labels: list[str] = list(_session.scalars(stmt).all()) + + return labels @classmethod def get_tools_labels(cls, tool_providers: list[ToolProviderController]) -> dict[str, list[str]]: @@ -78,16 +109,22 @@ class ToolLabelManager: if not tool_providers: return {} + provider_ids: list[str] = [] + provider_types: set[str] = set() + for controller in tool_providers: if not isinstance(controller, ApiToolProviderController | WorkflowToolProviderController): raise ValueError("Unsupported tool type") - - provider_ids = [] - for controller in tool_providers: - assert isinstance(controller, ApiToolProviderController | WorkflowToolProviderController) provider_ids.append(controller.provider_id) + provider_types.add(controller.provider_type) - labels = db.session.scalars(select(ToolLabelBinding).where(ToolLabelBinding.tool_id.in_(provider_ids))).all() + labels: list[ToolLabelBinding] = [] + + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + stmt = select(ToolLabelBinding).where( + ToolLabelBinding.tool_id.in_(provider_ids), ToolLabelBinding.tool_type.in_(list(provider_types)) + ) + labels = list(_session.scalars(stmt).all()) tool_labels: dict[str, list[str]] = {label.tool_id: [] for label in labels} diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index d45d45c520..be13d40f3e 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -5,7 +5,7 @@ import time from collections.abc import Generator, Mapping from os import listdir, path from threading import Lock -from typing import TYPE_CHECKING, Any, Literal, Optional, Protocol, Union, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast import sqlalchemy as sa from graphon.runtime import VariablePool @@ -100,7 +100,7 @@ class ToolManager: _builtin_provider_lock = Lock() _hardcoded_providers: dict[str, BuiltinToolProviderController] = {} _builtin_providers_loaded = False - _builtin_tools_labels: dict[str, Union[I18nObject, None]] = {} + _builtin_tools_labels: dict[str, I18nObject | None] = {} @classmethod def get_hardcoded_provider(cls, provider: str) -> BuiltinToolProviderController: @@ -190,7 +190,7 @@ class ToolManager: invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, tool_invoke_from: ToolInvokeFrom = ToolInvokeFrom.AGENT, credential_id: str | None = None, - ) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool, MCPTool]: + ) -> BuiltinTool | PluginTool | ApiTool | WorkflowTool | MCPTool: """ get the tool runtime @@ -205,16 +205,160 @@ class ToolManager: :return: the tool """ - if provider_type == ToolProviderType.BUILT_IN: - # check if the builtin tool need credentials - provider_controller = cls.get_builtin_provider(provider_id, tenant_id) + match provider_type: + case ToolProviderType.BUILT_IN: + provider_controller = cls.get_builtin_provider(provider_id, tenant_id) - builtin_tool = provider_controller.get_tool(tool_name) - if not builtin_tool: - raise ToolProviderNotFoundError(f"builtin tool {tool_name} not found") + builtin_tool = provider_controller.get_tool(tool_name) + if not builtin_tool: + raise ToolProviderNotFoundError(f"builtin tool {tool_name} not found") + + if not provider_controller.need_credentials: + return builtin_tool.fork_tool_runtime( + runtime=ToolRuntime( + tenant_id=tenant_id, + user_id=user_id, + credentials={}, + invoke_from=invoke_from, + tool_invoke_from=tool_invoke_from, + ) + ) + builtin_provider = None + if isinstance(provider_controller, PluginToolProviderController): + provider_id_entity = ToolProviderID(provider_id) + if is_valid_uuid(credential_id): + try: + builtin_provider_stmt = select(BuiltinToolProvider).where( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.id == credential_id, + ) + builtin_provider = db.session.scalar(builtin_provider_stmt) + except Exception as e: + builtin_provider = None + logger.info("Error getting builtin provider %s:%s", credential_id, e, exc_info=True) + if builtin_provider is None: + raise ToolProviderNotFoundError(f"provider has been deleted: {credential_id}") + + if builtin_provider is None: + with Session(db.engine) as session: + builtin_provider = session.scalar( + sa.select(BuiltinToolProvider) + .where( + BuiltinToolProvider.tenant_id == tenant_id, + (BuiltinToolProvider.provider == str(provider_id_entity)) + | (BuiltinToolProvider.provider == provider_id_entity.provider_name), + ) + .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) + ) + if builtin_provider is None: + raise ToolProviderNotFoundError(f"no default provider for {provider_id}") + else: + builtin_provider = db.session.scalar( + select(BuiltinToolProvider) + .where( + BuiltinToolProvider.tenant_id == tenant_id, (BuiltinToolProvider.provider == provider_id) + ) + .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) + .limit(1) + ) + + if builtin_provider is None: + raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found") + + from core.helper.credential_utils import check_credential_policy_compliance + + check_credential_policy_compliance( + credential_id=builtin_provider.id, + provider=provider_id, + credential_type=PluginCredentialType.TOOL, + check_existence=False, + ) + + encrypter, cache = create_provider_encrypter( + tenant_id=tenant_id, + config=[ + x.to_basic_provider_config() + for x in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type) + ], + cache=ToolProviderCredentialsCache( + tenant_id=tenant_id, provider=provider_id, credential_id=builtin_provider.id + ), + ) + + decrypted_credentials: Mapping[str, Any] = encrypter.decrypt(builtin_provider.credentials) + + if builtin_provider.expires_at != -1 and (builtin_provider.expires_at - 60) < int(time.time()): + # TODO: circular import + from core.plugin.impl.oauth import OAuthHandler + from services.tools.builtin_tools_manage_service import BuiltinToolManageService + + tool_provider = ToolProviderID(provider_id) + provider_name = tool_provider.provider_name + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback" + system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id) + + oauth_handler = OAuthHandler() + refreshed_credentials = oauth_handler.refresh_credentials( + tenant_id=tenant_id, + user_id=builtin_provider.user_id, + plugin_id=tool_provider.plugin_id, + provider=provider_name, + redirect_uri=redirect_uri, + system_credentials=system_credentials or {}, + credentials=decrypted_credentials, + ) + # update the credentials + builtin_provider.encrypted_credentials = json.dumps( + encrypter.encrypt(refreshed_credentials.credentials) + ) + builtin_provider.expires_at = refreshed_credentials.expires_at + db.session.commit() + decrypted_credentials = refreshed_credentials.credentials + cache.delete() - if not provider_controller.need_credentials: return builtin_tool.fork_tool_runtime( + runtime=ToolRuntime( + tenant_id=tenant_id, + user_id=user_id, + credentials=dict(decrypted_credentials), + credential_type=builtin_provider.credential_type, + runtime_parameters={}, + invoke_from=invoke_from, + tool_invoke_from=tool_invoke_from, + ) + ) + + case ToolProviderType.API: + api_provider, credentials = cls.get_api_provider_controller(tenant_id, provider_id) + encrypter, _ = create_tool_provider_encrypter( + tenant_id=tenant_id, + controller=api_provider, + ) + return api_provider.get_tool(tool_name).fork_tool_runtime( + runtime=ToolRuntime( + tenant_id=tenant_id, + user_id=user_id, + credentials=dict(encrypter.decrypt(credentials)), + invoke_from=invoke_from, + tool_invoke_from=tool_invoke_from, + ) + ) + case ToolProviderType.WORKFLOW: + workflow_provider_stmt = select(WorkflowToolProvider).where( + WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == provider_id + ) + with Session(db.engine, expire_on_commit=False) as session, session.begin(): + workflow_provider = session.scalar(workflow_provider_stmt) + + if workflow_provider is None: + raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") + + controller = ToolTransformService.workflow_provider_to_controller(db_provider=workflow_provider) + controller_tools: list[WorkflowTool] = controller.get_tools(tenant_id=workflow_provider.tenant_id) + if controller_tools is None or len(controller_tools) == 0: + raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") + + return controller.get_tools(tenant_id=workflow_provider.tenant_id)[0].fork_tool_runtime( runtime=ToolRuntime( tenant_id=tenant_id, user_id=user_id, @@ -223,177 +367,28 @@ class ToolManager: tool_invoke_from=tool_invoke_from, ) ) - builtin_provider = None - if isinstance(provider_controller, PluginToolProviderController): - provider_id_entity = ToolProviderID(provider_id) - # get specific credentials - if is_valid_uuid(credential_id): - try: - builtin_provider_stmt = select(BuiltinToolProvider).where( - BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.id == credential_id, - ) - builtin_provider = db.session.scalar(builtin_provider_stmt) - except Exception as e: - builtin_provider = None - logger.info("Error getting builtin provider %s:%s", credential_id, e, exc_info=True) - # if the provider has been deleted, raise an error - if builtin_provider is None: - raise ToolProviderNotFoundError(f"provider has been deleted: {credential_id}") - - # fallback to the default provider - if builtin_provider is None: - # use the default provider - with Session(db.engine) as session: - builtin_provider = session.scalar( - sa.select(BuiltinToolProvider) - .where( - BuiltinToolProvider.tenant_id == tenant_id, - (BuiltinToolProvider.provider == str(provider_id_entity)) - | (BuiltinToolProvider.provider == provider_id_entity.provider_name), - ) - .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) - ) - if builtin_provider is None: - raise ToolProviderNotFoundError(f"no default provider for {provider_id}") - else: - builtin_provider = db.session.scalar( - select(BuiltinToolProvider) - .where(BuiltinToolProvider.tenant_id == tenant_id, (BuiltinToolProvider.provider == provider_id)) - .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) - .limit(1) - ) - - if builtin_provider is None: - raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found") - - # check if the credential is allowed to be used - from core.helper.credential_utils import check_credential_policy_compliance - - check_credential_policy_compliance( - credential_id=builtin_provider.id, - provider=provider_id, - credential_type=PluginCredentialType.TOOL, - check_existence=False, - ) - - encrypter, cache = create_provider_encrypter( - tenant_id=tenant_id, - config=[ - x.to_basic_provider_config() - for x in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type) - ], - cache=ToolProviderCredentialsCache( - tenant_id=tenant_id, provider=provider_id, credential_id=builtin_provider.id - ), - ) - - # decrypt the credentials - decrypted_credentials: Mapping[str, Any] = encrypter.decrypt(builtin_provider.credentials) - - # check if the credentials is expired - if builtin_provider.expires_at != -1 and (builtin_provider.expires_at - 60) < int(time.time()): - # TODO: circular import - from core.plugin.impl.oauth import OAuthHandler - from services.tools.builtin_tools_manage_service import BuiltinToolManageService - - # refresh the credentials - tool_provider = ToolProviderID(provider_id) - provider_name = tool_provider.provider_name - redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback" - system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id) - - oauth_handler = OAuthHandler() - # refresh the credentials - refreshed_credentials = oauth_handler.refresh_credentials( - tenant_id=tenant_id, - user_id=builtin_provider.user_id, - plugin_id=tool_provider.plugin_id, - provider=provider_name, - redirect_uri=redirect_uri, - system_credentials=system_credentials or {}, - credentials=decrypted_credentials, - ) - # update the credentials - builtin_provider.encrypted_credentials = json.dumps( - encrypter.encrypt(refreshed_credentials.credentials) - ) - builtin_provider.expires_at = refreshed_credentials.expires_at - db.session.commit() - decrypted_credentials = refreshed_credentials.credentials - cache.delete() - - return builtin_tool.fork_tool_runtime( - runtime=ToolRuntime( - tenant_id=tenant_id, - user_id=user_id, - credentials=dict(decrypted_credentials), - credential_type=builtin_provider.credential_type, - runtime_parameters={}, - invoke_from=invoke_from, - tool_invoke_from=tool_invoke_from, - ) - ) - - elif provider_type == ToolProviderType.API: - api_provider, credentials = cls.get_api_provider_controller(tenant_id, provider_id) - encrypter, _ = create_tool_provider_encrypter( - tenant_id=tenant_id, - controller=api_provider, - ) - return api_provider.get_tool(tool_name).fork_tool_runtime( - runtime=ToolRuntime( - tenant_id=tenant_id, - user_id=user_id, - credentials=dict(encrypter.decrypt(credentials)), - invoke_from=invoke_from, - tool_invoke_from=tool_invoke_from, - ) - ) - elif provider_type == ToolProviderType.WORKFLOW: - workflow_provider_stmt = select(WorkflowToolProvider).where( - WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == provider_id - ) - with Session(db.engine, expire_on_commit=False) as session, session.begin(): - workflow_provider = session.scalar(workflow_provider_stmt) - - if workflow_provider is None: - raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") - - controller = ToolTransformService.workflow_provider_to_controller(db_provider=workflow_provider) - controller_tools: list[WorkflowTool] = controller.get_tools(tenant_id=workflow_provider.tenant_id) - if controller_tools is None or len(controller_tools) == 0: - raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") - - return controller.get_tools(tenant_id=workflow_provider.tenant_id)[0].fork_tool_runtime( - runtime=ToolRuntime( - tenant_id=tenant_id, - user_id=user_id, - credentials={}, - invoke_from=invoke_from, - tool_invoke_from=tool_invoke_from, - ) - ) - elif provider_type == ToolProviderType.APP: - raise NotImplementedError("app provider not implemented") - elif provider_type == ToolProviderType.PLUGIN: - plugin_tool = cls.get_plugin_provider(provider_id, tenant_id).get_tool(tool_name) - runtime = getattr(plugin_tool, "runtime", None) - if runtime is not None: - runtime.user_id = user_id - runtime.invoke_from = invoke_from - runtime.tool_invoke_from = tool_invoke_from - return plugin_tool - elif provider_type == ToolProviderType.MCP: - mcp_tool = cls.get_mcp_provider_controller(tenant_id, provider_id).get_tool(tool_name) - runtime = getattr(mcp_tool, "runtime", None) - if runtime is not None: - runtime.user_id = user_id - runtime.invoke_from = invoke_from - runtime.tool_invoke_from = tool_invoke_from - return mcp_tool - else: - raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found") + case ToolProviderType.APP: + raise NotImplementedError("app provider not implemented") + case ToolProviderType.PLUGIN: + plugin_tool = cls.get_plugin_provider(provider_id, tenant_id).get_tool(tool_name) + runtime = getattr(plugin_tool, "runtime", None) + if runtime is not None: + runtime.user_id = user_id + runtime.invoke_from = invoke_from + runtime.tool_invoke_from = tool_invoke_from + return plugin_tool + case ToolProviderType.MCP: + mcp_tool = cls.get_mcp_provider_controller(tenant_id, provider_id).get_tool(tool_name) + runtime = getattr(mcp_tool, "runtime", None) + if runtime is not None: + runtime.user_id = user_id + runtime.invoke_from = invoke_from + runtime.tool_invoke_from = tool_invoke_from + return mcp_tool + case ToolProviderType.DATASET_RETRIEVAL: + raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found") + case _: + raise ToolProviderNotFoundError(f"provider type {provider_type} not found") @classmethod def get_agent_tool_runtime( @@ -403,7 +398,7 @@ class ToolManager: agent_tool: AgentToolEntity, user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, - variable_pool: Optional["VariablePool"] = None, + variable_pool: "VariablePool | None" = None, ) -> Tool: """ get the agent tool runtime @@ -447,7 +442,7 @@ class ToolManager: workflow_tool: WorkflowToolRuntimeSpec, user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, - variable_pool: Optional["VariablePool"] = None, + variable_pool: "VariablePool | None" = None, ) -> Tool: """ get the workflow tool runtime @@ -639,7 +634,7 @@ class ToolManager: cls._builtin_providers_loaded = False @classmethod - def get_tool_label(cls, tool_name: str) -> Union[I18nObject, None]: + def get_tool_label(cls, tool_name: str) -> I18nObject | None: """ get the tool label @@ -687,7 +682,7 @@ class ToolManager: with Session(db.engine, autoflush=False) as session: ids = [row.id for row in session.execute(sa.text(sql), {"tenant_id": tenant_id}).all()] - return session.query(BuiltinToolProvider).where(BuiltinToolProvider.id.in_(ids)).all() + return list(session.scalars(select(BuiltinToolProvider).where(BuiltinToolProvider.id.in_(ids)))) @classmethod def list_providers_from_api( @@ -998,7 +993,7 @@ class ToolManager: return {"background": "#252525", "content": "\ud83d\ude01"} @classmethod - def generate_mcp_tool_icon_url(cls, tenant_id: str, provider_id: str) -> EmojiIconDict | dict[str, str] | str: + def generate_mcp_tool_icon_url(cls, tenant_id: str, provider_id: str) -> EmojiIconDict | str: try: with Session(db.engine) as session: mcp_service = MCPToolManageService(session=session) @@ -1006,7 +1001,7 @@ class ToolManager: mcp_provider = mcp_service.get_provider_entity( provider_id=provider_id, tenant_id=tenant_id, by_server_id=True ) - return mcp_provider.provider_icon + return cast(EmojiIconDict | str, mcp_provider.provider_icon) except ValueError: raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found") except Exception: @@ -1018,7 +1013,7 @@ class ToolManager: tenant_id: str, provider_type: ToolProviderType, provider_id: str, - ) -> str | EmojiIconDict | dict[str, str]: + ) -> str | EmojiIconDict: """ get the tool icon @@ -1027,37 +1022,37 @@ class ToolManager: :param provider_id: the id of the provider :return: """ - provider_type = provider_type - provider_id = provider_id - if provider_type == ToolProviderType.BUILT_IN: - provider = ToolManager.get_builtin_provider(provider_id, tenant_id) - if isinstance(provider, PluginToolProviderController): + match provider_type: + case ToolProviderType.BUILT_IN: + provider = ToolManager.get_builtin_provider(provider_id, tenant_id) + if isinstance(provider, PluginToolProviderController): + try: + return cls.generate_plugin_tool_icon_url(tenant_id, provider.entity.identity.icon) + except Exception: + return {"background": "#252525", "content": "\ud83d\ude01"} + return cls.generate_builtin_tool_icon_url(provider_id) + case ToolProviderType.API: + return cls.generate_api_tool_icon_url(tenant_id, provider_id) + case ToolProviderType.WORKFLOW: + return cls.generate_workflow_tool_icon_url(tenant_id, provider_id) + case ToolProviderType.PLUGIN: + provider = ToolManager.get_plugin_provider(provider_id, tenant_id) try: return cls.generate_plugin_tool_icon_url(tenant_id, provider.entity.identity.icon) except Exception: return {"background": "#252525", "content": "\ud83d\ude01"} - return cls.generate_builtin_tool_icon_url(provider_id) - elif provider_type == ToolProviderType.API: - return cls.generate_api_tool_icon_url(tenant_id, provider_id) - elif provider_type == ToolProviderType.WORKFLOW: - return cls.generate_workflow_tool_icon_url(tenant_id, provider_id) - elif provider_type == ToolProviderType.PLUGIN: - provider = ToolManager.get_plugin_provider(provider_id, tenant_id) - try: - return cls.generate_plugin_tool_icon_url(tenant_id, provider.entity.identity.icon) - except Exception: - return {"background": "#252525", "content": "\ud83d\ude01"} - raise ValueError(f"plugin provider {provider_id} not found") - elif provider_type == ToolProviderType.MCP: - return cls.generate_mcp_tool_icon_url(tenant_id, provider_id) - else: - raise ValueError(f"provider type {provider_type} not found") + case ToolProviderType.MCP: + return cls.generate_mcp_tool_icon_url(tenant_id, provider_id) + case ToolProviderType.APP | ToolProviderType.DATASET_RETRIEVAL: + raise ValueError(f"provider type {provider_type} not found") + case _: + raise ValueError(f"provider type {provider_type} not found") @classmethod def _convert_tool_parameters_type( cls, parameters: list[ToolParameter], - variable_pool: Optional["VariablePool"], + variable_pool: "VariablePool | None", tool_configurations: Mapping[str, Any], typ: Literal["agent", "workflow", "tool"] = "workflow", ) -> dict[str, Any]: diff --git a/api/core/tools/utils/dataset_retriever/dataset_multi_retriever_tool.py b/api/core/tools/utils/dataset_retriever/dataset_multi_retriever_tool.py index c72bdf02ed..03e3c5918d 100644 --- a/api/core/tools/utils/dataset_retriever/dataset_multi_retriever_tool.py +++ b/api/core/tools/utils/dataset_retriever/dataset_multi_retriever_tool.py @@ -7,14 +7,13 @@ from sqlalchemy import select from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler from core.model_manager import ModelManager -from core.rag.datasource.retrieval_service import RetrievalService +from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService from core.rag.entities import RetrievalSourceMetadata from core.rag.index_processor.constant.index_type import IndexTechniqueType from core.rag.models.document import Document as RagDocument from core.rag.rerank.rerank_model import RerankModelRunner from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.tools.utils.dataset_retriever.dataset_retriever_base_tool import DatasetRetrieverBaseTool -from core.tools.utils.dataset_retriever.dataset_retriever_tool import DefaultRetrievalModelDict from extensions.ext_database import db from models.dataset import Dataset, Document, DocumentSegment diff --git a/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py b/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py index a346eb53c4..0d1dc7273b 100644 --- a/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py +++ b/api/core/tools/utils/dataset_retriever/dataset_retriever_tool.py @@ -1,11 +1,10 @@ -from typing import NotRequired, TypedDict, cast +from typing import Any, cast from pydantic import BaseModel, Field from sqlalchemy import select from core.app.app_config.entities import DatasetRetrieveConfigEntity, ModelConfig -from core.rag.data_post_processor.data_post_processor import RerankingModelDict, WeightsDict -from core.rag.datasource.retrieval_service import RetrievalService +from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService from core.rag.entities import DocumentContext, RetrievalSourceMetadata from core.rag.index_processor.constant.index_type import IndexTechniqueType from core.rag.models.document import Document as RetrievalDocument @@ -17,18 +16,6 @@ from models.dataset import Dataset from models.dataset import Document as DatasetDocument from services.external_knowledge_service import ExternalDatasetService - -class DefaultRetrievalModelDict(TypedDict): - search_method: RetrievalMethod - reranking_enable: bool - reranking_model: RerankingModelDict - reranking_mode: NotRequired[str] - weights: NotRequired[WeightsDict | None] - score_threshold: NotRequired[float] - top_k: int - score_threshold_enabled: bool - - default_retrieval_model: DefaultRetrievalModelDict = { "search_method": RetrievalMethod.SEMANTIC_SEARCH, "reranking_enable": False, @@ -52,7 +39,7 @@ class DatasetRetrieverTool(DatasetRetrieverBaseTool): dataset_id: str user_id: str | None = None retrieve_config: DatasetRetrieveConfigEntity - inputs: dict + inputs: dict[str, Any] @classmethod def from_dataset(cls, dataset: Dataset, **kwargs): diff --git a/api/core/tools/utils/dataset_retriever_tool.py b/api/core/tools/utils/dataset_retriever_tool.py index fca6e6f1c7..0bdc3df869 100644 --- a/api/core/tools/utils/dataset_retriever_tool.py +++ b/api/core/tools/utils/dataset_retriever_tool.py @@ -33,7 +33,7 @@ class DatasetRetrieverTool(Tool): invoke_from: InvokeFrom, hit_callback: DatasetIndexToolCallbackHandler, user_id: str, - inputs: dict, + inputs: dict[str, Any], ) -> list["DatasetRetrieverTool"]: """ get dataset tool diff --git a/api/core/tools/utils/message_transformer.py b/api/core/tools/utils/message_transformer.py index bb5b3ba76e..81c85bc90d 100644 --- a/api/core/tools/utils/message_transformer.py +++ b/api/core/tools/utils/message_transformer.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import date, datetime from decimal import Decimal from mimetypes import guess_extension +from typing import Any from uuid import UUID import numpy as np @@ -50,7 +51,7 @@ def safe_json_value(v): return v -def safe_json_dict(d: dict): +def safe_json_dict(d: dict[str, Any]): if not isinstance(d, dict): raise TypeError("safe_json_dict() expects a dictionary (dict) as input") return {k: safe_json_value(v) for k, v in d.items()} @@ -118,7 +119,8 @@ class ToolFileMessageTransformer: if not isinstance(message.message, ToolInvokeMessage.BlobMessage): raise ValueError("unexpected message type") - assert isinstance(message.message.blob, bytes) + if not isinstance(message.message.blob, bytes): + raise TypeError(f"Expected blob to be bytes, got {type(message.message.blob).__name__}") tool_file_manager = ToolFileManager() tool_file = tool_file_manager.create_file_by_raw( user_id=user_id, @@ -195,11 +197,11 @@ class ToolFileMessageTransformer: @staticmethod def _with_tool_file_meta( - meta: dict | None, + meta: dict[str, Any] | None, *, tool_file_id: str | None = None, url: str | None = None, - ) -> dict: + ) -> dict[str, Any]: normalized_meta = meta.copy() if meta is not None else {} resolved_tool_file_id = tool_file_id or ToolFileMessageTransformer._extract_tool_file_id(url) if resolved_tool_file_id and "tool_file_id" not in normalized_meta: diff --git a/api/core/tools/utils/parser.py b/api/core/tools/utils/parser.py index f7484b93fb..434af55583 100644 --- a/api/core/tools/utils/parser.py +++ b/api/core/tools/utils/parser.py @@ -32,7 +32,7 @@ class OpenAPISpecDict(TypedDict): class ApiBasedToolSchemaParser: @staticmethod def parse_openapi_to_tool_bundle( - openapi: Mapping[str, Any], extra_info: dict | None = None, warning: dict | None = None + openapi: Mapping[str, Any], extra_info: dict[str, Any] | None = None, warning: dict[str, Any] | None = None ) -> list[ApiToolBundle]: warning = warning if warning is not None else {} extra_info = extra_info if extra_info is not None else {} @@ -236,7 +236,7 @@ class ApiBasedToolSchemaParser: return value @staticmethod - def _get_tool_parameter_type(parameter: dict) -> ToolParameter.ToolParameterType | None: + def _get_tool_parameter_type(parameter: dict[str, Any]) -> ToolParameter.ToolParameterType | None: parameter = parameter or {} typ: str | None = None if parameter.get("format") == "binary": @@ -265,7 +265,7 @@ class ApiBasedToolSchemaParser: @staticmethod def parse_openapi_yaml_to_tool_bundle( - yaml: str, extra_info: dict | None = None, warning: dict | None = None + yaml: str, extra_info: dict[str, Any] | None = None, warning: dict[str, Any] | None = None ) -> list[ApiToolBundle]: """ parse openapi yaml to tool bundle @@ -278,14 +278,14 @@ class ApiBasedToolSchemaParser: warning = warning if warning is not None else {} extra_info = extra_info if extra_info is not None else {} - openapi: dict = safe_load(yaml) + openapi: dict[str, Any] = safe_load(yaml) if openapi is None: raise ToolApiSchemaError("Invalid openapi yaml.") return ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(openapi, extra_info=extra_info, warning=warning) @staticmethod def parse_swagger_to_openapi( - swagger: dict, extra_info: dict | None = None, warning: dict | None = None + swagger: dict[str, Any], extra_info: dict[str, Any] | None = None, warning: dict[str, Any] | None = None ) -> OpenAPISpecDict: warning = warning or {} """ @@ -351,7 +351,7 @@ class ApiBasedToolSchemaParser: @staticmethod def parse_openai_plugin_json_to_tool_bundle( - json: str, extra_info: dict | None = None, warning: dict | None = None + json: str, extra_info: dict[str, Any] | None = None, warning: dict[str, Any] | None = None ) -> list[ApiToolBundle]: """ parse openapi plugin yaml to tool bundle @@ -392,7 +392,7 @@ class ApiBasedToolSchemaParser: @staticmethod def auto_parse_to_tool_bundle( - content: str, extra_info: dict | None = None, warning: dict | None = None + content: str, extra_info: dict[str, Any] | None = None, warning: dict[str, Any] | None = None ) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]: """ auto parse to tool bundle diff --git a/api/core/tools/utils/text_processing_utils.py b/api/core/tools/utils/text_processing_utils.py index 4bfaa5e49b..1dd0605f28 100644 --- a/api/core/tools/utils/text_processing_utils.py +++ b/api/core/tools/utils/text_processing_utils.py @@ -19,5 +19,18 @@ def remove_leading_symbols(text: str) -> str: # Match Unicode ranges for punctuation and symbols # FIXME this pattern is confused quick fix for #11868 maybe refactor it later - pattern = r'^[\[\]\u2000-\u2025\u2027-\u206F\u2E00-\u2E7F\u3000-\u300F\u3011-\u303F"#$%&\'()*+,./:;<=>?@^_`~]+' + pattern = re.compile( + r""" + ^ + (?: + [\u2000-\u2025] # General Punctuation: spaces, quotes, dashes + | [\u2027-\u206F] # General Punctuation: ellipsis, underscores, etc. + | [\u2E00-\u2E7F] # Supplemental Punctuation: medieval, ancient marks + | [\u3000-\u300F] # CJK Punctuation: 、。〃「」『》』 (excludes 【】) + | [\u3012-\u303F] # CJK Punctuation: 〖〗〔〕〘〙〚〛〜 etc. + | ["#$%&'()*+,./:;<=>?@^_`~] # ASCII punctuation (excludes []【】) + )+ + """, + re.VERBOSE, + ) return re.sub(pattern, "", text) diff --git a/api/core/tools/utils/workflow_configuration_sync.py b/api/core/tools/utils/workflow_configuration_sync.py index c4b7d57449..2159eb8638 100644 --- a/api/core/tools/utils/workflow_configuration_sync.py +++ b/api/core/tools/utils/workflow_configuration_sync.py @@ -17,10 +17,8 @@ class WorkflowToolConfigurationUtils: """ nodes = graph.get("nodes", []) start_node = next(filter(lambda x: x.get("data", {}).get("type") == "start", nodes), None) - if not start_node: return [] - return [VariableEntity.model_validate(variable) for variable in start_node.get("data", {}).get("variables", [])] @classmethod diff --git a/api/core/tools/workflow_as_tool/provider.py b/api/core/tools/workflow_as_tool/provider.py index f48b24be30..a01004448a 100644 --- a/api/core/tools/workflow_as_tool/provider.py +++ b/api/core/tools/workflow_as_tool/provider.py @@ -4,6 +4,7 @@ from collections.abc import Mapping from graphon.variables.input_entities import VariableEntity, VariableEntityType from pydantic import Field +from sqlalchemy import select from sqlalchemy.orm import Session from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager @@ -96,10 +97,10 @@ class WorkflowToolProviderController(ToolProviderController): :param app: the app :return: the tool """ - workflow: Workflow | None = ( - session.query(Workflow) + workflow: Workflow | None = session.scalar( + select(Workflow) .where(Workflow.app_id == db_provider.app_id, Workflow.version == db_provider.version) - .first() + .limit(1) ) if not workflow: @@ -217,13 +218,13 @@ class WorkflowToolProviderController(ToolProviderController): return self.tools with Session(db.engine, expire_on_commit=False) as session, session.begin(): - db_provider: WorkflowToolProvider | None = ( - session.query(WorkflowToolProvider) + db_provider: WorkflowToolProvider | None = session.scalar( + select(WorkflowToolProvider) .where( WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == self.provider_id, ) - .first() + .limit(1) ) if not db_provider: diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index a3fb4eda92..7c4f8ee03a 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -277,7 +277,7 @@ class WorkflowTool(Tool): session.expunge(app) return app - def _transform_args(self, tool_parameters: dict) -> tuple[dict, list[dict]]: + def _transform_args(self, tool_parameters: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str | None]]]: """ transform the tool parameters @@ -305,14 +305,15 @@ class WorkflowTool(Tool): "transfer_method": file.transfer_method.value, "type": file.type.value, } - if file.transfer_method == FileTransferMethod.TOOL_FILE: - file_dict["tool_file_id"] = resolve_file_record_id(file.reference) - elif file.transfer_method == FileTransferMethod.LOCAL_FILE: - file_dict["upload_file_id"] = resolve_file_record_id(file.reference) - elif file.transfer_method == FileTransferMethod.DATASOURCE_FILE: - file_dict["datasource_file_id"] = resolve_file_record_id(file.reference) - elif file.transfer_method == FileTransferMethod.REMOTE_URL: - file_dict["url"] = file.generate_url() + match file.transfer_method: + case FileTransferMethod.TOOL_FILE: + file_dict["tool_file_id"] = resolve_file_record_id(file.reference) + case FileTransferMethod.LOCAL_FILE: + file_dict["upload_file_id"] = resolve_file_record_id(file.reference) + case FileTransferMethod.DATASOURCE_FILE: + file_dict["datasource_file_id"] = resolve_file_record_id(file.reference) + case FileTransferMethod.REMOTE_URL: + file_dict["url"] = file.generate_url() files.append(file_dict) except Exception: @@ -322,7 +323,7 @@ class WorkflowTool(Tool): return parameters_result, files - def _extract_files(self, outputs: dict) -> tuple[dict, list[File]]: + def _extract_files(self, outputs: dict[str, Any]) -> tuple[dict[str, Any], list[File]]: """ extract files from the result @@ -354,11 +355,14 @@ class WorkflowTool(Tool): return result, files - def _update_file_mapping(self, file_dict: dict): + def _update_file_mapping(self, file_dict: dict[str, Any]) -> dict[str, Any]: file_id = resolve_file_record_id(file_dict.get("reference") or file_dict.get("related_id")) transfer_method = FileTransferMethod.value_of(file_dict.get("transfer_method")) - if transfer_method == FileTransferMethod.TOOL_FILE: - file_dict["tool_file_id"] = file_id - elif transfer_method == FileTransferMethod.LOCAL_FILE: - file_dict["upload_file_id"] = file_id + match transfer_method: + case FileTransferMethod.TOOL_FILE: + file_dict["tool_file_id"] = file_id + case FileTransferMethod.LOCAL_FILE: + file_dict["upload_file_id"] = file_id + case FileTransferMethod.REMOTE_URL | FileTransferMethod.DATASOURCE_FILE: + pass return file_dict diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index f6c3aee4c1..b04ac7da3d 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -1,6 +1,7 @@ import importlib import pkgutil from collections.abc import Callable, Iterator, Mapping, MutableMapping +from dataclasses import dataclass from functools import lru_cache from typing import TYPE_CHECKING, Any, cast, final, override @@ -67,6 +68,31 @@ _START_NODE_TYPES: frozenset[NodeType] = frozenset( ) +@dataclass(frozen=True, slots=True) +class DifyGraphInitContext: + """Explicit graph-init values owned by the workflow layer. + + Dify is gradually removing direct `GraphInitParams` construction from its + production call sites. Keep the translation here until `graphon` exposes an + equivalent explicit API. + """ + + workflow_id: str + graph_config: Mapping[str, Any] + run_context: Mapping[str, Any] + call_depth: int + + def to_graph_init_params(self) -> "GraphInitParams": + from graphon.entities import GraphInitParams + + return GraphInitParams( + workflow_id=self.workflow_id, + graph_config=self.graph_config, + run_context=self.run_context, + call_depth=self.call_depth, + ) + + def _import_node_package(package_name: str, *, excluded_modules: frozenset[str] = frozenset()) -> None: package = importlib.import_module(package_name) for _, module_name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."): @@ -237,6 +263,19 @@ class DifyNodeFactory(NodeFactory): Default implementation of NodeFactory that resolves node classes from the live registry. """ + @classmethod + def from_graph_init_context( + cls, + *, + graph_init_context: DifyGraphInitContext, + graph_runtime_state: "GraphRuntimeState", + ) -> "DifyNodeFactory": + """Bridge Dify's explicit init context into the current `graphon` API.""" + return cls( + graph_init_params=graph_init_context.to_graph_init_params(), + graph_runtime_state=graph_runtime_state, + ) + def __init__( self, graph_init_params: "GraphInitParams", diff --git a/api/core/workflow/nodes/knowledge_index/entities.py b/api/core/workflow/nodes/knowledge_index/entities.py index f4789a42a5..04a10f9257 100644 --- a/api/core/workflow/nodes/knowledge_index/entities.py +++ b/api/core/workflow/nodes/knowledge_index/entities.py @@ -1,50 +1,15 @@ -from typing import Literal, Union +from typing import Union from graphon.entities.base_node_data import BaseNodeData from graphon.enums import NodeType from pydantic import BaseModel -from core.rag.entities import KeywordSetting, VectorSetting +from core.rag.entities import RerankingModelConfig, WeightedScoreConfig from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE -class RerankingModelConfig(BaseModel): - """ - Reranking Model Config. - """ - - reranking_provider_name: str - reranking_model_name: str - - -class WeightedScoreConfig(BaseModel): - """ - Weighted score Config. - """ - - vector_setting: VectorSetting - keyword_setting: KeywordSetting - - -class EmbeddingSetting(BaseModel): - """ - Embedding Setting. - """ - - embedding_provider_name: str - embedding_model_name: str - - -class EconomySetting(BaseModel): - """ - Economy Setting. - """ - - keyword_number: int - - class RetrievalSetting(BaseModel): """ Retrieval Setting. @@ -60,16 +25,6 @@ class RetrievalSetting(BaseModel): weights: WeightedScoreConfig | None = None -class IndexMethod(BaseModel): - """ - Knowledge Index Setting. - """ - - indexing_technique: Literal["high_quality", "economy"] - embedding_setting: EmbeddingSetting - economy_setting: EconomySetting - - class FileInfo(BaseModel): """ File Info. diff --git a/api/core/workflow/nodes/knowledge_index/protocols.py b/api/core/workflow/nodes/knowledge_index/protocols.py index 6668f0c98e..d04e79c2a8 100644 --- a/api/core/workflow/nodes/knowledge_index/protocols.py +++ b/api/core/workflow/nodes/knowledge_index/protocols.py @@ -43,15 +43,20 @@ class IndexProcessorProtocol(Protocol): original_document_id: str, chunks: Mapping[str, Any], batch: Any, - summary_index_setting: dict | None = None, + summary_index_setting: dict[str, Any] | None = None, ) -> IndexingResultDict: ... def get_preview_output( - self, chunks: Any, dataset_id: str, document_id: str, chunk_structure: str, summary_index_setting: dict | None + self, + chunks: Any, + dataset_id: str, + document_id: str, + chunk_structure: str, + summary_index_setting: dict[str, Any] | None, ) -> Preview: ... class SummaryIndexServiceProtocol(Protocol): def generate_and_vectorize_summary( - self, dataset_id: str, document_id: str, is_preview: bool, summary_index_setting: dict | None = None + self, dataset_id: str, document_id: str, is_preview: bool, summary_index_setting: dict[str, Any] | None = None ) -> None: ... diff --git a/api/core/workflow/nodes/knowledge_retrieval/entities.py b/api/core/workflow/nodes/knowledge_retrieval/entities.py index 00a36e8d5f..460ec693ce 100644 --- a/api/core/workflow/nodes/knowledge_retrieval/entities.py +++ b/api/core/workflow/nodes/knowledge_retrieval/entities.py @@ -5,29 +5,11 @@ from graphon.enums import BuiltinNodeTypes, NodeType from graphon.nodes.llm.entities import ModelConfig, VisionConfig from pydantic import BaseModel, Field -from core.rag.entities import Condition, KeywordSetting, MetadataFilteringCondition, VectorSetting +from core.rag.entities import Condition, MetadataFilteringCondition, RerankingModelConfig, WeightedScoreConfig __all__ = ["Condition"] -class RerankingModelConfig(BaseModel): - """ - Reranking Model Config. - """ - - provider: str - model: str - - -class WeightedScoreConfig(BaseModel): - """ - Weighted score Config. - """ - - vector_setting: VectorSetting - keyword_setting: KeywordSetting - - class MultipleRetrievalConfig(BaseModel): """ Multiple Retrieval Config. diff --git a/api/core/workflow/nodes/trigger_schedule/entities.py b/api/core/workflow/nodes/trigger_schedule/entities.py index f14ca893c9..04f1f7e6bb 100644 --- a/api/core/workflow/nodes/trigger_schedule/entities.py +++ b/api/core/workflow/nodes/trigger_schedule/entities.py @@ -1,4 +1,4 @@ -from typing import Literal, Union +from typing import Any, Literal, Union from graphon.entities.base_node_data import BaseNodeData from graphon.enums import NodeType @@ -16,7 +16,7 @@ class TriggerScheduleNodeData(BaseNodeData): mode: str = Field(default="visual", description="Schedule mode: visual or cron") frequency: str | None = Field(default=None, description="Frequency for visual mode: hourly, daily, weekly, monthly") cron_expression: str | None = Field(default=None, description="Cron expression for cron mode") - visual_config: dict | None = Field(default=None, description="Visual configuration details") + visual_config: dict[str, Any] | None = Field(default=None, description="Visual configuration details") timezone: str = Field(default="UTC", description="Timezone for schedule execution") diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py index ebaac93934..d942a718cc 100644 --- a/api/core/workflow/nodes/trigger_webhook/node.py +++ b/api/core/workflow/nodes/trigger_webhook/node.py @@ -29,7 +29,7 @@ class TriggerWebhookNode(Node[WebhookData]): def post_init(self) -> None: from core.workflow.node_runtime import DifyFileReferenceFactory - self._file_reference_factory = DifyFileReferenceFactory(self.graph_init_params.run_context) + self._file_reference_factory = DifyFileReferenceFactory(self.run_context) @classmethod def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]: @@ -75,7 +75,7 @@ class TriggerWebhookNode(Node[WebhookData]): outputs=outputs, ) - def generate_file_var(self, param_name: str, file: dict): + def generate_file_var(self, param_name: str, file: dict[str, Any]): file_id = resolve_file_record_id(file.get("reference") or file.get("related_id")) transfer_method_value = file.get("transfer_method") if transfer_method_value: @@ -147,7 +147,7 @@ class TriggerWebhookNode(Node[WebhookData]): outputs[param_name] = str(webhook_data.get("body", {}).get("raw", "")) continue elif self.node_data.content_type == ContentType.BINARY: - raw_data: dict = webhook_data.get("body", {}).get("raw", {}) + raw_data: dict[str, Any] = webhook_data.get("body", {}).get("raw", {}) file_var = self.generate_file_var(param_name, raw_data) if file_var: outputs[param_name] = file_var @@ -155,24 +155,25 @@ class TriggerWebhookNode(Node[WebhookData]): outputs[param_name] = raw_data continue - if param_type == SegmentType.FILE: - # Get File object (already processed by webhook controller) - files = webhook_data.get("files", {}) - if files and isinstance(files, dict): - file = files.get(param_name) - if file and isinstance(file, dict): - file_var = self.generate_file_var(param_name, file) - if file_var: - outputs[param_name] = file_var + match param_type: + case SegmentType.FILE: + # Get File object (already processed by webhook controller) + files = webhook_data.get("files", {}) + if files and isinstance(files, dict): + file = files.get(param_name) + if file and isinstance(file, dict): + file_var = self.generate_file_var(param_name, file) + if file_var: + outputs[param_name] = file_var + else: + outputs[param_name] = files else: outputs[param_name] = files else: outputs[param_name] = files - else: - outputs[param_name] = files - else: - # Get regular body parameter - outputs[param_name] = webhook_data.get("body", {}).get(param_name) + case _: + # Get regular body parameter + outputs[param_name] = webhook_data.get("body", {}).get(param_name) # Include raw webhook data for debugging/advanced use outputs["_webhook_raw"] = webhook_data diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index cecc20145a..f0a5fbb400 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -24,7 +24,12 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_di from core.app.file_access import DatabaseFileAccessController from core.app.workflow.layers.llm_quota import LLMQuotaLayer from core.app.workflow.layers.observability import ObservabilityLayer -from core.workflow.node_factory import DifyNodeFactory, is_start_node_type, resolve_workflow_node_class +from core.workflow.node_factory import ( + DifyGraphInitContext, + DifyNodeFactory, + is_start_node_type, + resolve_workflow_node_class, +) from core.workflow.system_variables import ( default_system_variables, get_node_creation_preload_selectors, @@ -251,17 +256,18 @@ class WorkflowEntry: node_version = str(node_config_data.version) node_cls = resolve_workflow_node_class(node_type=node_type, node_version=node_version) - # init graph init params and runtime state - graph_init_params = GraphInitParams( + # init graph context and runtime state + run_context = build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + user_id=user_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow.id, graph_config=workflow.graph_dict, - run_context=build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=workflow.app_id, - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ), + run_context=run_context, call_depth=0, ) graph_runtime_state = GraphRuntimeState( @@ -313,8 +319,8 @@ class WorkflowEntry: ) # init workflow run state - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) node = node_factory.create_node(node_config) @@ -409,17 +415,18 @@ class WorkflowEntry: variable_pool = VariablePool() add_variables_to_pool(variable_pool, default_system_variables()) - # init graph init params and runtime state - graph_init_params = GraphInitParams( + # init graph context and runtime state + run_context = build_dify_run_context( + tenant_id=tenant_id, + app_id="", + user_id=user_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + graph_init_context = DifyGraphInitContext( workflow_id="", graph_config=graph_dict, - run_context=build_dify_run_context( - tenant_id=tenant_id, - app_id="", - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ), + run_context=run_context, call_depth=0, ) graph_runtime_state = GraphRuntimeState( @@ -430,8 +437,8 @@ class WorkflowEntry: # init workflow run state node_config = NodeConfigDictAdapter.validate_python({"id": node_id, "data": node_data}) - node_factory = DifyNodeFactory( - graph_init_params=graph_init_params, + node_factory = DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, graph_runtime_state=graph_runtime_state, ) node = node_factory.create_node(node_config) diff --git a/api/enterprise/telemetry/metric_handler.py b/api/enterprise/telemetry/metric_handler.py index ffd9a7e2b5..9cda0bf90a 100644 --- a/api/enterprise/telemetry/metric_handler.py +++ b/api/enterprise/telemetry/metric_handler.py @@ -68,46 +68,49 @@ class EnterpriseMetricHandler: # Route to appropriate handler based on case case = envelope.case - if case == TelemetryCase.APP_CREATED: - self._on_app_created(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "app_created"}) - elif case == TelemetryCase.APP_UPDATED: - self._on_app_updated(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "app_updated"}) - elif case == TelemetryCase.APP_DELETED: - self._on_app_deleted(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "app_deleted"}) - elif case == TelemetryCase.FEEDBACK_CREATED: - self._on_feedback_created(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "feedback_created"}) - elif case == TelemetryCase.MESSAGE_RUN: - self._on_message_run(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "message_run"}) - elif case == TelemetryCase.TOOL_EXECUTION: - self._on_tool_execution(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "tool_execution"}) - elif case == TelemetryCase.MODERATION_CHECK: - self._on_moderation_check(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "moderation_check"}) - elif case == TelemetryCase.SUGGESTED_QUESTION: - self._on_suggested_question(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "suggested_question"}) - elif case == TelemetryCase.DATASET_RETRIEVAL: - self._on_dataset_retrieval(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "dataset_retrieval"}) - elif case == TelemetryCase.GENERATE_NAME: - self._on_generate_name(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "generate_name"}) - elif case == TelemetryCase.PROMPT_GENERATION: - self._on_prompt_generation(envelope) - self._increment_diagnostic_counter("processed_total", {"case": "prompt_generation"}) - else: - logger.warning( - "Unknown telemetry case: %s (tenant_id=%s, event_id=%s)", - case, - envelope.tenant_id, - envelope.event_id, - ) + match case: + case TelemetryCase.APP_CREATED: + self._on_app_created(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "app_created"}) + case TelemetryCase.APP_UPDATED: + self._on_app_updated(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "app_updated"}) + case TelemetryCase.APP_DELETED: + self._on_app_deleted(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "app_deleted"}) + case TelemetryCase.FEEDBACK_CREATED: + self._on_feedback_created(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "feedback_created"}) + case TelemetryCase.MESSAGE_RUN: + self._on_message_run(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "message_run"}) + case TelemetryCase.TOOL_EXECUTION: + self._on_tool_execution(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "tool_execution"}) + case TelemetryCase.MODERATION_CHECK: + self._on_moderation_check(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "moderation_check"}) + case TelemetryCase.SUGGESTED_QUESTION: + self._on_suggested_question(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "suggested_question"}) + case TelemetryCase.DATASET_RETRIEVAL: + self._on_dataset_retrieval(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "dataset_retrieval"}) + case TelemetryCase.GENERATE_NAME: + self._on_generate_name(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "generate_name"}) + case TelemetryCase.PROMPT_GENERATION: + self._on_prompt_generation(envelope) + self._increment_diagnostic_counter("processed_total", {"case": "prompt_generation"}) + case TelemetryCase.WORKFLOW_RUN | TelemetryCase.NODE_EXECUTION | TelemetryCase.DRAFT_NODE_EXECUTION: + pass + case _: + logger.warning( + "Unknown telemetry case: %s (tenant_id=%s, event_id=%s)", + case, + envelope.tenant_id, + envelope.event_id, + ) def _is_duplicate(self, envelope: TelemetryEnvelope) -> bool: """Check if this event has already been processed. diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 1b3ccd1207..86b0550187 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -5,12 +5,30 @@ from typing import Any import pytz # type: ignore[import-untyped] from celery import Celery, Task from celery.schedules import crontab +from typing_extensions import TypedDict from configs import dify_config from dify_app import DifyApp -def get_celery_ssl_options() -> dict[str, Any] | None: +class _CelerySentinelKwargsDict(TypedDict): + socket_timeout: float | None + password: str | None + + +class CelerySentinelTransportDict(TypedDict): + master_name: str | None + sentinel_kwargs: _CelerySentinelKwargsDict + + +class CelerySSLOptionsDict(TypedDict): + ssl_cert_reqs: int + ssl_ca_certs: str | None + ssl_certfile: str | None + ssl_keyfile: str | None + + +def get_celery_ssl_options() -> CelerySSLOptionsDict | None: """Get SSL configuration for Celery broker/backend connections.""" # Only apply SSL if we're using Redis as broker/backend if not dify_config.BROKER_USE_SSL: @@ -33,26 +51,24 @@ def get_celery_ssl_options() -> dict[str, Any] | None: ssl_cert_reqs = cert_reqs_map.get(dify_config.REDIS_SSL_CERT_REQS, ssl.CERT_NONE) - ssl_options = { - "ssl_cert_reqs": ssl_cert_reqs, - "ssl_ca_certs": dify_config.REDIS_SSL_CA_CERTS, - "ssl_certfile": dify_config.REDIS_SSL_CERTFILE, - "ssl_keyfile": dify_config.REDIS_SSL_KEYFILE, - } - - return ssl_options + return CelerySSLOptionsDict( + ssl_cert_reqs=ssl_cert_reqs, + ssl_ca_certs=dify_config.REDIS_SSL_CA_CERTS, + ssl_certfile=dify_config.REDIS_SSL_CERTFILE, + ssl_keyfile=dify_config.REDIS_SSL_KEYFILE, + ) -def get_celery_broker_transport_options() -> dict[str, Any]: +def get_celery_broker_transport_options() -> CelerySentinelTransportDict | dict[str, Any]: """Get broker transport options (e.g. Redis Sentinel) for Celery connections.""" if dify_config.CELERY_USE_SENTINEL: - return { - "master_name": dify_config.CELERY_SENTINEL_MASTER_NAME, - "sentinel_kwargs": { - "socket_timeout": dify_config.CELERY_SENTINEL_SOCKET_TIMEOUT, - "password": dify_config.CELERY_SENTINEL_PASSWORD, - }, - } + return CelerySentinelTransportDict( + master_name=dify_config.CELERY_SENTINEL_MASTER_NAME, + sentinel_kwargs=_CelerySentinelKwargsDict( + socket_timeout=dify_config.CELERY_SENTINEL_SOCKET_TIMEOUT, + password=dify_config.CELERY_SENTINEL_PASSWORD, + ), + ) return {} diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py index 5f528dbf9e..20f05b8b9e 100644 --- a/api/extensions/ext_redis.py +++ b/api/extensions/ext_redis.py @@ -7,11 +7,14 @@ from typing import TYPE_CHECKING, Any, Union import redis from redis import RedisError +from redis.backoff import ExponentialWithJitterBackoff # type: ignore from redis.cache import CacheConfig from redis.client import PubSub from redis.cluster import ClusterNode, RedisCluster from redis.connection import Connection, SSLConnection +from redis.retry import Retry from redis.sentinel import Sentinel +from typing_extensions import TypedDict from configs import dify_config from dify_app import DifyApp @@ -124,6 +127,41 @@ redis_client: RedisClientWrapper = RedisClientWrapper() _pubsub_redis_client: redis.Redis | RedisCluster | None = None +class RedisSSLParamsDict(TypedDict): + ssl_cert_reqs: int + ssl_ca_certs: str | None + ssl_certfile: str | None + ssl_keyfile: str | None + + +class RedisHealthParamsDict(TypedDict): + retry: Retry + socket_timeout: float | None + socket_connect_timeout: float | None + health_check_interval: int | None + + +class RedisClusterHealthParamsDict(TypedDict): + retry: Retry + socket_timeout: float | None + socket_connect_timeout: float | None + + +class RedisBaseParamsDict(TypedDict): + username: str | None + password: str | None + db: int + encoding: str + encoding_errors: str + decode_responses: bool + protocol: int + cache_config: CacheConfig | None + retry: Retry + socket_timeout: float | None + socket_connect_timeout: float | None + health_check_interval: int | None + + def _get_ssl_configuration() -> tuple[type[Union[Connection, SSLConnection]], dict[str, Any]]: """Get SSL configuration for Redis connection.""" if not dify_config.REDIS_USE_SSL: @@ -158,21 +196,60 @@ def _get_cache_configuration() -> CacheConfig | None: return CacheConfig() -def _get_base_redis_params() -> dict[str, Any]: - """Get base Redis connection parameters.""" - return { - "username": dify_config.REDIS_USERNAME, - "password": dify_config.REDIS_PASSWORD or None, - "db": dify_config.REDIS_DB, - "encoding": "utf-8", - "encoding_errors": "strict", - "decode_responses": False, - "protocol": dify_config.REDIS_SERIALIZATION_PROTOCOL, - "cache_config": _get_cache_configuration(), +def _get_retry_policy() -> Retry: + """Build the shared retry policy for Redis connections.""" + return Retry( + backoff=ExponentialWithJitterBackoff( + base=dify_config.REDIS_RETRY_BACKOFF_BASE, + cap=dify_config.REDIS_RETRY_BACKOFF_CAP, + ), + retries=dify_config.REDIS_RETRY_RETRIES, + ) + + +def _get_connection_health_params() -> RedisHealthParamsDict: + """Get connection health and retry parameters for standalone and Sentinel Redis clients.""" + return RedisHealthParamsDict( + retry=_get_retry_policy(), + socket_timeout=dify_config.REDIS_SOCKET_TIMEOUT, + socket_connect_timeout=dify_config.REDIS_SOCKET_CONNECT_TIMEOUT, + health_check_interval=dify_config.REDIS_HEALTH_CHECK_INTERVAL, + ) + + +def _get_cluster_connection_health_params() -> RedisClusterHealthParamsDict: + """Get retry and timeout parameters for Redis Cluster clients. + + RedisCluster does not support ``health_check_interval`` as a constructor + keyword (it is silently stripped by ``cleanup_kwargs``), so it is excluded + here. Only ``retry``, ``socket_timeout``, and ``socket_connect_timeout`` + are passed through. + """ + health_params = _get_connection_health_params() + result: RedisClusterHealthParamsDict = { + "retry": health_params["retry"], + "socket_timeout": health_params["socket_timeout"], + "socket_connect_timeout": health_params["socket_connect_timeout"], } + return result -def _create_sentinel_client(redis_params: dict[str, Any]) -> Union[redis.Redis, RedisCluster]: +def _get_base_redis_params() -> RedisBaseParamsDict: + """Get base Redis connection parameters including retry and health policy.""" + return RedisBaseParamsDict( + username=dify_config.REDIS_USERNAME, + password=dify_config.REDIS_PASSWORD or None, + db=dify_config.REDIS_DB, + encoding="utf-8", + encoding_errors="strict", + decode_responses=False, + protocol=dify_config.REDIS_SERIALIZATION_PROTOCOL, + cache_config=_get_cache_configuration(), + **_get_connection_health_params(), + ) + + +def _create_sentinel_client(redis_params: RedisBaseParamsDict) -> Union[redis.Redis, RedisCluster]: """Create Redis client using Sentinel configuration.""" if not dify_config.REDIS_SENTINELS: raise ValueError("REDIS_SENTINELS must be set when REDIS_USE_SENTINEL is True") @@ -196,7 +273,8 @@ def _create_sentinel_client(redis_params: dict[str, Any]) -> Union[redis.Redis, sentinel_kwargs=sentinel_kwargs, ) - master: redis.Redis = sentinel.master_for(dify_config.REDIS_SENTINEL_SERVICE_NAME, **redis_params) + params: dict[str, Any] = {**redis_params} + master: redis.Redis = sentinel.master_for(dify_config.REDIS_SENTINEL_SERVICE_NAME, **params) return master @@ -215,6 +293,7 @@ def _create_cluster_client() -> Union[redis.Redis, RedisCluster]: "password": dify_config.REDIS_CLUSTERS_PASSWORD, "protocol": dify_config.REDIS_SERIALIZATION_PROTOCOL, "cache_config": _get_cache_configuration(), + **_get_cluster_connection_health_params(), } if dify_config.REDIS_MAX_CONNECTIONS: cluster_kwargs["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS @@ -222,41 +301,43 @@ def _create_cluster_client() -> Union[redis.Redis, RedisCluster]: return cluster -def _create_standalone_client(redis_params: dict[str, Any]) -> Union[redis.Redis, RedisCluster]: +def _create_standalone_client(redis_params: RedisBaseParamsDict) -> Union[redis.Redis, RedisCluster]: """Create standalone Redis client.""" connection_class, ssl_kwargs = _get_ssl_configuration() - redis_params.update( - { - "host": dify_config.REDIS_HOST, - "port": dify_config.REDIS_PORT, - "connection_class": connection_class, - } - ) + params: dict[str, Any] = { + **redis_params, + "host": dify_config.REDIS_HOST, + "port": dify_config.REDIS_PORT, + "connection_class": connection_class, + } if dify_config.REDIS_MAX_CONNECTIONS: - redis_params["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS + params["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS if ssl_kwargs: - redis_params.update(ssl_kwargs) + params.update(ssl_kwargs) - pool = redis.ConnectionPool(**redis_params) + pool = redis.ConnectionPool(**params) client: redis.Redis = redis.Redis(connection_pool=pool) return client def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> redis.Redis | RedisCluster: max_conns = dify_config.REDIS_MAX_CONNECTIONS - if use_clusters: - if max_conns: - return RedisCluster.from_url(pubsub_url, max_connections=max_conns) - else: - return RedisCluster.from_url(pubsub_url) + if use_clusters: + health_params = _get_cluster_connection_health_params() + kwargs: dict[str, Any] = {**health_params} + if max_conns: + kwargs["max_connections"] = max_conns + return RedisCluster.from_url(pubsub_url, **kwargs) + + standalone_health_params: dict[str, Any] = dict(_get_connection_health_params()) + kwargs = {**standalone_health_params} if max_conns: - return redis.Redis.from_url(pubsub_url, max_connections=max_conns) - else: - return redis.Redis.from_url(pubsub_url) + kwargs["max_connections"] = max_conns + return redis.Redis.from_url(pubsub_url, **kwargs) def init_app(app: DifyApp): diff --git a/api/extensions/otel/decorators/base.py b/api/extensions/otel/decorators/base.py index 1dd92caeae..ad83826427 100644 --- a/api/extensions/otel/decorators/base.py +++ b/api/extensions/otel/decorators/base.py @@ -37,12 +37,7 @@ def trace_span[**P, R](handler_class: type[SpanHandler] | None = None) -> Callab handler = _get_handler_instance(handler_class or SpanHandler) tracer = get_tracer(__name__) - return handler.wrapper( - tracer=tracer, - wrapped=func, - args=args, - kwargs=kwargs, - ) + return handler.wrapper(tracer, func, *args, **kwargs) return cast(Callable[P, R], wrapper) diff --git a/api/extensions/otel/decorators/handler.py b/api/extensions/otel/decorators/handler.py index e465a615a6..b0d9fa7af6 100644 --- a/api/extensions/otel/decorators/handler.py +++ b/api/extensions/otel/decorators/handler.py @@ -1,8 +1,8 @@ import inspect -from collections.abc import Callable, Mapping +from collections.abc import Callable from typing import Any -from opentelemetry.trace import SpanKind, Status, StatusCode +from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer class SpanHandler: @@ -16,9 +16,9 @@ class SpanHandler: exceptions. Handlers can override the wrapper method to customize behavior. """ - _signature_cache: dict[Callable[..., Any], inspect.Signature] = {} + _signature_cache: dict[Callable[..., object], inspect.Signature] = {} - def _build_span_name(self, wrapped: Callable[..., Any]) -> str: + def _build_span_name[**P, R](self, wrapped: Callable[P, R]) -> str: """ Build the span name from the wrapped function. @@ -29,11 +29,11 @@ class SpanHandler: """ return f"{wrapped.__module__}.{wrapped.__qualname__}" - def _extract_arguments[T]( + def _extract_arguments[**P, R]( self, - wrapped: Callable[..., T], - args: tuple[object, ...], - kwargs: Mapping[str, object], + wrapped: Callable[P, R], + *args: P.args, + **kwargs: P.kwargs, ) -> dict[str, Any] | None: """ Extract function arguments using inspect.signature. @@ -59,13 +59,13 @@ class SpanHandler: except Exception: return None - def wrapper[T]( + def wrapper[**P, R]( self, - tracer: Any, - wrapped: Callable[..., T], - args: tuple[object, ...], - kwargs: Mapping[str, object], - ) -> T: + tracer: Tracer, + wrapped: Callable[P, R], + *args: P.args, + **kwargs: P.kwargs, + ) -> R: """ Fully control the wrapper behavior. diff --git a/api/extensions/otel/decorators/handlers/generate_handler.py b/api/extensions/otel/decorators/handlers/generate_handler.py index cc6c75304f..df5142c310 100644 --- a/api/extensions/otel/decorators/handlers/generate_handler.py +++ b/api/extensions/otel/decorators/handlers/generate_handler.py @@ -1,8 +1,7 @@ import logging -from collections.abc import Callable, Mapping -from typing import Any +from collections.abc import Callable -from opentelemetry.trace import SpanKind, Status, StatusCode +from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer from opentelemetry.util.types import AttributeValue from extensions.otel.decorators.handler import SpanHandler @@ -15,15 +14,15 @@ logger = logging.getLogger(__name__) class AppGenerateHandler(SpanHandler): """Span handler for ``AppGenerateService.generate``.""" - def wrapper[T]( + def wrapper[**P, R]( self, - tracer: Any, - wrapped: Callable[..., T], - args: tuple[object, ...], - kwargs: Mapping[str, object], - ) -> T: + tracer: Tracer, + wrapped: Callable[P, R], + *args: P.args, + **kwargs: P.kwargs, + ) -> R: try: - arguments = self._extract_arguments(wrapped, args, kwargs) + arguments = self._extract_arguments(wrapped, *args, **kwargs) if not arguments: return wrapped(*args, **kwargs) diff --git a/api/extensions/otel/decorators/handlers/workflow_app_runner_handler.py b/api/extensions/otel/decorators/handlers/workflow_app_runner_handler.py index 8abd60197c..6b2112ceb2 100644 --- a/api/extensions/otel/decorators/handlers/workflow_app_runner_handler.py +++ b/api/extensions/otel/decorators/handlers/workflow_app_runner_handler.py @@ -1,8 +1,7 @@ import logging -from collections.abc import Callable, Mapping -from typing import Any +from collections.abc import Callable -from opentelemetry.trace import SpanKind, Status, StatusCode +from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer from opentelemetry.util.types import AttributeValue from extensions.otel.decorators.handler import SpanHandler @@ -14,15 +13,15 @@ logger = logging.getLogger(__name__) class WorkflowAppRunnerHandler(SpanHandler): """Span handler for ``WorkflowAppRunner.run``.""" - def wrapper( + def wrapper[**P, R]( self, - tracer: Any, - wrapped: Callable[..., Any], - args: tuple[Any, ...], - kwargs: Mapping[str, Any], - ) -> Any: + tracer: Tracer, + wrapped: Callable[P, R], + *args: P.args, + **kwargs: P.kwargs, + ) -> R: try: - arguments = self._extract_arguments(wrapped, args, kwargs) + arguments = self._extract_arguments(wrapped, *args, **kwargs) if not arguments: return wrapped(*args, **kwargs) diff --git a/api/extensions/storage/clickzetta_volume/clickzetta_volume_storage.py b/api/extensions/storage/clickzetta_volume/clickzetta_volume_storage.py index 18eed4e481..05492327c8 100644 --- a/api/extensions/storage/clickzetta_volume/clickzetta_volume_storage.py +++ b/api/extensions/storage/clickzetta_volume/clickzetta_volume_storage.py @@ -10,6 +10,7 @@ import tempfile from collections.abc import Generator from io import BytesIO from pathlib import Path +from typing import Any import clickzetta from pydantic import BaseModel, model_validator @@ -39,7 +40,7 @@ class ClickZettaVolumeConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): """Validate the configuration values. This method will first try to use CLICKZETTA_VOLUME_* environment variables, diff --git a/api/extensions/storage/clickzetta_volume/file_lifecycle.py b/api/extensions/storage/clickzetta_volume/file_lifecycle.py index 86b1bba544..1cb940b797 100644 --- a/api/extensions/storage/clickzetta_volume/file_lifecycle.py +++ b/api/extensions/storage/clickzetta_volume/file_lifecycle.py @@ -65,7 +65,7 @@ class FileMetadata: return data @classmethod - def from_dict(cls, data: dict) -> FileMetadata: + def from_dict(cls, data: dict[str, Any]) -> FileMetadata: """Create instance from dictionary""" data = data.copy() data["created_at"] = datetime.fromisoformat(data["created_at"]) @@ -459,7 +459,7 @@ class FileLifecycleManager: newest_file=None, ) - def _create_version_backup(self, filename: str, metadata: dict): + def _create_version_backup(self, filename: str, metadata: dict[str, Any]): """Create version backup""" try: # Read current file content @@ -487,7 +487,7 @@ class FileLifecycleManager: logger.warning("Failed to load metadata: %s", e) return {} - def _save_metadata(self, metadata_dict: dict): + def _save_metadata(self, metadata_dict: dict[str, Any]): """Save metadata file""" try: metadata_content = json.dumps(metadata_dict, indent=2, ensure_ascii=False) diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index 7878d58679..1afcbdb5b9 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -80,7 +80,7 @@ class Feedback(ResponseModel): from_account: SimpleAccount | None = None -class Annotation(ResponseModel): +class ConversationAnnotation(ResponseModel): id: str question: str | None = None content: str @@ -95,7 +95,7 @@ class Annotation(ResponseModel): return value -class AnnotationHitHistory(ResponseModel): +class ConversationAnnotationHitHistory(ResponseModel): annotation_id: str annotation_create_account: SimpleAccount | None = None created_at: int | None = None @@ -151,8 +151,8 @@ class MessageDetail(ResponseModel): from_account_id: str | None = None feedbacks: list[Feedback] workflow_run_id: str | None = None - annotation: Annotation | None = None - annotation_hit_history: AnnotationHitHistory | None = None + annotation: ConversationAnnotation | None = None + annotation_hit_history: ConversationAnnotationHitHistory | None = None created_at: int | None = None agent_thoughts: list[AgentThought] message_files: list[MessageFile] @@ -223,7 +223,7 @@ class Conversation(ResponseModel): read_at: int | None = None created_at: int | None = None updated_at: int | None = None - annotation: Annotation | None = None + annotation: ConversationAnnotation | None = None model_config_: SimpleModelConfig | None = Field(default=None, alias="model_config") user_feedback_stats: FeedbackStat | None = None admin_feedback_stats: FeedbackStat | None = None diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py index a063a643b4..1a871204a0 100644 --- a/api/fields/message_fields.py +++ b/api/fields/message_fields.py @@ -4,18 +4,15 @@ from datetime import datetime from uuid import uuid4 from graphon.file import File -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import Field, field_validator from core.entities.execution_extra_content import ExecutionExtraContentDomainModel +from fields.base import ResponseModel from fields.conversation_fields import AgentThought, JSONValue, MessageFile type JSONValueType = JSONValue -class ResponseModel(BaseModel): - model_config = ConfigDict(from_attributes=True, extra="ignore") - - class SimpleFeedback(ResponseModel): rating: str | None = None diff --git a/api/libs/broadcast_channel/redis/_subscription.py b/api/libs/broadcast_channel/redis/_subscription.py index 40027bc424..4db79a15a9 100644 --- a/api/libs/broadcast_channel/redis/_subscription.py +++ b/api/libs/broadcast_channel/redis/_subscription.py @@ -3,7 +3,7 @@ import queue import threading import types from collections.abc import Generator, Iterator -from typing import Self +from typing import Any, Self from libs.broadcast_channel.channel import Subscription from libs.broadcast_channel.exc import SubscriptionClosedError @@ -221,7 +221,7 @@ class RedisSubscriptionBase(Subscription): """Unsubscribe from the Redis topic using the appropriate command.""" raise NotImplementedError - def _get_message(self) -> dict | None: + def _get_message(self) -> dict[str, Any] | None: """Get a message from Redis using the appropriate method.""" raise NotImplementedError diff --git a/api/libs/broadcast_channel/redis/channel.py b/api/libs/broadcast_channel/redis/channel.py index bd6d58c53f..36aa1cd3e8 100644 --- a/api/libs/broadcast_channel/redis/channel.py +++ b/api/libs/broadcast_channel/redis/channel.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from libs.broadcast_channel.channel import Producer, Subscriber, Subscription from redis import Redis, RedisCluster @@ -62,7 +64,7 @@ class _RedisSubscription(RedisSubscriptionBase): assert self._pubsub is not None self._pubsub.unsubscribe(self._topic) - def _get_message(self) -> dict | None: + def _get_message(self) -> dict[str, Any] | None: assert self._pubsub is not None return self._pubsub.get_message(ignore_subscribe_messages=True, timeout=1) diff --git a/api/libs/broadcast_channel/redis/sharded_channel.py b/api/libs/broadcast_channel/redis/sharded_channel.py index 20c43b8bbb..dddc92d099 100644 --- a/api/libs/broadcast_channel/redis/sharded_channel.py +++ b/api/libs/broadcast_channel/redis/sharded_channel.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from libs.broadcast_channel.channel import Producer, Subscriber, Subscription from redis import Redis, RedisCluster @@ -60,7 +62,7 @@ class _RedisShardedSubscription(RedisSubscriptionBase): assert self._pubsub is not None self._pubsub.sunsubscribe(self._topic) # type: ignore[attr-defined] - def _get_message(self) -> dict | None: + def _get_message(self) -> dict[str, Any] | None: assert self._pubsub is not None # NOTE(QuantumGhost): this is an issue in # upstream code. If Sharded PubSub is used with Cluster, the diff --git a/api/libs/db_migration_lock.py b/api/libs/db_migration_lock.py index 1d3a81e0a2..ca8956e397 100644 --- a/api/libs/db_migration_lock.py +++ b/api/libs/db_migration_lock.py @@ -14,9 +14,15 @@ from __future__ import annotations import logging import threading -from typing import Any +from typing import TYPE_CHECKING, Any +import redis +from redis.cluster import RedisCluster from redis.exceptions import LockNotOwnedError, RedisError +from redis.lock import Lock + +if TYPE_CHECKING: + from extensions.ext_redis import RedisClientWrapper logger = logging.getLogger(__name__) @@ -38,21 +44,21 @@ class DbMigrationAutoRenewLock: primary error/exit code. """ - _redis_client: Any + _redis_client: redis.Redis | RedisCluster | RedisClientWrapper _name: str _ttl_seconds: float _renew_interval_seconds: float _log_context: str | None _logger: logging.Logger - _lock: Any + _lock: Lock | None _stop_event: threading.Event | None _thread: threading.Thread | None _acquired: bool def __init__( self, - redis_client: Any, + redis_client: redis.Redis | RedisCluster | RedisClientWrapper, name: str, ttl_seconds: float = 60, renew_interval_seconds: float | None = None, @@ -127,7 +133,7 @@ class DbMigrationAutoRenewLock: ) self._thread.start() - def _heartbeat_loop(self, lock: Any, stop_event: threading.Event) -> None: + def _heartbeat_loop(self, lock: Lock, stop_event: threading.Event) -> None: while not stop_event.wait(self._renew_interval_seconds): try: lock.reacquire() diff --git a/api/libs/exception.py b/api/libs/exception.py index 73379dfded..1e4bbb44f6 100644 --- a/api/libs/exception.py +++ b/api/libs/exception.py @@ -1,9 +1,11 @@ +from typing import Any + from werkzeug.exceptions import HTTPException class BaseHTTPException(HTTPException): error_code: str = "unknown" - data: dict | None = None + data: dict[str, Any] | None = None def __init__(self, description=None, response=None): super().__init__(description, response) diff --git a/api/libs/external_api.py b/api/libs/external_api.py index e8592407c3..f907d17750 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -17,7 +17,6 @@ def http_status_message(code): def register_external_error_handlers(api: Api): - @api.errorhandler(HTTPException) def handle_http_exception(e: HTTPException): got_request_exception.send(current_app, exception=e) @@ -74,27 +73,18 @@ def register_external_error_handlers(api: Api): headers["Set-Cookie"] = build_force_logout_cookie_headers() return data, status_code, headers - _ = handle_http_exception - - @api.errorhandler(ValueError) def handle_value_error(e: ValueError): got_request_exception.send(current_app, exception=e) status_code = 400 data = {"code": "invalid_param", "message": str(e), "status": status_code} return data, status_code - _ = handle_value_error - - @api.errorhandler(AppInvokeQuotaExceededError) def handle_quota_exceeded(e: AppInvokeQuotaExceededError): got_request_exception.send(current_app, exception=e) status_code = 429 data = {"code": "too_many_requests", "message": str(e), "status": status_code} return data, status_code - _ = handle_quota_exceeded - - @api.errorhandler(Exception) def handle_general_exception(e: Exception): got_request_exception.send(current_app, exception=e) @@ -113,7 +103,10 @@ def register_external_error_handlers(api: Api): return data, status_code - _ = handle_general_exception + api.errorhandler(HTTPException)(handle_http_exception) + api.errorhandler(ValueError)(handle_value_error) + api.errorhandler(AppInvokeQuotaExceededError)(handle_quota_exceeded) + api.errorhandler(Exception)(handle_general_exception) class ExternalApi(Api): diff --git a/api/libs/helper.py b/api/libs/helper.py index ece53e8806..f28de92927 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -10,7 +10,7 @@ import uuid from collections.abc import Callable, Generator, Mapping from datetime import datetime from hashlib import sha256 -from typing import TYPE_CHECKING, Annotated, Any, Optional, Protocol, Union, cast +from typing import TYPE_CHECKING, Annotated, Any, Protocol, cast from uuid import UUID from zoneinfo import available_timezones @@ -81,7 +81,7 @@ def escape_like_pattern(pattern: str) -> str: return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") -def extract_tenant_id(user: Union["Account", "EndUser"]) -> str | None: +def extract_tenant_id(user: "Account | EndUser") -> str | None: """ Extract tenant_id from Account or EndUser object. @@ -164,7 +164,10 @@ def email(email): EmailStr = Annotated[str, AfterValidator(email)] -def uuid_value(value: Any) -> str: +def uuid_value(value: str | UUID) -> str: + if isinstance(value, UUID): + return str(value) + if value == "": return str(value) @@ -405,9 +408,9 @@ class TokenManager: def generate_token( cls, token_type: str, - account: Optional["Account"] = None, + account: "Account | None" = None, email: str | None = None, - additional_data: dict | None = None, + additional_data: dict[str, Any] | None = None, ) -> str: if account is None and email is None: raise ValueError("Account or email must be provided") @@ -465,9 +468,7 @@ class TokenManager: return current_token @classmethod - def _set_current_token_for_account( - cls, account_id: str, token: str, token_type: str, expiry_minutes: Union[int, float] - ): + def _set_current_token_for_account(cls, account_id: str, token: str, token_type: str, expiry_minutes: int | float): key = cls._get_account_token_key(account_id, token_type) expiry_seconds = int(expiry_minutes * 60) redis_client.setex(key, expiry_seconds, token) diff --git a/api/libs/pyrefly_type_coverage.py b/api/libs/pyrefly_type_coverage.py new file mode 100644 index 0000000000..369b8dff3c --- /dev/null +++ b/api/libs/pyrefly_type_coverage.py @@ -0,0 +1,145 @@ +"""Helpers for generating type-coverage summaries from pyrefly report output.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import TypedDict + + +class CoverageSummary(TypedDict): + n_modules: int + n_typable: int + n_typed: int + n_any: int + n_untyped: int + coverage: float + strict_coverage: float + + +_REQUIRED_KEYS = frozenset(CoverageSummary.__annotations__) + +_EMPTY_SUMMARY: CoverageSummary = { + "n_modules": 0, + "n_typable": 0, + "n_typed": 0, + "n_any": 0, + "n_untyped": 0, + "coverage": 0.0, + "strict_coverage": 0.0, +} + + +def parse_summary(report_json: str) -> CoverageSummary: + """Extract the summary section from ``pyrefly report`` JSON output. + + Returns an empty summary when *report_json* is empty or malformed so that + the CI workflow can degrade gracefully instead of crashing. + """ + if not report_json or not report_json.strip(): + return _EMPTY_SUMMARY.copy() + + try: + data = json.loads(report_json) + except json.JSONDecodeError: + return _EMPTY_SUMMARY.copy() + + summary = data.get("summary") + if not isinstance(summary, dict) or not _REQUIRED_KEYS.issubset(summary): + return _EMPTY_SUMMARY.copy() + + return { + "n_modules": summary["n_modules"], + "n_typable": summary["n_typable"], + "n_typed": summary["n_typed"], + "n_any": summary["n_any"], + "n_untyped": summary["n_untyped"], + "coverage": summary["coverage"], + "strict_coverage": summary["strict_coverage"], + } + + +def format_summary_markdown(summary: CoverageSummary) -> str: + """Format a single coverage summary as a Markdown table.""" + + return ( + "| Metric | Value |\n" + "| --- | ---: |\n" + f"| Modules | {summary['n_modules']} |\n" + f"| Typable symbols | {summary['n_typable']:,} |\n" + f"| Typed symbols | {summary['n_typed']:,} |\n" + f"| Untyped symbols | {summary['n_untyped']:,} |\n" + f"| Any symbols | {summary['n_any']:,} |\n" + f"| **Type coverage** | **{summary['coverage']:.2f}%** |\n" + f"| Strict coverage | {summary['strict_coverage']:.2f}% |" + ) + + +def format_comparison_markdown( + base: CoverageSummary, + pr: CoverageSummary, +) -> str: + """Format a comparison between base and PR coverage as Markdown.""" + + coverage_delta = pr["coverage"] - base["coverage"] + strict_delta = pr["strict_coverage"] - base["strict_coverage"] + typed_delta = pr["n_typed"] - base["n_typed"] + untyped_delta = pr["n_untyped"] - base["n_untyped"] + + def _fmt_delta(value: float, fmt: str = ".2f") -> str: + sign = "+" if value > 0 else "" + return f"{sign}{value:{fmt}}" + + lines = [ + "| Metric | Base | PR | Delta |", + "| --- | ---: | ---: | ---: |", + (f"| **Type coverage** | {base['coverage']:.2f}% | {pr['coverage']:.2f}% | {_fmt_delta(coverage_delta)}% |"), + ( + f"| Strict coverage | {base['strict_coverage']:.2f}% " + f"| {pr['strict_coverage']:.2f}% " + f"| {_fmt_delta(strict_delta)}% |" + ), + (f"| Typed symbols | {base['n_typed']:,} | {pr['n_typed']:,} | {_fmt_delta(typed_delta, ',')} |"), + (f"| Untyped symbols | {base['n_untyped']:,} | {pr['n_untyped']:,} | {_fmt_delta(untyped_delta, ',')} |"), + ( + f"| Modules | {base['n_modules']} " + f"| {pr['n_modules']} " + f"| {_fmt_delta(pr['n_modules'] - base['n_modules'], ',')} |" + ), + ] + return "\n".join(lines) + + +def main() -> int: + """Read pyrefly report JSON from stdin and print a Markdown summary. + + Accepts an optional ``--base `` argument. When provided, the output + includes a base-vs-PR comparison table. + """ + + args = sys.argv[1:] + + base_file: str | None = None + if "--base" in args: + idx = args.index("--base") + if idx + 1 >= len(args): + sys.stderr.write("error: --base requires a file path\n") + return 1 + base_file = args[idx + 1] + + pr_report = sys.stdin.read() + pr_summary = parse_summary(pr_report) + + if base_file is not None: + base_text = Path(base_file).read_text() if Path(base_file).exists() else "" + base_summary = parse_summary(base_text) + sys.stdout.write(format_comparison_markdown(base_summary, pr_summary) + "\n") + else: + sys.stdout.write(format_summary_markdown(pr_summary) + "\n") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/libs/sendgrid.py b/api/libs/sendgrid.py index c047c54d06..0338641d11 100644 --- a/api/libs/sendgrid.py +++ b/api/libs/sendgrid.py @@ -1,4 +1,5 @@ import logging +from typing import Any import sendgrid from python_http_client.exceptions import ForbiddenError, UnauthorizedError @@ -12,7 +13,7 @@ class SendGridClient: self.sendgrid_api_key = sendgrid_api_key self._from = _from - def send(self, mail: dict): + def send(self, mail: dict[str, Any]): logger.debug("Sending email with SendGrid") _to = "" try: diff --git a/api/libs/smtp.py b/api/libs/smtp.py index 6f82f1440a..53906d1769 100644 --- a/api/libs/smtp.py +++ b/api/libs/smtp.py @@ -2,6 +2,7 @@ import logging import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from typing import Any from configs import dify_config @@ -20,7 +21,7 @@ class SMTPClient: self.use_tls = use_tls self.opportunistic_tls = opportunistic_tls - def send(self, mail: dict): + def send(self, mail: dict[str, Any]): smtp: smtplib.SMTP | None = None local_host = dify_config.SMTP_LOCAL_HOSTNAME try: diff --git a/api/models/base.py b/api/models/base.py index b7023b9c8b..5acdf184f4 100644 --- a/api/models/base.py +++ b/api/models/base.py @@ -24,6 +24,8 @@ class TypeBase(MappedAsDataclass, DeclarativeBase): class DefaultFieldsMixin: + """Mixin for models that inherit from Base (non-dataclass).""" + id: Mapped[str] = mapped_column( StringUUID, primary_key=True, @@ -53,6 +55,42 @@ class DefaultFieldsMixin: return f"<{self.__class__.__name__}(id={self.id})>" +class DefaultFieldsDCMixin(MappedAsDataclass): + """Mixin for models that inherit from TypeBase (MappedAsDataclass).""" + + __abstract__ = True + + id: Mapped[str] = mapped_column( + StringUUID, + primary_key=True, + insert_default=lambda: str(uuidv7()), + default_factory=lambda: str(uuidv7()), + init=False, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + insert_default=naive_utc_now, + default_factory=naive_utc_now, + init=False, + server_default=func.current_timestamp(), + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + insert_default=naive_utc_now, + default_factory=naive_utc_now, + init=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + ) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}(id={self.id})>" + + def gen_uuidv4_string() -> str: """gen_uuidv4_string generate a UUIDv4 string. diff --git a/api/models/dataset.py b/api/models/dataset.py index 14dfcea5de..d78824460a 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -108,6 +108,56 @@ class ExternalKnowledgeApiDict(TypedDict): created_at: str +class DocumentDict(TypedDict): + id: str + tenant_id: str + dataset_id: str + position: int + data_source_type: str + data_source_info: str | None + dataset_process_rule_id: str | None + batch: str + name: str + created_from: str + created_by: str + created_api_request_id: str | None + created_at: datetime + processing_started_at: datetime | None + file_id: str | None + word_count: int | None + parsing_completed_at: datetime | None + cleaning_completed_at: datetime | None + splitting_completed_at: datetime | None + tokens: int | None + indexing_latency: float | None + completed_at: datetime | None + is_paused: bool | None + paused_by: str | None + paused_at: datetime | None + error: str | None + stopped_at: datetime | None + indexing_status: str + enabled: bool + disabled_at: datetime | None + disabled_by: str | None + archived: bool + archived_reason: str | None + archived_by: str | None + archived_at: datetime | None + updated_at: datetime + doc_type: str | None + doc_metadata: Any + doc_form: IndexStructureType + doc_language: str | None + display_status: str | None + data_source_info_dict: dict[str, Any] + average_segment_length: int + dataset_process_rule: ProcessRuleDict | None + dataset: None + segment_count: int | None + hit_count: int | None + + class DatasetPermissionEnum(enum.StrEnum): ONLY_ME = "only_me" ALL_TEAM = "all_team_members" @@ -303,13 +353,17 @@ class Dataset(Base): if self.provider != "external": return None external_knowledge_binding = db.session.scalar( - select(ExternalKnowledgeBindings).where(ExternalKnowledgeBindings.dataset_id == self.id) + select(ExternalKnowledgeBindings).where( + ExternalKnowledgeBindings.dataset_id == self.id, + ExternalKnowledgeBindings.tenant_id == self.tenant_id, + ) ) if not external_knowledge_binding: return None external_knowledge_api = db.session.scalar( select(ExternalKnowledgeApis).where( - ExternalKnowledgeApis.id == external_knowledge_binding.external_knowledge_api_id + ExternalKnowledgeApis.id == external_knowledge_binding.external_knowledge_api_id, + ExternalKnowledgeApis.tenant_id == self.tenant_id, ) ) if external_knowledge_api is None or external_knowledge_api.settings is None: @@ -675,8 +729,8 @@ class Document(Base): ) return built_in_fields - def to_dict(self) -> dict[str, Any]: - return { + def to_dict(self) -> DocumentDict: + result: DocumentDict = { "id": self.id, "tenant_id": self.tenant_id, "dataset_id": self.dataset_id, @@ -721,10 +775,11 @@ class Document(Base): "data_source_info_dict": self.data_source_info_dict, "average_segment_length": self.average_segment_length, "dataset_process_rule": self.dataset_process_rule.to_dict() if self.dataset_process_rule else None, - "dataset": None, # Dataset class doesn't have a to_dict method + "dataset": None, "segment_count": self.segment_count, "hit_count": self.hit_count, } + return result @classmethod def from_dict(cls, data: dict[str, Any]): @@ -1634,7 +1689,7 @@ class PipelineRecommendedPlugin(TypeBase): ) -class SegmentAttachmentBinding(Base): +class SegmentAttachmentBinding(TypeBase): __tablename__ = "segment_attachment_bindings" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="segment_attachment_binding_pkey"), @@ -1647,13 +1702,17 @@ class SegmentAttachmentBinding(Base): ), sa.Index("segment_attachment_binding_attachment_idx", "attachment_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + id: Mapped[str] = mapped_column( + StringUUID, insert_default=lambda: str(uuidv7()), default_factory=lambda: str(uuidv7()), init=False + ) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False) document_id: Mapped[str] = mapped_column(StringUUID, nullable=False) segment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) attachment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) class DocumentSegmentSummary(Base): diff --git a/api/models/model.py b/api/models/model.py index 43ddf344d2..47b096d0bf 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -674,28 +674,24 @@ class AppModelConfig(TypeBase): def suggested_questions_list(self) -> list[str]: return json.loads(self.suggested_questions) if self.suggested_questions else [] + def _get_enabled_config(self, value: str | None, *, default_enabled: bool = False) -> EnabledConfig: + return cast(EnabledConfig, json.loads(value) if value else {"enabled": default_enabled}) + @property def suggested_questions_after_answer_dict(self) -> EnabledConfig: - return cast( - EnabledConfig, - json.loads(self.suggested_questions_after_answer) - if self.suggested_questions_after_answer - else {"enabled": False}, - ) + return self._get_enabled_config(self.suggested_questions_after_answer) @property def speech_to_text_dict(self) -> EnabledConfig: - return cast(EnabledConfig, json.loads(self.speech_to_text) if self.speech_to_text else {"enabled": False}) + return self._get_enabled_config(self.speech_to_text) @property def text_to_speech_dict(self) -> EnabledConfig: - return cast(EnabledConfig, json.loads(self.text_to_speech) if self.text_to_speech else {"enabled": False}) + return self._get_enabled_config(self.text_to_speech) @property def retriever_resource_dict(self) -> EnabledConfig: - return cast( - EnabledConfig, json.loads(self.retriever_resource) if self.retriever_resource else {"enabled": True} - ) + return self._get_enabled_config(self.retriever_resource, default_enabled=True) @property def annotation_reply_dict(self) -> AnnotationReplyConfig: @@ -722,7 +718,7 @@ class AppModelConfig(TypeBase): @property def more_like_this_dict(self) -> EnabledConfig: - return cast(EnabledConfig, json.loads(self.more_like_this) if self.more_like_this else {"enabled": False}) + return self._get_enabled_config(self.more_like_this) @property def sensitive_word_avoidance_dict(self) -> SensitiveWordAvoidanceConfig: @@ -813,60 +809,36 @@ class AppModelConfig(TypeBase): "file_upload": self.file_upload_dict, } + @staticmethod + def _dump_optional(value: Any) -> str | None: + return json.dumps(value) if value else None + def from_model_config_dict(self, model_config: AppModelConfigDict): self.opening_statement = model_config.get("opening_statement") - self.suggested_questions = ( - json.dumps(model_config.get("suggested_questions")) if model_config.get("suggested_questions") else None - ) - self.suggested_questions_after_answer = ( - json.dumps(model_config.get("suggested_questions_after_answer")) - if model_config.get("suggested_questions_after_answer") - else None - ) - self.speech_to_text = ( - json.dumps(model_config.get("speech_to_text")) if model_config.get("speech_to_text") else None - ) - self.text_to_speech = ( - json.dumps(model_config.get("text_to_speech")) if model_config.get("text_to_speech") else None - ) - self.more_like_this = ( - json.dumps(model_config.get("more_like_this")) if model_config.get("more_like_this") else None - ) - self.sensitive_word_avoidance = ( - json.dumps(model_config.get("sensitive_word_avoidance")) - if model_config.get("sensitive_word_avoidance") - else None - ) - self.external_data_tools = ( - json.dumps(model_config.get("external_data_tools")) if model_config.get("external_data_tools") else None - ) - self.model = json.dumps(model_config.get("model")) if model_config.get("model") else None - self.user_input_form = ( - json.dumps(model_config.get("user_input_form")) if model_config.get("user_input_form") else None + self.suggested_questions = self._dump_optional(model_config.get("suggested_questions")) + self.suggested_questions_after_answer = self._dump_optional( + model_config.get("suggested_questions_after_answer") ) + self.speech_to_text = self._dump_optional(model_config.get("speech_to_text")) + self.text_to_speech = self._dump_optional(model_config.get("text_to_speech")) + self.more_like_this = self._dump_optional(model_config.get("more_like_this")) + self.sensitive_word_avoidance = self._dump_optional(model_config.get("sensitive_word_avoidance")) + self.external_data_tools = self._dump_optional(model_config.get("external_data_tools")) + self.model = self._dump_optional(model_config.get("model")) + self.user_input_form = self._dump_optional(model_config.get("user_input_form")) self.dataset_query_variable = model_config.get("dataset_query_variable") self.pre_prompt = model_config.get("pre_prompt") - self.agent_mode = json.dumps(model_config.get("agent_mode")) if model_config.get("agent_mode") else None - self.retriever_resource = ( - json.dumps(model_config.get("retriever_resource")) if model_config.get("retriever_resource") else None - ) + self.agent_mode = self._dump_optional(model_config.get("agent_mode")) + self.retriever_resource = self._dump_optional(model_config.get("retriever_resource")) self.prompt_type = PromptType(model_config.get("prompt_type", "simple")) - self.chat_prompt_config = ( - json.dumps(model_config.get("chat_prompt_config")) if model_config.get("chat_prompt_config") else None - ) - self.completion_prompt_config = ( - json.dumps(model_config.get("completion_prompt_config")) - if model_config.get("completion_prompt_config") - else None - ) - self.dataset_configs = ( - json.dumps(model_config.get("dataset_configs")) if model_config.get("dataset_configs") else None - ) - self.file_upload = json.dumps(model_config.get("file_upload")) if model_config.get("file_upload") else None + self.chat_prompt_config = self._dump_optional(model_config.get("chat_prompt_config")) + self.completion_prompt_config = self._dump_optional(model_config.get("completion_prompt_config")) + self.dataset_configs = self._dump_optional(model_config.get("dataset_configs")) + self.file_upload = self._dump_optional(model_config.get("file_upload")) return self -class RecommendedApp(Base): # bug +class RecommendedApp(TypeBase): __tablename__ = "recommended_apps" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="recommended_app_pkey"), @@ -874,20 +846,37 @@ class RecommendedApp(Base): # bug sa.Index("recommended_app_is_listed_idx", "is_listed", "language"), ) - id = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuid4())) - app_id = mapped_column(StringUUID, nullable=False) - description = mapped_column(sa.JSON, nullable=False) + id: Mapped[str] = mapped_column( + StringUUID, + primary_key=True, + insert_default=lambda: str(uuid4()), + default_factory=lambda: str(uuid4()), + init=False, + ) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + description: Mapped[Any] = mapped_column(sa.JSON, nullable=False) copyright: Mapped[str] = mapped_column(String(255), nullable=False) privacy_policy: Mapped[str] = mapped_column(String(255), nullable=False) - custom_disclaimer: Mapped[str] = mapped_column(LongText, default="") category: Mapped[str] = mapped_column(String(255), nullable=False) + custom_disclaimer: Mapped[str] = mapped_column(LongText, default="") position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) - language = mapped_column(String(255), nullable=False, server_default=sa.text("'en-US'")) - created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) - updated_at = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + language: Mapped[str] = mapped_column( + String(255), + nullable=False, + server_default=sa.text("'en-US'"), + default="en-US", + ) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) + updated_at: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) @property @@ -926,7 +915,7 @@ class InstalledApp(TypeBase): return db.session.scalar(select(Tenant).where(Tenant.id == self.tenant_id)) -class TrialApp(Base): +class TrialApp(TypeBase): __tablename__ = "trial_apps" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="trial_app_pkey"), @@ -935,18 +924,22 @@ class TrialApp(Base): sa.UniqueConstraint("app_id", name="unique_trail_app_id"), ) - id = mapped_column(StringUUID, default=gen_uuidv4_string) - app_id = mapped_column(StringUUID, nullable=False) - tenant_id = mapped_column(StringUUID, nullable=False) - created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) - trial_limit = mapped_column(sa.Integer, nullable=False, default=3) + id: Mapped[str] = mapped_column( + StringUUID, insert_default=gen_uuidv4_string, default_factory=gen_uuidv4_string, init=False + ) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) + trial_limit: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=3) @property def app(self) -> App | None: return db.session.scalar(select(App).where(App.id == self.app_id)) -class AccountTrialAppRecord(Base): +class AccountTrialAppRecord(TypeBase): __tablename__ = "account_trial_app_records" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="user_trial_app_pkey"), @@ -954,11 +947,15 @@ class AccountTrialAppRecord(Base): sa.Index("account_trial_app_record_app_id_idx", "app_id"), sa.UniqueConstraint("account_id", "app_id", name="unique_account_trial_app_record"), ) - id = mapped_column(StringUUID, default=gen_uuidv4_string) - account_id = mapped_column(StringUUID, nullable=False) - app_id = mapped_column(StringUUID, nullable=False) - count = mapped_column(sa.Integer, nullable=False, default=0) - created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + id: Mapped[str] = mapped_column( + StringUUID, insert_default=gen_uuidv4_string, default_factory=gen_uuidv4_string, init=False + ) + account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) @property def app(self) -> App | None: @@ -1081,7 +1078,7 @@ class Conversation(Base): messages = db.relationship("Message", backref="conversation", lazy="select", passive_deletes="all") message_annotations = db.relationship( - "MessageAnnotation", backref="conversation", lazy="select", passive_deletes="all" + lambda: MessageAnnotation, backref="conversation", lazy="select", passive_deletes="all" ) is_deleted: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) @@ -1632,52 +1629,53 @@ class Message(Base): files: list[File] = [] for message_file in message_files: - if message_file.transfer_method == FileTransferMethod.LOCAL_FILE: - if message_file.upload_file_id is None: - raise ValueError(f"MessageFile {message_file.id} is a local file but has no upload_file_id") - file = file_factory.build_from_mapping( - mapping={ + match message_file.transfer_method: + case FileTransferMethod.LOCAL_FILE: + if message_file.upload_file_id is None: + raise ValueError(f"MessageFile {message_file.id} is a local file but has no upload_file_id") + file = file_factory.build_from_mapping( + mapping={ + "id": message_file.id, + "type": message_file.type, + "transfer_method": message_file.transfer_method, + "upload_file_id": message_file.upload_file_id, + }, + tenant_id=current_app.tenant_id, + access_controller=_get_file_access_controller(), + ) + case FileTransferMethod.REMOTE_URL: + if message_file.url is None: + raise ValueError(f"MessageFile {message_file.id} is a remote url but has no url") + file = file_factory.build_from_mapping( + mapping={ + "id": message_file.id, + "type": message_file.type, + "transfer_method": message_file.transfer_method, + "upload_file_id": message_file.upload_file_id, + "url": message_file.url, + }, + tenant_id=current_app.tenant_id, + access_controller=_get_file_access_controller(), + ) + case FileTransferMethod.TOOL_FILE: + if message_file.upload_file_id is None: + assert message_file.url is not None + message_file.upload_file_id = message_file.url.split("/")[-1].split(".")[0] + mapping = { "id": message_file.id, "type": message_file.type, "transfer_method": message_file.transfer_method, - "upload_file_id": message_file.upload_file_id, - }, - tenant_id=current_app.tenant_id, - access_controller=_get_file_access_controller(), - ) - elif message_file.transfer_method == FileTransferMethod.REMOTE_URL: - if message_file.url is None: - raise ValueError(f"MessageFile {message_file.id} is a remote url but has no url") - file = file_factory.build_from_mapping( - mapping={ - "id": message_file.id, - "type": message_file.type, - "transfer_method": message_file.transfer_method, - "upload_file_id": message_file.upload_file_id, - "url": message_file.url, - }, - tenant_id=current_app.tenant_id, - access_controller=_get_file_access_controller(), - ) - elif message_file.transfer_method == FileTransferMethod.TOOL_FILE: - if message_file.upload_file_id is None: - assert message_file.url is not None - message_file.upload_file_id = message_file.url.split("/")[-1].split(".")[0] - mapping = { - "id": message_file.id, - "type": message_file.type, - "transfer_method": message_file.transfer_method, - "tool_file_id": message_file.upload_file_id, - } - file = file_factory.build_from_mapping( - mapping=mapping, - tenant_id=current_app.tenant_id, - access_controller=_get_file_access_controller(), - ) - else: - raise ValueError( - f"MessageFile {message_file.id} has an invalid transfer_method {message_file.transfer_method}" - ) + "tool_file_id": message_file.upload_file_id, + } + file = file_factory.build_from_mapping( + mapping=mapping, + tenant_id=current_app.tenant_id, + access_controller=_get_file_access_controller(), + ) + case FileTransferMethod.DATASOURCE_FILE: + raise ValueError( + f"MessageFile {message_file.id} has an invalid transfer_method {message_file.transfer_method}" + ) files.append(file) result = cast( @@ -1839,7 +1837,7 @@ class MessageFile(TypeBase): ) -class MessageAnnotation(Base): +class MessageAnnotation(TypeBase): __tablename__ = "message_annotations" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="message_annotation_pkey"), @@ -1848,17 +1846,25 @@ class MessageAnnotation(Base): sa.Index("message_annotation_message_idx", "message_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) + id: Mapped[str] = mapped_column( + StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False + ) app_id: Mapped[str] = mapped_column(StringUUID) - conversation_id: Mapped[str | None] = mapped_column(StringUUID, sa.ForeignKey("conversations.id")) - message_id: Mapped[str | None] = mapped_column(StringUUID) question: Mapped[str] = mapped_column(LongText, nullable=False) content: Mapped[str] = mapped_column(LongText, nullable=False) - hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0")) account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + conversation_id: Mapped[str | None] = mapped_column(StringUUID, sa.ForeignKey("conversations.id"), default=None) + message_id: Mapped[str | None] = mapped_column(StringUUID, default=None) + hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"), default=0) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) updated_at: Mapped[datetime] = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) @property diff --git a/api/models/source.py b/api/models/source.py index a8addbe342..8078b32f8c 100644 --- a/api/models/source.py +++ b/api/models/source.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from typing import Any, TypedDict from uuid import uuid4 import sqlalchemy as sa @@ -38,6 +39,17 @@ class DataSourceOauthBinding(TypeBase): disabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=True, server_default=sa.text("false"), default=False) +class DataSourceApiKeyAuthBindingDict(TypedDict): + id: str + tenant_id: str + category: str + provider: str + credentials: Any + created_at: float + updated_at: float + disabled: bool + + class DataSourceApiKeyAuthBinding(TypeBase): __tablename__ = "data_source_api_key_auth_bindings" __table_args__ = ( @@ -65,8 +77,8 @@ class DataSourceApiKeyAuthBinding(TypeBase): ) disabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=True, server_default=sa.text("false"), default=False) - def to_dict(self): - return { + def to_dict(self) -> DataSourceApiKeyAuthBindingDict: + result: DataSourceApiKeyAuthBindingDict = { "id": self.id, "tenant_id": self.tenant_id, "category": self.category, @@ -76,3 +88,4 @@ class DataSourceApiKeyAuthBinding(TypeBase): "updated_at": self.updated_at.timestamp(), "disabled": self.disabled, } + return result diff --git a/api/models/types.py b/api/models/types.py index 9ab694759f..4f35c31a27 100644 --- a/api/models/types.py +++ b/api/models/types.py @@ -1,6 +1,6 @@ import enum import uuid -from typing import Any +from typing import Any, cast import sqlalchemy as sa from sqlalchemy import CHAR, TEXT, VARCHAR, LargeBinary, TypeDecorator @@ -103,10 +103,14 @@ class AdjustedJSON(TypeDecorator[dict | list | None]): else: return dialect.type_descriptor(sa.JSON()) - def process_bind_param(self, value: dict | list | None, dialect: Dialect) -> dict | list | None: + def process_bind_param( + self, value: dict[str, Any] | list[Any] | None, dialect: Dialect + ) -> dict[str, Any] | list[Any] | None: return value - def process_result_value(self, value: dict | list | None, dialect: Dialect) -> dict | list | None: + def process_result_value( + self, value: dict[str, Any] | list[Any] | None, dialect: Dialect + ) -> dict[str, Any] | list[Any] | None: return value @@ -143,8 +147,14 @@ class EnumText[T: enum.StrEnum](TypeDecorator[T | None]): def process_result_value(self, value: str | None, dialect: Dialect) -> T | None: if value is None or value == "": return None - # Type annotation guarantees value is str at this point - return self._enum_class(value) + try: + # Type annotation guarantees value is str at this point + return self._enum_class(value) + except ValueError: + value_of = getattr(self._enum_class, "value_of", None) + if callable(value_of): + return cast(T, value_of(value)) + raise def compare_values(self, x: T | None, y: T | None) -> bool: if x is None or y is None: diff --git a/api/models/utils/file_input_compat.py b/api/models/utils/file_input_compat.py index f71583c1cd..8b767779ce 100644 --- a/api/models/utils/file_input_compat.py +++ b/api/models/utils/file_input_compat.py @@ -66,12 +66,15 @@ def build_file_from_stored_mapping( record_id = resolve_file_record_id(mapping) transfer_method = FileTransferMethod.value_of(mapping["transfer_method"]) - if transfer_method == FileTransferMethod.TOOL_FILE and record_id: - mapping["tool_file_id"] = record_id - elif transfer_method in [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL] and record_id: - mapping["upload_file_id"] = record_id - elif transfer_method == FileTransferMethod.DATASOURCE_FILE and record_id: - mapping["datasource_file_id"] = record_id + match transfer_method: + case FileTransferMethod.TOOL_FILE if record_id: + mapping["tool_file_id"] = record_id + case FileTransferMethod.LOCAL_FILE | FileTransferMethod.REMOTE_URL if record_id: + mapping["upload_file_id"] = record_id + case FileTransferMethod.DATASOURCE_FILE if record_id: + mapping["datasource_file_id"] = record_id + case _: + pass if transfer_method == FileTransferMethod.REMOTE_URL and record_id is None: remote_url = mapping.get("remote_url") diff --git a/api/models/workflow.py b/api/models/workflow.py index 1063016370..63abf8c3b6 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -4,7 +4,7 @@ import logging from collections.abc import Generator, Mapping, Sequence from datetime import datetime from enum import StrEnum -from typing import TYPE_CHECKING, Any, Optional, TypedDict, Union, cast +from typing import TYPE_CHECKING, Any, Optional, TypedDict, cast from uuid import uuid4 import sqlalchemy as sa @@ -61,7 +61,7 @@ from factories import variable_factory from libs import helper from .account import Account -from .base import Base, DefaultFieldsMixin, TypeBase +from .base import Base, DefaultFieldsDCMixin, TypeBase from .engine import db from .enums import CreatorUserRole, DraftVariableType, ExecutionOffLoadType, WorkflowRunTriggeredFrom from .types import EnumText, LongText, StringUUID @@ -121,7 +121,7 @@ class WorkflowType(StrEnum): raise ValueError(f"invalid workflow type value {value}") @classmethod - def from_app_mode(cls, app_mode: Union[str, "AppMode"]) -> "WorkflowType": + def from_app_mode(cls, app_mode: "str | AppMode") -> "WorkflowType": """ Get workflow type from app mode. @@ -671,6 +671,29 @@ class Workflow(Base): # bug return str(d) +class WorkflowRunDict(TypedDict): + id: str + tenant_id: str + app_id: str + workflow_id: str + type: WorkflowType + triggered_from: WorkflowRunTriggeredFrom + version: str + graph: Mapping[str, Any] + inputs: Mapping[str, Any] + status: WorkflowExecutionStatus + outputs: Mapping[str, Any] + error: str | None + elapsed_time: float + total_tokens: int + total_steps: int + created_by_role: CreatorUserRole + created_by: str + created_at: datetime + finished_at: datetime | None + exceptions_count: int + + class WorkflowRun(Base): """ Workflow Run @@ -742,8 +765,8 @@ class WorkflowRun(Base): exceptions_count: Mapped[int] = mapped_column(sa.Integer, server_default=sa.text("0"), nullable=True) pause: Mapped[Optional["WorkflowPause"]] = orm.relationship( - "WorkflowPause", - primaryjoin="WorkflowRun.id == foreign(WorkflowPause.workflow_run_id)", + lambda: WorkflowPause, + primaryjoin=lambda: WorkflowRun.id == orm.foreign(WorkflowPause.workflow_run_id), uselist=False, # require explicit preloading. lazy="raise", @@ -790,29 +813,29 @@ class WorkflowRun(Base): def workflow(self): return db.session.scalar(select(Workflow).where(Workflow.id == self.workflow_id)) - def to_dict(self): - return { - "id": self.id, - "tenant_id": self.tenant_id, - "app_id": self.app_id, - "workflow_id": self.workflow_id, - "type": self.type, - "triggered_from": self.triggered_from, - "version": self.version, - "graph": self.graph_dict, - "inputs": self.inputs_dict, - "status": self.status, - "outputs": self.outputs_dict, - "error": self.error, - "elapsed_time": self.elapsed_time, - "total_tokens": self.total_tokens, - "total_steps": self.total_steps, - "created_by_role": self.created_by_role, - "created_by": self.created_by, - "created_at": self.created_at, - "finished_at": self.finished_at, - "exceptions_count": self.exceptions_count, - } + def to_dict(self) -> WorkflowRunDict: + return WorkflowRunDict( + id=self.id, + tenant_id=self.tenant_id, + app_id=self.app_id, + workflow_id=self.workflow_id, + type=self.type, + triggered_from=self.triggered_from, + version=self.version, + graph=self.graph_dict, + inputs=self.inputs_dict, + status=self.status, + outputs=self.outputs_dict, + error=self.error, + elapsed_time=self.elapsed_time, + total_tokens=self.total_tokens, + total_steps=self.total_steps, + created_by_role=self.created_by_role, + created_by=self.created_by, + created_at=self.created_at, + finished_at=self.finished_at, + exceptions_count=self.exceptions_count, + ) @classmethod def from_dict(cls, data: dict[str, Any]) -> "WorkflowRun": @@ -1051,7 +1074,7 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo ) return extras - def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> Optional["WorkflowNodeExecutionOffload"]: + def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> "WorkflowNodeExecutionOffload | None": return next(iter([i for i in self.offload_data if i.type_ == type_]), None) @property @@ -1196,6 +1219,18 @@ class WorkflowAppLogCreatedFrom(StrEnum): raise ValueError(f"invalid workflow app log created from value {value}") +class WorkflowAppLogDict(TypedDict): + id: str + tenant_id: str + app_id: str + workflow_id: str + workflow_run_id: str + created_from: WorkflowAppLogCreatedFrom + created_by_role: CreatorUserRole + created_by: str + created_at: datetime + + class WorkflowAppLog(TypeBase): """ Workflow App execution log, excluding workflow debugging records. @@ -1273,8 +1308,8 @@ class WorkflowAppLog(TypeBase): created_by_role = CreatorUserRole(self.created_by_role) return db.session.get(EndUser, self.created_by) if created_by_role == CreatorUserRole.END_USER else None - def to_dict(self): - return { + def to_dict(self) -> WorkflowAppLogDict: + result: WorkflowAppLogDict = { "id": self.id, "tenant_id": self.tenant_id, "app_id": self.app_id, @@ -1285,6 +1320,7 @@ class WorkflowAppLog(TypeBase): "created_by": self.created_by, "created_at": self.created_at, } + return result class WorkflowArchiveLog(TypeBase): @@ -1625,21 +1661,22 @@ class WorkflowDraftVariable(Base): # Rebuild them through the file factory so tenant ownership, signed URLs, # and storage-backed metadata come from canonical records instead of the # serialized JSON blob. - if segment_type == SegmentType.FILE: - if isinstance(value, File): - return build_segment_with_type(segment_type, value) - elif isinstance(value, dict): - file = self._rebuild_file_types(value) - return build_segment_with_type(segment_type, file) - else: - raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}") - if segment_type == SegmentType.ARRAY_FILE: - if not isinstance(value, list): - raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}") - file_list = self._rebuild_file_types(value) - return build_segment_with_type(segment_type=segment_type, value=file_list) - - return build_segment_with_type(segment_type=segment_type, value=value) + match segment_type: + case SegmentType.FILE: + if isinstance(value, File): + return build_segment_with_type(segment_type, value) + elif isinstance(value, dict): + file = self._rebuild_file_types(value) + return build_segment_with_type(segment_type, file) + else: + raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}") + case SegmentType.ARRAY_FILE: + if not isinstance(value, list): + raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}") + file_list = self._rebuild_file_types(value) + return build_segment_with_type(segment_type=segment_type, value=file_list) + case _: + return build_segment_with_type(segment_type=segment_type, value=value) @staticmethod def rebuild_file_types(value: Any): @@ -1672,21 +1709,22 @@ class WorkflowDraftVariable(Base): # Extends `variable_factory.build_segment_with_type` functionality by # reconstructing `FileSegment`` or `ArrayFileSegment`` objects from # their serialized dictionary or list representations, respectively. - if segment_type == SegmentType.FILE: - if isinstance(value, File): - return build_segment_with_type(segment_type, value) - elif isinstance(value, dict): - file = cls.rebuild_file_types(value) - return build_segment_with_type(segment_type, file) - else: - raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}") - if segment_type == SegmentType.ARRAY_FILE: - if not isinstance(value, list): - raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}") - file_list = cls.rebuild_file_types(value) - return build_segment_with_type(segment_type=segment_type, value=file_list) - - return build_segment_with_type(segment_type=segment_type, value=value) + match segment_type: + case SegmentType.FILE: + if isinstance(value, File): + return build_segment_with_type(segment_type, value) + elif isinstance(value, dict): + file = cls.rebuild_file_types(value) + return build_segment_with_type(segment_type, file) + else: + raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}") + case SegmentType.ARRAY_FILE: + if not isinstance(value, list): + raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}") + file_list = cls.rebuild_file_types(value) + return build_segment_with_type(segment_type=segment_type, value=file_list) + case _: + return build_segment_with_type(segment_type=segment_type, value=value) def get_value(self) -> Segment: """Decode the serialized value into its corresponding `Segment` object. @@ -1939,7 +1977,7 @@ def is_system_variable_editable(name: str) -> bool: return name in _EDITABLE_SYSTEM_VARIABLE -class WorkflowPause(DefaultFieldsMixin, Base): +class WorkflowPause(DefaultFieldsDCMixin, TypeBase): """ WorkflowPause records the paused state and related metadata for a specific workflow run. @@ -1978,6 +2016,11 @@ class WorkflowPause(DefaultFieldsMixin, Base): nullable=False, ) + # state_object_key stores the object key referencing the serialized runtime state + # of the `GraphEngine`. This object captures the complete execution context of the + # workflow at the moment it was paused, enabling accurate resumption. + state_object_key: Mapped[str] = mapped_column(String(length=255), nullable=False) + # `resumed_at` records the timestamp when the suspended workflow was resumed. # It is set to `NULL` if the workflow has not been resumed. # @@ -1986,25 +2029,23 @@ class WorkflowPause(DefaultFieldsMixin, Base): resumed_at: Mapped[datetime | None] = mapped_column( sa.DateTime, nullable=True, + default=None, ) - # state_object_key stores the object key referencing the serialized runtime state - # of the `GraphEngine`. This object captures the complete execution context of the - # workflow at the moment it was paused, enabling accurate resumption. - state_object_key: Mapped[str] = mapped_column(String(length=255), nullable=False) - - # Relationship to WorkflowRun + # Relationship to WorkflowRun (uses lambda to resolve across Base/TypeBase registries) workflow_run: Mapped["WorkflowRun"] = orm.relationship( + lambda: WorkflowRun, foreign_keys=[workflow_run_id], # require explicit preloading. lazy="raise", uselist=False, - primaryjoin="WorkflowPause.workflow_run_id == WorkflowRun.id", + primaryjoin=lambda: WorkflowPause.workflow_run_id == WorkflowRun.id, back_populates="pause", + init=False, ) -class WorkflowPauseReason(DefaultFieldsMixin, Base): +class WorkflowPauseReason(DefaultFieldsDCMixin, TypeBase): __tablename__ = "workflow_pause_reasons" # `pause_id` represents the identifier of the pause, @@ -2047,16 +2088,20 @@ class WorkflowPauseReason(DefaultFieldsMixin, Base): lazy="raise", uselist=False, primaryjoin="WorkflowPauseReason.pause_id == WorkflowPause.id", + init=False, ) @classmethod - def from_entity(cls, pause_reason: PauseReason) -> "WorkflowPauseReason": + def from_entity(cls, *, pause_id: str, pause_reason: PauseReason) -> "WorkflowPauseReason": if isinstance(pause_reason, HumanInputRequired): return cls( - type_=PauseReasonType.HUMAN_INPUT_REQUIRED, form_id=pause_reason.form_id, node_id=pause_reason.node_id + pause_id=pause_id, + type_=PauseReasonType.HUMAN_INPUT_REQUIRED, + form_id=pause_reason.form_id, + node_id=pause_reason.node_id, ) elif isinstance(pause_reason, SchedulingPause): - return cls(type_=PauseReasonType.SCHEDULED_PAUSE, message=pause_reason.message, node_id="") + return cls(pause_id=pause_id, type_=PauseReasonType.SCHEDULED_PAUSE, message=pause_reason.message) else: raise AssertionError(f"Unknown pause reason type: {pause_reason}") diff --git a/api/providers/README.md b/api/providers/README.md new file mode 100644 index 0000000000..a00ec8bc52 --- /dev/null +++ b/api/providers/README.md @@ -0,0 +1,12 @@ +# Providers + +This directory holds **optional workspace packages** that plug into Dify’s API core. Providers are responsible for implementing the interfaces and registering themselves to the API core. Provider mechanism allows building the software with selected set of providers so as to enhance the security and flexibility of distributions. + +## Developing Providers + +- [VDB Providers](vdb/README.md) + +## Tests + +Provider tests often live next to the package, e.g. `providers///tests/unit_tests/`. Shared fixtures may live under `providers/` (e.g. `conftest.py`). + diff --git a/api/providers/vdb/README.md b/api/providers/vdb/README.md new file mode 100644 index 0000000000..b5b4197f63 --- /dev/null +++ b/api/providers/vdb/README.md @@ -0,0 +1,58 @@ +# VDB providers + +This directory contains all VDB providers. + +## Architecture +1. **Core** (`api/core/rag/datasource/vdb/`) defines the contracts and loads plugins. +2. **Each provider** (`api/providers/vdb//`) implements those contracts and registers an entry point. +3. At runtime, **`importlib.metadata.entry_points`** resolves the backend name (e.g. `pgvector`) to a factory class. The registry caches loaded classes (see `vector_backend_registry.py`). + +### Interfaces + +| Piece | Role | +|--------|----------| +| `AbstractVectorFactory` | You subclass this. Implement `init_vector(dataset, attributes, embeddings) -> BaseVector`. Optionally use `gen_index_struct_dict()` for new datasets. | +| `BaseVector` | Your store class subclasses this: `create`, `add_texts`, `search_by_vector`, `delete`, etc. | +| `VectorType` | `StrEnum` of supported backend **string ids**. Add a member when you introduce a new backend that should be selectable like existing ones. | +| Discovery | Loads `dify.vector_backends` entry points and caches `get_vector_factory_class(vector_type)`. | + +The high-level caller is `Vector` in `vector_factory.py`: it reads the configured or dataset-specific vector type, calls `get_vector_factory_class`, instantiates the factory, and uses the returned `BaseVector` implementation. + +### Entry point name must match the vector type string + +Entry points are registered under the group **`dify.vector_backends`**. The **entry point name** (left-hand side) must be exactly the string used as `vector_type` everywhere else—typically the **`VectorType` enum value** (e.g. `PGVECTOR = "pgvector"` → entry point name `pgvector`; `TIDB_ON_QDRANT = "tidb_on_qdrant"` → `tidb_on_qdrant`). + +In `pyproject.toml`: + +```toml +[project.entry-points."dify.vector_backends"] +pgvector = "dify_vdb_pgvector.pgvector:PGVectorFactory" +``` + +The value is **`module:attribute`**: a importable module path and the class implementing `AbstractVectorFactory`. + +### How registration works + +1. On first use, `get_vector_factory_class(vector_type)` looks up `vector_type` in a process cache. +2. If missing, it scans **`entry_points().select(group="dify.vector_backends")`** for an entry whose **`name` equals `vector_type`**. +3. It loads that entry (`ep.load()`), which must return the **factory class** (not an instance). +4. There is an optional internal map `_BUILTIN_VECTOR_FACTORY_TARGETS` for non-distribution builtins; **normal VDB plugins use entry points only**. + +After you change a provider’s `pyproject.toml` (entry points or dependencies), run **`uv sync`** in `api/` so the installed environment’s dist-info matches the project metadata. + +### Package layout (VDB) + +Each backend usually follows: + +- `api/providers/vdb//pyproject.toml` — project name `dify-vdb-`, dependencies, entry points. +- `api/providers/vdb//src/dify_vdb_/` — implementation (e.g. `PGVector`, `PGVectorFactory`). + +See `vdb/pgvector/` as a reference implementation. + +### Wiring a new backend into the API workspace + +The API uses a **uv workspace** (`api/pyproject.toml`): + +1. **`[tool.uv.workspace]`** — `members = ["providers/vdb/*"]` already includes every subdirectory under `vdb/`; new folders there are workspace members. +2. **`[tool.uv.sources]`** — add a line for your package: `dify-vdb-mine = { workspace = true }`. +3. **`[project.optional-dependencies]`** — add a group such as `vdb-mine = ["dify-vdb-mine"]`, and list `dify-vdb-mine` under `vdb-all` if it should install with the default bundle. \ No newline at end of file diff --git a/api/providers/vdb/conftest.py b/api/providers/vdb/conftest.py new file mode 100644 index 0000000000..c4b1cdef29 --- /dev/null +++ b/api/providers/vdb/conftest.py @@ -0,0 +1,22 @@ +from unittest.mock import MagicMock + +import pytest + +from extensions import ext_redis + + +@pytest.fixture(autouse=True) +def _init_mock_redis(): + """Ensure redis_client has a backing client so __getattr__ never raises.""" + if ext_redis.redis_client._client is None: + ext_redis.redis_client.initialize(MagicMock()) + + +@pytest.fixture +def setup_mock_redis(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(ext_redis.redis_client, "get", MagicMock(return_value=None)) + monkeypatch.setattr(ext_redis.redis_client, "set", MagicMock(return_value=None)) + mock_redis_lock = MagicMock() + mock_redis_lock.__enter__ = MagicMock() + mock_redis_lock.__exit__ = MagicMock() + monkeypatch.setattr(ext_redis.redis_client, "lock", mock_redis_lock) diff --git a/api/providers/vdb/vdb-alibabacloud-mysql/pyproject.toml b/api/providers/vdb/vdb-alibabacloud-mysql/pyproject.toml new file mode 100644 index 0000000000..bbc0e06ffa --- /dev/null +++ b/api/providers/vdb/vdb-alibabacloud-mysql/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "dify-vdb-alibabacloud-mysql" +version = "0.0.1" +dependencies = [ + "mysql-connector-python>=9.3.0", +] +description = "Dify vector store backend (dify-vdb-alibabacloud-mysql)." + +[project.entry-points."dify.vector_backends"] +alibabacloud_mysql = "dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector:AlibabaCloudMySQLVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/alibabacloud_mysql/__init__.py b/api/providers/vdb/vdb-alibabacloud-mysql/src/dify_vdb_alibabacloud_mysql/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/alibabacloud_mysql/__init__.py rename to api/providers/vdb/vdb-alibabacloud-mysql/src/dify_vdb_alibabacloud_mysql/__init__.py diff --git a/api/core/rag/datasource/vdb/alibabacloud_mysql/alibabacloud_mysql_vector.py b/api/providers/vdb/vdb-alibabacloud-mysql/src/dify_vdb_alibabacloud_mysql/alibabacloud_mysql_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/alibabacloud_mysql/alibabacloud_mysql_vector.py rename to api/providers/vdb/vdb-alibabacloud-mysql/src/dify_vdb_alibabacloud_mysql/alibabacloud_mysql_vector.py index 6e76827a42..37ffd11063 100644 --- a/api/core/rag/datasource/vdb/alibabacloud_mysql/alibabacloud_mysql_vector.py +++ b/api/providers/vdb/vdb-alibabacloud-mysql/src/dify_vdb_alibabacloud_mysql/alibabacloud_mysql_vector.py @@ -35,7 +35,7 @@ class AlibabaCloudMySQLVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values.get("host"): raise ValueError("config ALIBABACLOUD_MYSQL_HOST is required") if not values.get("port"): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/alibabacloud_mysql/test_alibabacloud_mysql_factory.py b/api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py similarity index 94% rename from api/tests/unit_tests/core/rag/datasource/vdb/alibabacloud_mysql/test_alibabacloud_mysql_factory.py rename to api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py index e063a49f22..a907f918c3 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/alibabacloud_mysql/test_alibabacloud_mysql_factory.py +++ b/api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py @@ -1,10 +1,9 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector as alibaba_module import pytest - -import core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector as alibaba_module -from core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector import AlibabaCloudMySQLVectorFactory +from dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector import AlibabaCloudMySQLVectorFactory def test_validate_distance_function_accepts_supported_values(): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/alibabacloud_mysql/test_alibabacloud_mysql_vector.py b/api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_vector.py similarity index 87% rename from api/tests/unit_tests/core/rag/datasource/vdb/alibabacloud_mysql/test_alibabacloud_mysql_vector.py rename to api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_vector.py index 8ccd739e64..54eeb78ca9 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/alibabacloud_mysql/test_alibabacloud_mysql_vector.py +++ b/api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_vector.py @@ -3,11 +3,11 @@ import unittest from unittest.mock import MagicMock, patch import pytest - -from core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector import ( +from dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector import ( AlibabaCloudMySQLVector, AlibabaCloudMySQLVectorConfig, ) + from core.rag.models.document import Document try: @@ -49,9 +49,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): # Sample embeddings self.sample_embeddings = [[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]] - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_init(self, mock_pool_class): """Test AlibabaCloudMySQLVector initialization.""" # Mock the connection pool @@ -76,10 +74,8 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert alibabacloud_mysql_vector.distance_function == "cosine" assert alibabacloud_mysql_vector.pool is not None - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) - @patch("core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.redis_client") + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.redis_client") def test_create_collection(self, mock_redis, mock_pool_class): """Test collection creation.""" # Mock Redis operations @@ -110,9 +106,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert mock_cursor.execute.call_count >= 3 # CREATE TABLE + 2 indexes mock_redis.set.assert_called_once() - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_vector_support_check_success(self, mock_pool_class): """Test successful vector support check.""" # Mock the connection pool @@ -129,9 +123,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): vector_store = AlibabaCloudMySQLVector(self.collection_name, self.config) assert vector_store is not None - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_vector_support_check_failure(self, mock_pool_class): """Test vector support check failure.""" # Mock the connection pool @@ -149,9 +141,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert "RDS MySQL Vector functions are not available" in str(context.value) - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_vector_support_check_function_error(self, mock_pool_class): """Test vector support check with function not found error.""" # Mock the connection pool @@ -170,10 +160,8 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert "RDS MySQL Vector functions are not available" in str(context.value) - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) - @patch("core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.redis_client") + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.redis_client") def test_create_documents(self, mock_redis, mock_pool_class): """Test creating documents with embeddings.""" # Setup mocks @@ -186,9 +174,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert "doc1" in result assert "doc2" in result - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_add_texts(self, mock_pool_class): """Test adding texts to the vector store.""" # Mock the connection pool @@ -207,9 +193,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert len(result) == 2 mock_cursor.executemany.assert_called_once() - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_text_exists(self, mock_pool_class): """Test checking if text exists.""" # Mock the connection pool @@ -236,9 +220,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert "SELECT id FROM" in last_call[0][0] assert last_call[0][1] == ("doc1",) - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_text_not_exists(self, mock_pool_class): """Test checking if text does not exist.""" # Mock the connection pool @@ -260,9 +242,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert not exists - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_get_by_ids(self, mock_pool_class): """Test getting documents by IDs.""" # Mock the connection pool @@ -288,9 +268,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert docs[0].page_content == "Test document 1" assert docs[1].page_content == "Test document 2" - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_get_by_ids_empty_list(self, mock_pool_class): """Test getting documents with empty ID list.""" # Mock the connection pool @@ -308,9 +286,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert len(docs) == 0 - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_delete_by_ids(self, mock_pool_class): """Test deleting documents by IDs.""" # Mock the connection pool @@ -334,9 +310,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert "DELETE FROM" in delete_call[0][0] assert delete_call[0][1] == ["doc1", "doc2"] - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_delete_by_ids_empty_list(self, mock_pool_class): """Test deleting with empty ID list.""" # Mock the connection pool @@ -357,9 +331,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): delete_calls = [call for call in execute_calls if "DELETE" in str(call)] assert len(delete_calls) == 0 - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_delete_by_ids_table_not_exists(self, mock_pool_class): """Test deleting when table doesn't exist.""" # Mock the connection pool @@ -384,9 +356,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): # Should not raise an exception vector_store.delete_by_ids(["doc1"]) - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_delete_by_metadata_field(self, mock_pool_class): """Test deleting documents by metadata field.""" # Mock the connection pool @@ -410,9 +380,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert "JSON_UNQUOTE(JSON_EXTRACT(meta" in delete_call[0][0] assert delete_call[0][1] == ("$.document_id", "dataset1") - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_search_by_vector_cosine(self, mock_pool_class): """Test vector search with cosine distance.""" # Mock the connection pool @@ -437,9 +405,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert abs(docs[0].metadata["score"] - 0.9) < 0.1 # 1 - 0.1 = 0.9 assert docs[0].metadata["distance"] == 0.1 - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_search_by_vector_euclidean(self, mock_pool_class): """Test vector search with euclidean distance.""" config = AlibabaCloudMySQLVectorConfig( @@ -472,9 +438,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert len(docs) == 1 assert abs(docs[0].metadata["score"] - 1.0 / 3.0) < 0.01 # 1/(1+2) = 1/3 - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_search_by_vector_with_filter(self, mock_pool_class): """Test vector search with document ID filter.""" # Mock the connection pool @@ -499,9 +463,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): search_call = search_calls[0] assert "WHERE JSON_UNQUOTE" in search_call[0][0] - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_search_by_vector_with_score_threshold(self, mock_pool_class): """Test vector search with score threshold.""" # Mock the connection pool @@ -536,9 +498,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert len(docs) == 1 assert docs[0].page_content == "High similarity document" - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_search_by_vector_invalid_top_k(self, mock_pool_class): """Test vector search with invalid top_k.""" # Mock the connection pool @@ -560,9 +520,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): with pytest.raises(ValueError): vector_store.search_by_vector(query_vector, top_k="invalid") - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_search_by_full_text(self, mock_pool_class): """Test full-text search.""" # Mock the connection pool @@ -591,9 +549,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): assert docs[0].page_content == "This document contains machine learning content" assert docs[0].metadata["score"] == 1.5 - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_search_by_full_text_with_filter(self, mock_pool_class): """Test full-text search with document ID filter.""" # Mock the connection pool @@ -617,9 +573,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): search_call = search_calls[0] assert "AND JSON_UNQUOTE" in search_call[0][0] - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_search_by_full_text_invalid_top_k(self, mock_pool_class): """Test full-text search with invalid top_k.""" # Mock the connection pool @@ -640,9 +594,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): with pytest.raises(ValueError): vector_store.search_by_full_text("test", top_k="invalid") - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_delete_collection(self, mock_pool_class): """Test deleting the entire collection.""" # Mock the connection pool @@ -665,9 +617,7 @@ class TestAlibabaCloudMySQLVector(unittest.TestCase): drop_call = drop_calls[0] assert f"DROP TABLE IF EXISTS {self.collection_name.lower()}" in drop_call[0][0] - @patch( - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool" - ) + @patch("dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector.mysql.connector.pooling.MySQLConnectionPool") def test_unsupported_distance_function(self, mock_pool_class): """Test that Pydantic validation rejects unsupported distance functions.""" # Test that creating config with unsupported distance function raises ValidationError diff --git a/api/providers/vdb/vdb-analyticdb/pyproject.toml b/api/providers/vdb/vdb-analyticdb/pyproject.toml new file mode 100644 index 0000000000..af5def3061 --- /dev/null +++ b/api/providers/vdb/vdb-analyticdb/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "dify-vdb-analyticdb" +version = "0.0.1" +dependencies = [ + "alibabacloud_gpdb20160503~=5.2.0", + "alibabacloud_tea_openapi~=0.4.3", + "clickhouse-connect~=0.15.0", +] +description = "Dify vector store backend (dify-vdb-analyticdb)." + +[project.entry-points."dify.vector_backends"] +analyticdb = "dify_vdb_analyticdb.analyticdb_vector:AnalyticdbVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/analyticdb/__init__.py b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/analyticdb/__init__.py rename to api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/__init__.py diff --git a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector.py b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector.py similarity index 93% rename from api/core/rag/datasource/vdb/analyticdb/analyticdb_vector.py rename to api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector.py index ddb549ba9d..e56bb74ba3 100644 --- a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector.py +++ b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector.py @@ -2,16 +2,16 @@ import json from typing import Any from configs import dify_config -from core.rag.datasource.vdb.analyticdb.analyticdb_vector_openapi import ( - AnalyticdbVectorOpenAPI, - AnalyticdbVectorOpenAPIConfig, -) -from core.rag.datasource.vdb.analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySql, AnalyticdbVectorBySqlConfig from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType from core.rag.embedding.embedding_base import Embeddings from core.rag.models.document import Document +from dify_vdb_analyticdb.analyticdb_vector_openapi import ( + AnalyticdbVectorOpenAPI, + AnalyticdbVectorOpenAPIConfig, +) +from dify_vdb_analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySql, AnalyticdbVectorBySqlConfig from models.dataset import Dataset @@ -37,11 +37,12 @@ class AnalyticdbVector(BaseVector): def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs): dimension = len(embeddings[0]) - self.analyticdb_vector._create_collection_if_not_exists(dimension) + self.analyticdb_vector.create_collection_if_not_exists(dimension) self.analyticdb_vector.add_texts(texts, embeddings) - def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): + def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs) -> list[str]: self.analyticdb_vector.add_texts(documents, embeddings) + return [] def text_exists(self, id: str) -> bool: return self.analyticdb_vector.text_exists(id) diff --git a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_openapi.py similarity index 99% rename from api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py rename to api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_openapi.py index fb6eaa370a..f13d9c0817 100644 --- a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py +++ b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_openapi.py @@ -34,7 +34,7 @@ class AnalyticdbVectorOpenAPIConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["access_key_id"]: raise ValueError("config ANALYTICDB_KEY_ID is required") if not values["access_key_secret"]: @@ -123,7 +123,7 @@ class AnalyticdbVectorOpenAPI: else: raise ValueError(f"failed to create namespace {self.config.namespace}: {e}") - def _create_collection_if_not_exists(self, embedding_dimension: int): + def create_collection_if_not_exists(self, embedding_dimension: int): from alibabacloud_gpdb20160503 import models as gpdb_20160503_models from Tea.exceptions import TeaException diff --git a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_sql.py b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_sql.py similarity index 98% rename from api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_sql.py rename to api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_sql.py index 12126f32d6..b2908ebdae 100644 --- a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_sql.py +++ b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_sql.py @@ -1,5 +1,6 @@ import json import uuid +from collections.abc import Iterator from contextlib import contextmanager from typing import Any @@ -23,7 +24,7 @@ class AnalyticdbVectorBySqlConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config ANALYTICDB_HOST is required") if not values["port"]: @@ -74,7 +75,7 @@ class AnalyticdbVectorBySql: ) @contextmanager - def _get_cursor(self): + def _get_cursor(self) -> Iterator[Any]: assert self.pool is not None, "Connection pool is not initialized" conn = self.pool.getconn() cur = conn.cursor() @@ -130,7 +131,7 @@ class AnalyticdbVectorBySql: ) cur.execute(f"CREATE SCHEMA IF NOT EXISTS {self.config.namespace}") - def _create_collection_if_not_exists(self, embedding_dimension: int): + def create_collection_if_not_exists(self, embedding_dimension: int): cache_key = f"vector_indexing_{self._collection_name}" lock_name = f"{cache_key}_lock" with redis_client.lock(lock_name, timeout=20): diff --git a/api/tests/integration_tests/vdb/analyticdb/test_analyticdb.py b/api/providers/vdb/vdb-analyticdb/tests/integration_tests/test_analyticdb.py similarity index 79% rename from api/tests/integration_tests/vdb/analyticdb/test_analyticdb.py rename to api/providers/vdb/vdb-analyticdb/tests/integration_tests/test_analyticdb.py index 0981523809..2bb413dcc1 100644 --- a/api/tests/integration_tests/vdb/analyticdb/test_analyticdb.py +++ b/api/providers/vdb/vdb-analyticdb/tests/integration_tests/test_analyticdb.py @@ -1,9 +1,8 @@ -from core.rag.datasource.vdb.analyticdb.analyticdb_vector import AnalyticdbVector -from core.rag.datasource.vdb.analyticdb.analyticdb_vector_openapi import AnalyticdbVectorOpenAPIConfig -from core.rag.datasource.vdb.analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySqlConfig -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest +from dify_vdb_analyticdb.analyticdb_vector import AnalyticdbVector +from dify_vdb_analyticdb.analyticdb_vector_openapi import AnalyticdbVectorOpenAPIConfig +from dify_vdb_analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySqlConfig -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest class AnalyticdbVectorTest(AbstractVectorTest): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector.py b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector.py similarity index 92% rename from api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector.py rename to api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector.py index 545565cdf4..d1d471761d 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector.py +++ b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector.py @@ -1,12 +1,12 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import dify_vdb_analyticdb.analyticdb_vector as analyticdb_module import pytest +from dify_vdb_analyticdb.analyticdb_vector import AnalyticdbVector, AnalyticdbVectorFactory +from dify_vdb_analyticdb.analyticdb_vector_openapi import AnalyticdbVectorOpenAPIConfig +from dify_vdb_analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySqlConfig -import core.rag.datasource.vdb.analyticdb.analyticdb_vector as analyticdb_module -from core.rag.datasource.vdb.analyticdb.analyticdb_vector import AnalyticdbVector, AnalyticdbVectorFactory -from core.rag.datasource.vdb.analyticdb.analyticdb_vector_openapi import AnalyticdbVectorOpenAPIConfig -from core.rag.datasource.vdb.analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySqlConfig from core.rag.models.document import Document @@ -71,7 +71,7 @@ def test_vector_methods_delegate_to_underlying_implementation(): assert vector.search_by_full_text("hello", top_k=2) == runner.search_by_full_text.return_value vector.delete() - runner._create_collection_if_not_exists.assert_called_once_with(2) + runner.create_collection_if_not_exists.assert_called_once_with(2) runner.add_texts.assert_any_call(texts, [[0.1, 0.2]]) runner.delete_by_ids.assert_called_once_with(["d1"]) runner.delete_by_metadata_field.assert_called_once_with("document_id", "doc-1") diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_openapi.py b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_openapi.py similarity index 97% rename from api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_openapi.py rename to api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_openapi.py index 45777774d0..d2d735ae3e 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_openapi.py +++ b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_openapi.py @@ -4,13 +4,13 @@ import types from types import SimpleNamespace from unittest.mock import MagicMock +import dify_vdb_analyticdb.analyticdb_vector_openapi as openapi_module import pytest - -import core.rag.datasource.vdb.analyticdb.analyticdb_vector_openapi as openapi_module -from core.rag.datasource.vdb.analyticdb.analyticdb_vector_openapi import ( +from dify_vdb_analyticdb.analyticdb_vector_openapi import ( AnalyticdbVectorOpenAPI, AnalyticdbVectorOpenAPIConfig, ) + from core.rag.models.document import Document @@ -249,7 +249,7 @@ def test_create_collection_if_not_exists_creates_when_missing(monkeypatch): vector._client = MagicMock() vector._client.describe_collection.side_effect = stubs.TeaException(statusCode=404) - vector._create_collection_if_not_exists(embedding_dimension=1024) + vector.create_collection_if_not_exists(embedding_dimension=1024) vector._client.create_collection.assert_called_once() openapi_module.redis_client.set.assert_called_once() @@ -268,7 +268,7 @@ def test_create_collection_if_not_exists_skips_when_cached(monkeypatch): vector.config = _config() vector._client = MagicMock() - vector._create_collection_if_not_exists(embedding_dimension=1024) + vector.create_collection_if_not_exists(embedding_dimension=1024) vector._client.describe_collection.assert_not_called() vector._client.create_collection.assert_not_called() @@ -290,7 +290,7 @@ def test_create_collection_if_not_exists_raises_on_non_404_errors(monkeypatch): vector._client.describe_collection.side_effect = stubs.TeaException(statusCode=500) with pytest.raises(ValueError, match="failed to create collection collection_1"): - vector._create_collection_if_not_exists(embedding_dimension=512) + vector.create_collection_if_not_exists(embedding_dimension=512) def test_openapi_add_delete_and_search_methods(monkeypatch): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_sql.py b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_sql.py similarity index 98% rename from api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_sql.py rename to api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_sql.py index 8f1206696b..49a2ae72d0 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/analyticdb/test_analyticdb_vector_sql.py +++ b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_sql.py @@ -2,14 +2,14 @@ from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import MagicMock +import dify_vdb_analyticdb.analyticdb_vector_sql as sql_module import psycopg2.errors import pytest - -import core.rag.datasource.vdb.analyticdb.analyticdb_vector_sql as sql_module -from core.rag.datasource.vdb.analyticdb.analyticdb_vector_sql import ( +from dify_vdb_analyticdb.analyticdb_vector_sql import ( AnalyticdbVectorBySql, AnalyticdbVectorBySqlConfig, ) + from core.rag.models.document import Document @@ -374,7 +374,7 @@ def test_create_collection_if_not_exists_creates_table_indexes_and_cache(monkeyp vector._get_cursor = _cursor_context - vector._create_collection_if_not_exists(embedding_dimension=3) + vector.create_collection_if_not_exists(embedding_dimension=3) assert any("CREATE TABLE IF NOT EXISTS dify.collection" in call.args[0] for call in cursor.execute.call_args_list) assert any("CREATE INDEX collection_embedding_idx" in call.args[0] for call in cursor.execute.call_args_list) @@ -404,7 +404,7 @@ def test_create_collection_if_not_exists_raises_for_non_existing_error(monkeypat vector._get_cursor = _cursor_context with pytest.raises(RuntimeError, match="permission denied"): - vector._create_collection_if_not_exists(embedding_dimension=3) + vector.create_collection_if_not_exists(embedding_dimension=3) def test_delete_methods_raise_when_error_is_not_missing_table(): diff --git a/api/providers/vdb/vdb-baidu/pyproject.toml b/api/providers/vdb/vdb-baidu/pyproject.toml new file mode 100644 index 0000000000..bacff08793 --- /dev/null +++ b/api/providers/vdb/vdb-baidu/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "dify-vdb-baidu" +version = "0.0.1" +dependencies = [ + "pymochow==2.4.0", +] +description = "Dify vector store backend (dify-vdb-baidu)." + +[project.entry-points."dify.vector_backends"] +baidu = "dify_vdb_baidu.baidu_vector:BaiduVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/baidu/__init__.py b/api/providers/vdb/vdb-baidu/src/dify_vdb_baidu/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/baidu/__init__.py rename to api/providers/vdb/vdb-baidu/src/dify_vdb_baidu/__init__.py diff --git a/api/core/rag/datasource/vdb/baidu/baidu_vector.py b/api/providers/vdb/vdb-baidu/src/dify_vdb_baidu/baidu_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/baidu/baidu_vector.py rename to api/providers/vdb/vdb-baidu/src/dify_vdb_baidu/baidu_vector.py index 99ab0d82f2..bdd5a42c87 100644 --- a/api/core/rag/datasource/vdb/baidu/baidu_vector.py +++ b/api/providers/vdb/vdb-baidu/src/dify_vdb_baidu/baidu_vector.py @@ -59,7 +59,7 @@ class BaiduConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["endpoint"]: raise ValueError("config BAIDU_VECTOR_DB_ENDPOINT is required") if not values["account"]: diff --git a/api/tests/integration_tests/vdb/__mock/baiduvectordb.py b/api/providers/vdb/vdb-baidu/tests/integration_tests/conftest.py similarity index 100% rename from api/tests/integration_tests/vdb/__mock/baiduvectordb.py rename to api/providers/vdb/vdb-baidu/tests/integration_tests/conftest.py diff --git a/api/tests/integration_tests/vdb/baidu/test_baidu.py b/api/providers/vdb/vdb-baidu/tests/integration_tests/test_baidu.py similarity index 73% rename from api/tests/integration_tests/vdb/baidu/test_baidu.py rename to api/providers/vdb/vdb-baidu/tests/integration_tests/test_baidu.py index 716f88af67..2c1d0e3554 100644 --- a/api/tests/integration_tests/vdb/baidu/test_baidu.py +++ b/api/providers/vdb/vdb-baidu/tests/integration_tests/test_baidu.py @@ -1,10 +1,6 @@ -from core.rag.datasource.vdb.baidu.baidu_vector import BaiduConfig, BaiduVector -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text +from dify_vdb_baidu.baidu_vector import BaiduConfig, BaiduVector -pytest_plugins = ( - "tests.integration_tests.vdb.test_vector_store", - "tests.integration_tests.vdb.__mock.baiduvectordb", -) +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest, get_example_text class BaiduVectorTest(AbstractVectorTest): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/baidu/test_baidu_vector.py b/api/providers/vdb/vdb-baidu/tests/unit_tests/test_baidu_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/baidu/test_baidu_vector.py rename to api/providers/vdb/vdb-baidu/tests/unit_tests/test_baidu_vector.py index 487d021697..851c09f47a 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/baidu/test_baidu_vector.py +++ b/api/providers/vdb/vdb-baidu/tests/unit_tests/test_baidu_vector.py @@ -124,7 +124,7 @@ def _build_fake_pymochow_modules(): def baidu_module(monkeypatch): for name, module in _build_fake_pymochow_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.baidu.baidu_vector as module + import dify_vdb_baidu.baidu_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-chroma/pyproject.toml b/api/providers/vdb/vdb-chroma/pyproject.toml new file mode 100644 index 0000000000..b37ee2a588 --- /dev/null +++ b/api/providers/vdb/vdb-chroma/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "dify-vdb-chroma" +version = "0.0.1" +dependencies = [ + "chromadb==0.5.20", +] +description = "Dify vector store backend (dify-vdb-chroma)." + +[project.entry-points."dify.vector_backends"] +chroma = "dify_vdb_chroma.chroma_vector:ChromaVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/chroma/__init__.py b/api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/chroma/__init__.py rename to api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/__init__.py diff --git a/api/core/rag/datasource/vdb/chroma/chroma_vector.py b/api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/chroma_vector.py similarity index 94% rename from api/core/rag/datasource/vdb/chroma/chroma_vector.py rename to api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/chroma_vector.py index 73787c2f00..5b0cfbea15 100644 --- a/api/core/rag/datasource/vdb/chroma/chroma_vector.py +++ b/api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/chroma_vector.py @@ -2,7 +2,7 @@ import json from typing import Any, TypedDict import chromadb -from chromadb import QueryResult, Settings +from chromadb import QueryResult, Settings # pyright: ignore[reportPrivateImportUsage] from pydantic import BaseModel from configs import dify_config @@ -106,14 +106,15 @@ class ChromaVector(BaseVector): def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]: collection = self._client.get_or_create_collection(self._collection_name) document_ids_filter = kwargs.get("document_ids_filter") + results: QueryResult if document_ids_filter: - results: QueryResult = collection.query( + results = collection.query( query_embeddings=query_vector, n_results=kwargs.get("top_k", 4), where={"document_id": {"$in": document_ids_filter}}, # type: ignore ) else: - results: QueryResult = collection.query(query_embeddings=query_vector, n_results=kwargs.get("top_k", 4)) # type: ignore + results = collection.query(query_embeddings=query_vector, n_results=kwargs.get("top_k", 4)) # type: ignore score_threshold = float(kwargs.get("score_threshold") or 0.0) # Check if results contain data @@ -165,8 +166,8 @@ class ChromaVectorFactory(AbstractVectorFactory): config=ChromaConfig( host=dify_config.CHROMA_HOST or "", port=dify_config.CHROMA_PORT, - tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT, - database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE, + tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT, # pyright: ignore[reportPrivateImportUsage] + database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE, # pyright: ignore[reportPrivateImportUsage] auth_provider=dify_config.CHROMA_AUTH_PROVIDER, auth_credentials=dify_config.CHROMA_AUTH_CREDENTIALS, ), diff --git a/api/tests/integration_tests/vdb/chroma/test_chroma.py b/api/providers/vdb/vdb-chroma/tests/integration_tests/test_chroma.py similarity index 80% rename from api/tests/integration_tests/vdb/chroma/test_chroma.py rename to api/providers/vdb/vdb-chroma/tests/integration_tests/test_chroma.py index 52beba9979..87c259f3d0 100644 --- a/api/tests/integration_tests/vdb/chroma/test_chroma.py +++ b/api/providers/vdb/vdb-chroma/tests/integration_tests/test_chroma.py @@ -1,13 +1,11 @@ import chromadb +from dify_vdb_chroma.chroma_vector import ChromaConfig, ChromaVector -from core.rag.datasource.vdb.chroma.chroma_vector import ChromaConfig, ChromaVector -from tests.integration_tests.vdb.test_vector_store import ( +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, get_example_text, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class ChromaVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/chroma/test_chroma_vector.py b/api/providers/vdb/vdb-chroma/tests/unit_tests/test_chroma_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/chroma/test_chroma_vector.py rename to api/providers/vdb/vdb-chroma/tests/unit_tests/test_chroma_vector.py index 44427b7d87..b209c9df96 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/chroma/test_chroma_vector.py +++ b/api/providers/vdb/vdb-chroma/tests/unit_tests/test_chroma_vector.py @@ -47,7 +47,7 @@ def _build_fake_chroma_modules(): def chroma_module(monkeypatch): fake_chroma = _build_fake_chroma_modules() monkeypatch.setitem(sys.modules, "chromadb", fake_chroma) - import core.rag.datasource.vdb.chroma.chroma_vector as module + import dify_vdb_chroma.chroma_vector as module return importlib.reload(module) diff --git a/api/core/rag/datasource/vdb/clickzetta/README.md b/api/providers/vdb/vdb-clickzetta/README.md similarity index 99% rename from api/core/rag/datasource/vdb/clickzetta/README.md rename to api/providers/vdb/vdb-clickzetta/README.md index 969d4e40a0..faa76707ce 100644 --- a/api/core/rag/datasource/vdb/clickzetta/README.md +++ b/api/providers/vdb/vdb-clickzetta/README.md @@ -198,4 +198,4 @@ Clickzetta supports advanced full-text search with multiple analyzers: - [Clickzetta Vector Search Documentation](https://yunqi.tech/documents/vector-search) - [Clickzetta Inverted Index Documentation](https://yunqi.tech/documents/inverted-index) -- [Clickzetta SQL Functions](https://yunqi.tech/documents/sql-reference) +- [Clickzetta SQL Functions](https://yunqi.tech/documents/sql-reference) \ No newline at end of file diff --git a/api/providers/vdb/vdb-clickzetta/pyproject.toml b/api/providers/vdb/vdb-clickzetta/pyproject.toml new file mode 100644 index 0000000000..aea94fdb2a --- /dev/null +++ b/api/providers/vdb/vdb-clickzetta/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-clickzetta" +version = "0.0.1" + +dependencies = [ + "clickzetta-connector-python>=0.8.102", +] +description = "Dify vector store backend (dify-vdb-clickzetta)." + +[project.entry-points."dify.vector_backends"] +clickzetta = "dify_vdb_clickzetta.clickzetta_vector:ClickzettaVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/clickzetta/__init__.py b/api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/clickzetta/__init__.py rename to api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/__init__.py diff --git a/api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py b/api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/clickzetta_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py rename to api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/clickzetta_vector.py index a4dddc68f0..72b8c5e9eb 100644 --- a/api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py +++ b/api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/clickzetta_vector.py @@ -51,7 +51,7 @@ class ClickzettaConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): """ Validate the configuration values. """ diff --git a/api/tests/integration_tests/vdb/clickzetta/README.md b/api/providers/vdb/vdb-clickzetta/tests/README.md similarity index 100% rename from api/tests/integration_tests/vdb/clickzetta/README.md rename to api/providers/vdb/vdb-clickzetta/tests/README.md diff --git a/api/tests/integration_tests/vdb/clickzetta/test_clickzetta.py b/api/providers/vdb/vdb-clickzetta/tests/integration_tests/test_clickzetta.py similarity index 92% rename from api/tests/integration_tests/vdb/clickzetta/test_clickzetta.py rename to api/providers/vdb/vdb-clickzetta/tests/integration_tests/test_clickzetta.py index 21de8be6e3..1c6819f9f1 100644 --- a/api/tests/integration_tests/vdb/clickzetta/test_clickzetta.py +++ b/api/providers/vdb/vdb-clickzetta/tests/integration_tests/test_clickzetta.py @@ -2,10 +2,10 @@ import contextlib import os import pytest +from dify_vdb_clickzetta.clickzetta_vector import ClickzettaConfig, ClickzettaVector -from core.rag.datasource.vdb.clickzetta.clickzetta_vector import ClickzettaConfig, ClickzettaVector +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest, get_example_text from core.rag.models.document import Document -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text, setup_mock_redis class TestClickzettaVector(AbstractVectorTest): @@ -14,9 +14,8 @@ class TestClickzettaVector(AbstractVectorTest): """ @pytest.fixture - def vector_store(self): + def vector_store(self, setup_mock_redis): """Create a Clickzetta vector store instance for testing.""" - # Skip test if Clickzetta credentials are not configured if not os.getenv("CLICKZETTA_USERNAME"): pytest.skip("CLICKZETTA_USERNAME is not configured") if not os.getenv("CLICKZETTA_PASSWORD"): @@ -32,21 +31,19 @@ class TestClickzettaVector(AbstractVectorTest): workspace=os.getenv("CLICKZETTA_WORKSPACE", "quick_start"), vcluster=os.getenv("CLICKZETTA_VCLUSTER", "default_ap"), schema=os.getenv("CLICKZETTA_SCHEMA", "dify_test"), - batch_size=10, # Small batch size for testing + batch_size=10, enable_inverted_index=True, analyzer_type="chinese", analyzer_mode="smart", vector_distance_function="cosine_distance", ) - with setup_mock_redis(): - vector = ClickzettaVector(collection_name="test_collection_" + str(os.getpid()), config=config) + vector = ClickzettaVector(collection_name="test_collection_" + str(os.getpid()), config=config) - yield vector + yield vector - # Cleanup: delete the test collection - with contextlib.suppress(Exception): - vector.delete() + with contextlib.suppress(Exception): + vector.delete() def test_clickzetta_vector_basic_operations(self, vector_store): """Test basic CRUD operations on Clickzetta vector store.""" diff --git a/api/tests/integration_tests/vdb/clickzetta/test_docker_integration.py b/api/providers/vdb/vdb-clickzetta/tests/integration_tests/test_docker_integration.py similarity index 55% rename from api/tests/integration_tests/vdb/clickzetta/test_docker_integration.py rename to api/providers/vdb/vdb-clickzetta/tests/integration_tests/test_docker_integration.py index 60e3f30f26..a5d32f5e81 100644 --- a/api/tests/integration_tests/vdb/clickzetta/test_docker_integration.py +++ b/api/providers/vdb/vdb-clickzetta/tests/integration_tests/test_docker_integration.py @@ -3,16 +3,19 @@ Test Clickzetta integration in Docker environment """ +import logging import os import time import httpx from clickzetta import connect +logger = logging.getLogger(__name__) + def test_clickzetta_connection(): """Test direct connection to Clickzetta""" - print("=== Testing direct Clickzetta connection ===") + logger.info("=== Testing direct Clickzetta connection ===") try: conn = connect( username=os.getenv("CLICKZETTA_USERNAME", "test_user"), @@ -25,100 +28,93 @@ def test_clickzetta_connection(): ) with conn.cursor() as cursor: - # Test basic connectivity cursor.execute("SELECT 1 as test") result = cursor.fetchone() - print(f"✓ Connection test: {result}") + logger.info("✓ Connection test: %s", result) - # Check if our test table exists cursor.execute("SHOW TABLES IN dify") tables = cursor.fetchall() - print(f"✓ Existing tables: {[t[1] for t in tables if t[0] == 'dify']}") + logger.info("✓ Existing tables: %s", [t[1] for t in tables if t[0] == "dify"]) - # Check if test collection exists test_collection = "collection_test_dataset" if test_collection in [t[1] for t in tables if t[0] == "dify"]: cursor.execute(f"DESCRIBE dify.{test_collection}") columns = cursor.fetchall() - print(f"✓ Table structure for {test_collection}:") + logger.info("✓ Table structure for %s:", test_collection) for col in columns: - print(f" - {col[0]}: {col[1]}") + logger.info(" - %s: %s", col[0], col[1]) - # Check for indexes cursor.execute(f"SHOW INDEXES IN dify.{test_collection}") indexes = cursor.fetchall() - print(f"✓ Indexes on {test_collection}:") + logger.info("✓ Indexes on %s:", test_collection) for idx in indexes: - print(f" - {idx}") + logger.info(" - %s", idx) return True - except Exception as e: - print(f"✗ Connection test failed: {e}") + except Exception: + logger.exception("✗ Connection test failed") return False def test_dify_api(): """Test Dify API with Clickzetta backend""" - print("\n=== Testing Dify API ===") + logger.info("\n=== Testing Dify API ===") base_url = "http://localhost:5001" - # Wait for API to be ready max_retries = 30 for i in range(max_retries): try: response = httpx.get(f"{base_url}/console/api/health") if response.status_code == 200: - print("✓ Dify API is ready") + logger.info("✓ Dify API is ready") break except: if i == max_retries - 1: - print("✗ Dify API is not responding") + logger.exception("✗ Dify API is not responding") return False time.sleep(2) - # Check vector store configuration try: - # This is a simplified check - in production, you'd use proper auth - print("✓ Dify is configured to use Clickzetta as vector store") + logger.info("✓ Dify is configured to use Clickzetta as vector store") return True - except Exception as e: - print(f"✗ API test failed: {e}") + except Exception: + logger.exception("✗ API test failed") return False def verify_table_structure(): """Verify the table structure meets Dify requirements""" - print("\n=== Verifying Table Structure ===") + logger.info("\n=== Verifying Table Structure ===") expected_columns = { "id": "VARCHAR", "page_content": "VARCHAR", - "metadata": "VARCHAR", # JSON stored as VARCHAR in Clickzetta + "metadata": "VARCHAR", "vector": "ARRAY", } expected_metadata_fields = ["doc_id", "doc_hash", "document_id", "dataset_id"] - print("✓ Expected table structure:") + logger.info("✓ Expected table structure:") for col, dtype in expected_columns.items(): - print(f" - {col}: {dtype}") + logger.info(" - %s: %s", col, dtype) - print("\n✓ Required metadata fields:") + logger.info("\n✓ Required metadata fields:") for field in expected_metadata_fields: - print(f" - {field}") + logger.info(" - %s", field) - print("\n✓ Index requirements:") - print(" - Vector index (HNSW) on 'vector' column") - print(" - Full-text index on 'page_content' (optional)") - print(" - Functional index on metadata->>'$.doc_id' (recommended)") - print(" - Functional index on metadata->>'$.document_id' (recommended)") + logger.info("\n✓ Index requirements:") + logger.info(" - Vector index (HNSW) on 'vector' column") + logger.info(" - Full-text index on 'page_content' (optional)") + logger.info(" - Functional index on metadata->>'$.doc_id' (recommended)") + logger.info(" - Functional index on metadata->>'$.document_id' (recommended)") return True def main(): """Run all tests""" - print("Starting Clickzetta integration tests for Dify Docker\n") + logger.info("Starting Clickzetta integration tests for Dify Docker\n") tests = [ ("Direct Clickzetta Connection", test_clickzetta_connection), @@ -131,33 +127,34 @@ def main(): try: success = test_func() results.append((test_name, success)) - except Exception as e: - print(f"\n✗ {test_name} crashed: {e}") + except Exception: + logger.exception("\n✗ %s crashed", test_name) results.append((test_name, False)) - # Summary - print("\n" + "=" * 50) - print("Test Summary:") - print("=" * 50) + logger.info("\n%s", "=" * 50) + logger.info("Test Summary:") + logger.info("=" * 50) passed = sum(1 for _, success in results if success) total = len(results) for test_name, success in results: status = "✅ PASSED" if success else "❌ FAILED" - print(f"{test_name}: {status}") + logger.info("%s: %s", test_name, status) - print(f"\nTotal: {passed}/{total} tests passed") + logger.info("\nTotal: %s/%s tests passed", passed, total) if passed == total: - print("\n🎉 All tests passed! Clickzetta is ready for Dify Docker deployment.") - print("\nNext steps:") - print("1. Run: cd docker && docker-compose -f docker-compose.yaml -f docker-compose.clickzetta.yaml up -d") - print("2. Access Dify at http://localhost:3000") - print("3. Create a dataset and test vector storage with Clickzetta") + logger.info("\n🎉 All tests passed! Clickzetta is ready for Dify Docker deployment.") + logger.info("\nNext steps:") + logger.info( + "1. Run: cd docker && docker-compose -f docker-compose.yaml -f docker-compose.clickzetta.yaml up -d" + ) + logger.info("2. Access Dify at http://localhost:3000") + logger.info("3. Create a dataset and test vector storage with Clickzetta") return 0 else: - print("\n⚠️ Some tests failed. Please check the errors above.") + logger.error("\n⚠️ Some tests failed. Please check the errors above.") return 1 diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/clickzetta/test_clickzetta_vector.py b/api/providers/vdb/vdb-clickzetta/tests/unit_tests/test_clickzetta_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/clickzetta/test_clickzetta_vector.py rename to api/providers/vdb/vdb-clickzetta/tests/unit_tests/test_clickzetta_vector.py index 0ce5c04dd6..a7473f1b91 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/clickzetta/test_clickzetta_vector.py +++ b/api/providers/vdb/vdb-clickzetta/tests/unit_tests/test_clickzetta_vector.py @@ -47,7 +47,7 @@ def _build_fake_clickzetta_module(): @pytest.fixture def clickzetta_module(monkeypatch): monkeypatch.setitem(sys.modules, "clickzetta", _build_fake_clickzetta_module()) - import core.rag.datasource.vdb.clickzetta.clickzetta_vector as module + import dify_vdb_clickzetta.clickzetta_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-couchbase/pyproject.toml b/api/providers/vdb/vdb-couchbase/pyproject.toml new file mode 100644 index 0000000000..6bc348b2eb --- /dev/null +++ b/api/providers/vdb/vdb-couchbase/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-couchbase" +version = "0.0.1" + +dependencies = [ + "couchbase~=4.6.0", +] +description = "Dify vector store backend (dify-vdb-couchbase)." + +[project.entry-points."dify.vector_backends"] +couchbase = "dify_vdb_couchbase.couchbase_vector:CouchbaseVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/couchbase/__init__.py b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/couchbase/__init__.py rename to api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/__init__.py diff --git a/api/core/rag/datasource/vdb/couchbase/couchbase_vector.py b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/couchbase/couchbase_vector.py rename to api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py index 9a4a65cf6f..815ac30c0b 100644 --- a/api/core/rag/datasource/vdb/couchbase/couchbase_vector.py +++ b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py @@ -36,7 +36,7 @@ class CouchbaseConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values.get("connection_string"): raise ValueError("config COUCHBASE_CONNECTION_STRING is required") if not values.get("user"): diff --git a/api/tests/integration_tests/vdb/couchbase/test_couchbase.py b/api/providers/vdb/vdb-couchbase/tests/integration_tests/test_couchbase.py similarity index 80% rename from api/tests/integration_tests/vdb/couchbase/test_couchbase.py rename to api/providers/vdb/vdb-couchbase/tests/integration_tests/test_couchbase.py index 0371f04233..918dae328f 100644 --- a/api/tests/integration_tests/vdb/couchbase/test_couchbase.py +++ b/api/providers/vdb/vdb-couchbase/tests/integration_tests/test_couchbase.py @@ -1,12 +1,14 @@ +import logging import subprocess import time -from core.rag.datasource.vdb.couchbase.couchbase_vector import CouchbaseConfig, CouchbaseVector -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_couchbase.couchbase_vector import CouchbaseConfig, CouchbaseVector + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) +logger = logging.getLogger(__name__) def wait_for_healthy_container(service_name="couchbase-server", timeout=300): @@ -16,10 +18,10 @@ def wait_for_healthy_container(service_name="couchbase-server", timeout=300): ["docker", "inspect", "--format", "{{.State.Health.Status}}", service_name], capture_output=True, text=True ) if result.stdout.strip() == "healthy": - print(f"{service_name} is healthy!") + logger.info("%s is healthy!", service_name) return True else: - print(f"Waiting for {service_name} to be healthy...") + logger.info("Waiting for %s to be healthy...", service_name) time.sleep(10) raise TimeoutError(f"{service_name} did not become healthy in time") diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/couchbase/test_couchbase_vector.py b/api/providers/vdb/vdb-couchbase/tests/unit_tests/test_couchbase_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/couchbase/test_couchbase_vector.py rename to api/providers/vdb/vdb-couchbase/tests/unit_tests/test_couchbase_vector.py index 9fea187615..7e5c40b8f2 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/couchbase/test_couchbase_vector.py +++ b/api/providers/vdb/vdb-couchbase/tests/unit_tests/test_couchbase_vector.py @@ -154,7 +154,7 @@ def couchbase_module(monkeypatch): for name, module in _build_fake_couchbase_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.couchbase.couchbase_vector as module + import dify_vdb_couchbase.couchbase_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-elasticsearch/pyproject.toml b/api/providers/vdb/vdb-elasticsearch/pyproject.toml new file mode 100644 index 0000000000..d40908f92d --- /dev/null +++ b/api/providers/vdb/vdb-elasticsearch/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "dify-vdb-elasticsearch" +version = "0.0.1" + +dependencies = [ + "elasticsearch==8.14.0", +] +description = "Dify vector store backend (dify-vdb-elasticsearch)." + +[project.entry-points."dify.vector_backends"] +elasticsearch = "dify_vdb_elasticsearch.elasticsearch_vector:ElasticSearchVectorFactory" +elasticsearch-ja = "dify_vdb_elasticsearch.elasticsearch_ja_vector:ElasticSearchJaVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/elasticsearch/__init__.py b/api/providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/elasticsearch/__init__.py rename to api/providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/__init__.py diff --git a/api/core/rag/datasource/vdb/elasticsearch/elasticsearch_ja_vector.py b/api/providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/elasticsearch_ja_vector.py similarity index 98% rename from api/core/rag/datasource/vdb/elasticsearch/elasticsearch_ja_vector.py rename to api/providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/elasticsearch_ja_vector.py index 1e7fe52666..87b9d813ec 100644 --- a/api/core/rag/datasource/vdb/elasticsearch/elasticsearch_ja_vector.py +++ b/api/providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/elasticsearch_ja_vector.py @@ -4,14 +4,14 @@ from typing import Any from flask import current_app -from core.rag.datasource.vdb.elasticsearch.elasticsearch_vector import ( +from core.rag.datasource.vdb.field import Field +from core.rag.datasource.vdb.vector_type import VectorType +from core.rag.embedding.embedding_base import Embeddings +from dify_vdb_elasticsearch.elasticsearch_vector import ( ElasticSearchConfig, ElasticSearchVector, ElasticSearchVectorFactory, ) -from core.rag.datasource.vdb.field import Field -from core.rag.datasource.vdb.vector_type import VectorType -from core.rag.embedding.embedding_base import Embeddings from extensions.ext_redis import redis_client from models.dataset import Dataset diff --git a/api/core/rag/datasource/vdb/elasticsearch/elasticsearch_vector.py b/api/providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/elasticsearch_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/elasticsearch/elasticsearch_vector.py rename to api/providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/elasticsearch_vector.py index 1470713b88..11463b6c58 100644 --- a/api/core/rag/datasource/vdb/elasticsearch/elasticsearch_vector.py +++ b/api/providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/elasticsearch_vector.py @@ -43,7 +43,7 @@ class ElasticSearchConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): use_cloud = values.get("use_cloud", False) cloud_url = values.get("cloud_url") @@ -258,7 +258,7 @@ class ElasticSearchVector(BaseVector): self, embeddings: list[list[float]], metadatas: list[dict[Any, Any]] | None = None, - index_params: dict | None = None, + index_params: dict[str, Any] | None = None, ): lock_name = f"vector_indexing_lock_{self._collection_name}" with redis_client.lock(lock_name, timeout=20): diff --git a/api/tests/integration_tests/vdb/elasticsearch/test_elasticsearch.py b/api/providers/vdb/vdb-elasticsearch/tests/integration_tests/test_elasticsearch.py similarity index 71% rename from api/tests/integration_tests/vdb/elasticsearch/test_elasticsearch.py rename to api/providers/vdb/vdb-elasticsearch/tests/integration_tests/test_elasticsearch.py index 970d2cce1a..c8b679e021 100644 --- a/api/tests/integration_tests/vdb/elasticsearch/test_elasticsearch.py +++ b/api/providers/vdb/vdb-elasticsearch/tests/integration_tests/test_elasticsearch.py @@ -1,10 +1,9 @@ -from core.rag.datasource.vdb.elasticsearch.elasticsearch_vector import ElasticSearchConfig, ElasticSearchVector -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_elasticsearch.elasticsearch_vector import ElasticSearchConfig, ElasticSearchVector + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class ElasticSearchVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/elasticsearch/test_elasticsearch_ja_vector.py b/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_ja_vector.py similarity index 96% rename from api/tests/unit_tests/core/rag/datasource/vdb/elasticsearch/test_elasticsearch_ja_vector.py rename to api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_ja_vector.py index edd29a4649..f81ed6beea 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/elasticsearch/test_elasticsearch_ja_vector.py +++ b/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_ja_vector.py @@ -32,8 +32,8 @@ def elasticsearch_ja_module(monkeypatch): for name, module in _build_fake_elasticsearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.elasticsearch.elasticsearch_ja_vector as ja_module - import core.rag.datasource.vdb.elasticsearch.elasticsearch_vector as base_module + import dify_vdb_elasticsearch.elasticsearch_ja_vector as ja_module + import dify_vdb_elasticsearch.elasticsearch_vector as base_module importlib.reload(base_module) return importlib.reload(ja_module) diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/elasticsearch/test_elasticsearch_vector.py b/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/elasticsearch/test_elasticsearch_vector.py rename to api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_vector.py index 9ecf0caa24..48f1f6dc26 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/elasticsearch/test_elasticsearch_vector.py +++ b/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_vector.py @@ -42,7 +42,7 @@ def elasticsearch_module(monkeypatch): for name, module in _build_fake_elasticsearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.elasticsearch.elasticsearch_vector as module + import dify_vdb_elasticsearch.elasticsearch_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-hologres/pyproject.toml b/api/providers/vdb/vdb-hologres/pyproject.toml new file mode 100644 index 0000000000..88044bf6d6 --- /dev/null +++ b/api/providers/vdb/vdb-hologres/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-hologres" +version = "0.0.1" + +dependencies = [ + "holo-search-sdk>=0.4.2", +] +description = "Dify vector store backend (dify-vdb-hologres)." + +[project.entry-points."dify.vector_backends"] +hologres = "dify_vdb_hologres.hologres_vector:HologresVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/hologres/__init__.py b/api/providers/vdb/vdb-hologres/src/dify_vdb_hologres/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/hologres/__init__.py rename to api/providers/vdb/vdb-hologres/src/dify_vdb_hologres/__init__.py diff --git a/api/core/rag/datasource/vdb/hologres/hologres_vector.py b/api/providers/vdb/vdb-hologres/src/dify_vdb_hologres/hologres_vector.py similarity index 97% rename from api/core/rag/datasource/vdb/hologres/hologres_vector.py rename to api/providers/vdb/vdb-hologres/src/dify_vdb_hologres/hologres_vector.py index 13d48b5668..80c0ed582e 100644 --- a/api/core/rag/datasource/vdb/hologres/hologres_vector.py +++ b/api/providers/vdb/vdb-hologres/src/dify_vdb_hologres/hologres_vector.py @@ -1,7 +1,7 @@ import json import logging import time -from typing import Any +from typing import Any, cast import holo_search_sdk as holo # type: ignore from holo_search_sdk.types import BaseQuantizationType, DistanceType, TokenizerType @@ -43,7 +43,7 @@ class HologresVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values.get("host"): raise ValueError("config HOLOGRES_HOST is required") if not values.get("database"): @@ -351,9 +351,9 @@ class HologresVectorFactory(AbstractVectorFactory): access_key_id=dify_config.HOLOGRES_ACCESS_KEY_ID or "", access_key_secret=dify_config.HOLOGRES_ACCESS_KEY_SECRET or "", schema_name=dify_config.HOLOGRES_SCHEMA, - tokenizer=dify_config.HOLOGRES_TOKENIZER, - distance_method=dify_config.HOLOGRES_DISTANCE_METHOD, - base_quantization_type=dify_config.HOLOGRES_BASE_QUANTIZATION_TYPE, + tokenizer=cast(TokenizerType, dify_config.HOLOGRES_TOKENIZER), + distance_method=cast(DistanceType, dify_config.HOLOGRES_DISTANCE_METHOD), + base_quantization_type=cast(BaseQuantizationType, dify_config.HOLOGRES_BASE_QUANTIZATION_TYPE), max_degree=dify_config.HOLOGRES_MAX_DEGREE, ef_construction=dify_config.HOLOGRES_EF_CONSTRUCTION, ), diff --git a/api/tests/integration_tests/vdb/__mock/hologres.py b/api/providers/vdb/vdb-hologres/tests/integration_tests/conftest.py similarity index 82% rename from api/tests/integration_tests/vdb/__mock/hologres.py rename to api/providers/vdb/vdb-hologres/tests/integration_tests/conftest.py index b60cf358c0..d28ded0187 100644 --- a/api/tests/integration_tests/vdb/__mock/hologres.py +++ b/api/providers/vdb/vdb-hologres/tests/integration_tests/conftest.py @@ -7,13 +7,10 @@ import pytest from _pytest.monkeypatch import MonkeyPatch from psycopg import sql as psql -# Shared in-memory storage: {table_name: {doc_id: {"id", "text", "meta", "embedding"}}} _mock_tables: dict[str, dict[str, dict[str, Any]]] = {} class MockSearchQuery: - """Mock query builder for search_vector and search_text results.""" - def __init__(self, table_name: str, search_type: str): self._table_name = table_name self._search_type = search_type @@ -32,17 +29,13 @@ class MockSearchQuery: return self def _apply_filter(self, row: dict[str, Any]) -> bool: - """Apply the filter SQL to check if a row matches.""" if self._filter_sql is None: return True - # Extract literals (the document IDs) from the filter SQL - # Filter format: meta->>'document_id' IN ('doc1', 'doc2') literals = [v for t, v in _extract_identifiers_and_literals(self._filter_sql) if t == "literal"] if not literals: return True - # Get the document_id from the row's meta field meta = row.get("meta", "{}") if isinstance(meta, str): meta = json.loads(meta) @@ -54,22 +47,17 @@ class MockSearchQuery: data = _mock_tables.get(self._table_name, {}) results = [] for row in list(data.values())[: self._limit_val]: - # Apply filter if present if not self._apply_filter(row): continue if self._search_type == "vector": - # row format expected by _process_vector_results: (distance, id, text, meta) results.append((0.1, row["id"], row["text"], row["meta"])) else: - # row format expected by _process_full_text_results: (id, text, meta, embedding, score) results.append((row["id"], row["text"], row["meta"], row.get("embedding", []), 0.9)) return results class MockTable: - """Mock table object returned by client.open_table().""" - def __init__(self, table_name: str): self._table_name = table_name @@ -97,7 +85,6 @@ class MockTable: def _extract_sql_template(query) -> str: - """Extract the SQL template string from a psycopg Composed object.""" if isinstance(query, psql.Composed): for part in query: if isinstance(part, psql.SQL): @@ -108,7 +95,6 @@ def _extract_sql_template(query) -> str: def _extract_identifiers_and_literals(query) -> list[Any]: - """Extract Identifier and Literal values from a psycopg Composed object.""" values: list[Any] = [] if isinstance(query, psql.Composed): for part in query: @@ -117,7 +103,6 @@ def _extract_identifiers_and_literals(query) -> list[Any]: elif isinstance(part, psql.Literal): values.append(("literal", part._obj)) elif isinstance(part, psql.Composed): - # Handles SQL(...).join(...) for IN clauses for sub in part: if isinstance(sub, psql.Literal): values.append(("literal", sub._obj)) @@ -125,8 +110,6 @@ def _extract_identifiers_and_literals(query) -> list[Any]: class MockHologresClient: - """Mock holo_search_sdk client that stores data in memory.""" - def connect(self): pass @@ -141,21 +124,18 @@ class MockHologresClient: params = _extract_identifiers_and_literals(query) if "CREATE TABLE" in template.upper(): - # Extract table name from first identifier table_name = next((v for t, v in params if t == "ident"), "unknown") if table_name not in _mock_tables: _mock_tables[table_name] = {} return None if "SELECT 1" in template: - # text_exists: SELECT 1 FROM {table} WHERE id = {id} LIMIT 1 table_name = next((v for t, v in params if t == "ident"), "") doc_id = next((v for t, v in params if t == "literal"), "") data = _mock_tables.get(table_name, {}) return [(1,)] if doc_id in data else [] if "SELECT id" in template: - # get_ids_by_metadata_field: SELECT id FROM {table} WHERE meta->>{key} = {value} table_name = next((v for t, v in params if t == "ident"), "") literals = [v for t, v in params if t == "literal"] key = literals[0] if len(literals) > 0 else "" @@ -166,12 +146,10 @@ class MockHologresClient: if "DELETE" in template.upper(): table_name = next((v for t, v in params if t == "ident"), "") if "id IN" in template: - # delete_by_ids ids_to_delete = [v for t, v in params if t == "literal"] for did in ids_to_delete: _mock_tables.get(table_name, {}).pop(did, None) elif "meta->>" in template: - # delete_by_metadata_field literals = [v for t, v in params if t == "literal"] key = literals[0] if len(literals) > 0 else "" value = literals[1] if len(literals) > 1 else "" @@ -190,7 +168,6 @@ class MockHologresClient: def mock_connect(**kwargs): - """Replacement for holo_search_sdk.connect() that returns a mock client.""" return MockHologresClient() diff --git a/api/tests/integration_tests/vdb/hologres/test_hologres.py b/api/providers/vdb/vdb-hologres/tests/integration_tests/test_hologres.py similarity index 94% rename from api/tests/integration_tests/vdb/hologres/test_hologres.py rename to api/providers/vdb/vdb-hologres/tests/integration_tests/test_hologres.py index d81e18841e..04024be4ae 100644 --- a/api/tests/integration_tests/vdb/hologres/test_hologres.py +++ b/api/providers/vdb/vdb-hologres/tests/integration_tests/test_hologres.py @@ -2,16 +2,11 @@ import os import uuid from typing import cast +from dify_vdb_hologres.hologres_vector import HologresVector, HologresVectorConfig from holo_search_sdk.types import BaseQuantizationType, DistanceType, TokenizerType -from core.rag.datasource.vdb.hologres.hologres_vector import HologresVector, HologresVectorConfig +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest, get_example_text from core.rag.models.document import Document -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text - -pytest_plugins = ( - "tests.integration_tests.vdb.test_vector_store", - "tests.integration_tests.vdb.__mock.hologres", -) MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true" diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/hologres/test_hologres_vector.py b/api/providers/vdb/vdb-hologres/tests/unit_tests/test_hologres_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/hologres/test_hologres_vector.py rename to api/providers/vdb/vdb-hologres/tests/unit_tests/test_hologres_vector.py index 5d9e744ded..f9a557ecce 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/hologres/test_hologres_vector.py +++ b/api/providers/vdb/vdb-hologres/tests/unit_tests/test_hologres_vector.py @@ -42,7 +42,7 @@ def hologres_module(monkeypatch): for name, module in _build_fake_hologres_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.hologres.hologres_vector as module + import dify_vdb_hologres.hologres_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-huawei-cloud/pyproject.toml b/api/providers/vdb/vdb-huawei-cloud/pyproject.toml new file mode 100644 index 0000000000..71af56786c --- /dev/null +++ b/api/providers/vdb/vdb-huawei-cloud/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-huawei-cloud" +version = "0.0.1" + +dependencies = [ + "elasticsearch==8.14.0", +] +description = "Dify vector store backend (dify-vdb-huawei-cloud)." + +[project.entry-points."dify.vector_backends"] +huawei_cloud = "dify_vdb_huawei_cloud.huawei_cloud_vector:HuaweiCloudVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/huawei/__init__.py b/api/providers/vdb/vdb-huawei-cloud/src/dify_vdb_huawei_cloud/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/huawei/__init__.py rename to api/providers/vdb/vdb-huawei-cloud/src/dify_vdb_huawei_cloud/__init__.py diff --git a/api/core/rag/datasource/vdb/huawei/huawei_cloud_vector.py b/api/providers/vdb/vdb-huawei-cloud/src/dify_vdb_huawei_cloud/huawei_cloud_vector.py similarity index 91% rename from api/core/rag/datasource/vdb/huawei/huawei_cloud_vector.py rename to api/providers/vdb/vdb-huawei-cloud/src/dify_vdb_huawei_cloud/huawei_cloud_vector.py index df02c584ed..d51075d2e8 100644 --- a/api/core/rag/datasource/vdb/huawei/huawei_cloud_vector.py +++ b/api/providers/vdb/vdb-huawei-cloud/src/dify_vdb_huawei_cloud/huawei_cloud_vector.py @@ -5,6 +5,7 @@ from typing import Any from elasticsearch import Elasticsearch from pydantic import BaseModel, model_validator +from typing_extensions import TypedDict from configs import dify_config from core.rag.datasource.vdb.field import Field @@ -19,6 +20,16 @@ from models.dataset import Dataset logger = logging.getLogger(__name__) +class HuaweiElasticsearchParamsDict(TypedDict, total=False): + hosts: list[str] + verify_certs: bool + ssl_show_warn: bool + request_timeout: int + retry_on_timeout: bool + max_retries: int + basic_auth: tuple[str, str] + + def create_ssl_context() -> ssl.SSLContext: ssl_context = ssl.create_default_context() ssl_context.check_hostname = False @@ -33,20 +44,20 @@ class HuaweiCloudVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["hosts"]: raise ValueError("config HOSTS is required") return values - def to_elasticsearch_params(self) -> dict[str, Any]: - params = { - "hosts": self.hosts.split(","), - "verify_certs": False, - "ssl_show_warn": False, - "request_timeout": 30000, - "retry_on_timeout": True, - "max_retries": 10, - } + def to_elasticsearch_params(self) -> HuaweiElasticsearchParamsDict: + params = HuaweiElasticsearchParamsDict( + hosts=self.hosts.split(","), + verify_certs=False, + ssl_show_warn=False, + request_timeout=30000, + retry_on_timeout=True, + max_retries=10, + ) if self.username and self.password: params["basic_auth"] = (self.username, self.password) return params @@ -158,7 +169,7 @@ class HuaweiCloudVector(BaseVector): self, embeddings: list[list[float]], metadatas: list[dict[Any, Any]] | None = None, - index_params: dict | None = None, + index_params: dict[str, Any] | None = None, ): lock_name = f"vector_indexing_lock_{self._collection_name}" with redis_client.lock(lock_name, timeout=20): diff --git a/api/tests/integration_tests/vdb/__mock/huaweicloudvectordb.py b/api/providers/vdb/vdb-huawei-cloud/tests/integration_tests/conftest.py similarity index 100% rename from api/tests/integration_tests/vdb/__mock/huaweicloudvectordb.py rename to api/providers/vdb/vdb-huawei-cloud/tests/integration_tests/conftest.py diff --git a/api/tests/integration_tests/vdb/huawei/test_huawei_cloud.py b/api/providers/vdb/vdb-huawei-cloud/tests/integration_tests/test_huawei_cloud.py similarity index 69% rename from api/tests/integration_tests/vdb/huawei/test_huawei_cloud.py rename to api/providers/vdb/vdb-huawei-cloud/tests/integration_tests/test_huawei_cloud.py index 01f511358a..bb5f5b72ef 100644 --- a/api/tests/integration_tests/vdb/huawei/test_huawei_cloud.py +++ b/api/providers/vdb/vdb-huawei-cloud/tests/integration_tests/test_huawei_cloud.py @@ -1,10 +1,6 @@ -from core.rag.datasource.vdb.huawei.huawei_cloud_vector import HuaweiCloudVector, HuaweiCloudVectorConfig -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text +from dify_vdb_huawei_cloud.huawei_cloud_vector import HuaweiCloudVector, HuaweiCloudVectorConfig -pytest_plugins = ( - "tests.integration_tests.vdb.test_vector_store", - "tests.integration_tests.vdb.__mock.huaweicloudvectordb", -) +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest, get_example_text class HuaweiCloudVectorTest(AbstractVectorTest): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/huawei/test_huawei_cloud_vector.py b/api/providers/vdb/vdb-huawei-cloud/tests/unit_tests/test_huawei_cloud_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/huawei/test_huawei_cloud_vector.py rename to api/providers/vdb/vdb-huawei-cloud/tests/unit_tests/test_huawei_cloud_vector.py index 9d23dfcf63..ba3f14912b 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/huawei/test_huawei_cloud_vector.py +++ b/api/providers/vdb/vdb-huawei-cloud/tests/unit_tests/test_huawei_cloud_vector.py @@ -33,7 +33,7 @@ def huawei_module(monkeypatch): for name, module in _build_fake_elasticsearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.huawei.huawei_cloud_vector as module + import dify_vdb_huawei_cloud.huawei_cloud_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-iris/pyproject.toml b/api/providers/vdb/vdb-iris/pyproject.toml new file mode 100644 index 0000000000..6dd7a8e073 --- /dev/null +++ b/api/providers/vdb/vdb-iris/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-iris" +version = "0.0.1" + +dependencies = [ + "intersystems-irispython>=5.1.0", +] +description = "Dify vector store backend (dify-vdb-iris)." + +[project.entry-points."dify.vector_backends"] +iris = "dify_vdb_iris.iris_vector:IrisVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/iris/__init__.py b/api/providers/vdb/vdb-iris/src/dify_vdb_iris/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/iris/__init__.py rename to api/providers/vdb/vdb-iris/src/dify_vdb_iris/__init__.py diff --git a/api/core/rag/datasource/vdb/iris/iris_vector.py b/api/providers/vdb/vdb-iris/src/dify_vdb_iris/iris_vector.py similarity index 100% rename from api/core/rag/datasource/vdb/iris/iris_vector.py rename to api/providers/vdb/vdb-iris/src/dify_vdb_iris/iris_vector.py diff --git a/api/tests/integration_tests/vdb/iris/test_iris.py b/api/providers/vdb/vdb-iris/tests/integration_tests/test_iris.py similarity index 85% rename from api/tests/integration_tests/vdb/iris/test_iris.py rename to api/providers/vdb/vdb-iris/tests/integration_tests/test_iris.py index 4b2da8387b..8281e89c8a 100644 --- a/api/tests/integration_tests/vdb/iris/test_iris.py +++ b/api/providers/vdb/vdb-iris/tests/integration_tests/test_iris.py @@ -1,12 +1,11 @@ """Integration tests for IRIS vector database.""" -from core.rag.datasource.vdb.iris.iris_vector import IrisVector, IrisVectorConfig -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_iris.iris_vector import IrisVector, IrisVectorConfig + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class IrisVectorTest(AbstractVectorTest): """Test suite for IRIS vector store implementation.""" diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/iris/test_iris_vector.py b/api/providers/vdb/vdb-iris/tests/unit_tests/test_iris_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/iris/test_iris_vector.py rename to api/providers/vdb/vdb-iris/tests/unit_tests/test_iris_vector.py index 63338ca809..8c038e82b9 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/iris/test_iris_vector.py +++ b/api/providers/vdb/vdb-iris/tests/unit_tests/test_iris_vector.py @@ -26,7 +26,7 @@ def _build_fake_iris_module(): def iris_module(monkeypatch): monkeypatch.setitem(sys.modules, "iris", _build_fake_iris_module()) - import core.rag.datasource.vdb.iris.iris_vector as module + import dify_vdb_iris.iris_vector as module reloaded = importlib.reload(module) reloaded._pool_instance = None diff --git a/api/providers/vdb/vdb-lindorm/pyproject.toml b/api/providers/vdb/vdb-lindorm/pyproject.toml new file mode 100644 index 0000000000..0cffc67491 --- /dev/null +++ b/api/providers/vdb/vdb-lindorm/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "dify-vdb-lindorm" +version = "0.0.1" + +dependencies = [ + "opensearch-py==3.1.0", + "tenacity>=8.0.0", +] +description = "Dify vector store backend (dify-vdb-lindorm)." + +[project.entry-points."dify.vector_backends"] +lindorm = "dify_vdb_lindorm.lindorm_vector:LindormVectorStoreFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/lindorm/__init__.py b/api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/lindorm/__init__.py rename to api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/__init__.py diff --git a/api/core/rag/datasource/vdb/lindorm/lindorm_vector.py b/api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/lindorm_vector.py similarity index 96% rename from api/core/rag/datasource/vdb/lindorm/lindorm_vector.py rename to api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/lindorm_vector.py index bfcb620618..9187ca943d 100644 --- a/api/core/rag/datasource/vdb/lindorm/lindorm_vector.py +++ b/api/providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/lindorm_vector.py @@ -7,6 +7,7 @@ from opensearchpy import OpenSearch, helpers from opensearchpy.helpers import BulkIndexError from pydantic import BaseModel, model_validator from tenacity import retry, stop_after_attempt, wait_exponential +from typing_extensions import TypedDict from configs import dify_config from core.rag.datasource.vdb.field import Field @@ -26,6 +27,14 @@ ROUTING_FIELD = "routing_field" UGC_INDEX_PREFIX = "ugc_index" +class LindormOpenSearchParamsDict(TypedDict, total=False): + hosts: str | None + use_ssl: bool + pool_maxsize: int + timeout: int + http_auth: tuple[str, str] + + class LindormVectorStoreConfig(BaseModel): hosts: str | None username: str | None = None @@ -35,7 +44,7 @@ class LindormVectorStoreConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["hosts"]: raise ValueError("config URL is required") if not values["username"]: @@ -44,13 +53,13 @@ class LindormVectorStoreConfig(BaseModel): raise ValueError("config PASSWORD is required") return values - def to_opensearch_params(self) -> dict[str, Any]: - params: dict[str, Any] = { - "hosts": self.hosts, - "use_ssl": False, - "pool_maxsize": 128, - "timeout": 30, - } + def to_opensearch_params(self) -> LindormOpenSearchParamsDict: + params = LindormOpenSearchParamsDict( + hosts=self.hosts, + use_ssl=False, + pool_maxsize=128, + timeout=30, + ) if self.username and self.password: params["http_auth"] = (self.username, self.password) return params @@ -327,7 +336,10 @@ class LindormVectorStore(BaseVector): return docs def create_collection( - self, embeddings: list, metadatas: list[dict] | None = None, index_params: dict | None = None + self, + embeddings: list[list[float]], + metadatas: list[dict[str, Any]] | None = None, + index_params: dict[str, Any] | None = None, ): if not embeddings: raise ValueError(f"Embeddings list cannot be empty for collection create '{self._collection_name}'") diff --git a/api/tests/integration_tests/vdb/lindorm/test_lindorm.py b/api/providers/vdb/vdb-lindorm/tests/integration_tests/test_lindorm.py similarity index 88% rename from api/tests/integration_tests/vdb/lindorm/test_lindorm.py rename to api/providers/vdb/vdb-lindorm/tests/integration_tests/test_lindorm.py index b24498fdfd..0a0c2d2d59 100644 --- a/api/tests/integration_tests/vdb/lindorm/test_lindorm.py +++ b/api/providers/vdb/vdb-lindorm/tests/integration_tests/test_lindorm.py @@ -1,9 +1,8 @@ import os -from core.rag.datasource.vdb.lindorm.lindorm_vector import LindormVectorStore, LindormVectorStoreConfig -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest +from dify_vdb_lindorm.lindorm_vector import LindormVectorStore, LindormVectorStoreConfig -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest class Config: diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/lindorm/test_lindorm_vector.py b/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/lindorm/test_lindorm_vector.py rename to api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py index 34357d5907..238145c1d6 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/lindorm/test_lindorm_vector.py +++ b/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py @@ -51,7 +51,7 @@ def lindorm_module(monkeypatch): for name, module in _build_fake_opensearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.lindorm.lindorm_vector as module + import dify_vdb_lindorm.lindorm_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-matrixone/pyproject.toml b/api/providers/vdb/vdb-matrixone/pyproject.toml new file mode 100644 index 0000000000..53363ed7d9 --- /dev/null +++ b/api/providers/vdb/vdb-matrixone/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-matrixone" +version = "0.0.1" + +dependencies = [ + "mo-vector~=0.1.13", +] +description = "Dify vector store backend (dify-vdb-matrixone)." + +[project.entry-points."dify.vector_backends"] +matrixone = "dify_vdb_matrixone.matrixone_vector:MatrixoneVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/matrixone/__init__.py b/api/providers/vdb/vdb-matrixone/src/dify_vdb_matrixone/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/matrixone/__init__.py rename to api/providers/vdb/vdb-matrixone/src/dify_vdb_matrixone/__init__.py diff --git a/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py b/api/providers/vdb/vdb-matrixone/src/dify_vdb_matrixone/matrixone_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/matrixone/matrixone_vector.py rename to api/providers/vdb/vdb-matrixone/src/dify_vdb_matrixone/matrixone_vector.py index c6ebccd204..75fb54e6f4 100644 --- a/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py +++ b/api/providers/vdb/vdb-matrixone/src/dify_vdb_matrixone/matrixone_vector.py @@ -43,7 +43,7 @@ class MatrixoneConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config host is required") if not values["port"]: diff --git a/api/tests/integration_tests/vdb/matrixone/test_matrixone.py b/api/providers/vdb/vdb-matrixone/tests/integration_tests/test_matrixone.py similarity index 74% rename from api/tests/integration_tests/vdb/matrixone/test_matrixone.py rename to api/providers/vdb/vdb-matrixone/tests/integration_tests/test_matrixone.py index fe592f6699..d6f4781e65 100644 --- a/api/tests/integration_tests/vdb/matrixone/test_matrixone.py +++ b/api/providers/vdb/vdb-matrixone/tests/integration_tests/test_matrixone.py @@ -1,10 +1,9 @@ -from core.rag.datasource.vdb.matrixone.matrixone_vector import MatrixoneConfig, MatrixoneVector -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_matrixone.matrixone_vector import MatrixoneConfig, MatrixoneVector + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class MatrixoneVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/matrixone/test_matrixone_vector.py b/api/providers/vdb/vdb-matrixone/tests/unit_tests/test_matrixone_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/matrixone/test_matrixone_vector.py rename to api/providers/vdb/vdb-matrixone/tests/unit_tests/test_matrixone_vector.py index 55e7b9112e..c22f4304e5 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/matrixone/test_matrixone_vector.py +++ b/api/providers/vdb/vdb-matrixone/tests/unit_tests/test_matrixone_vector.py @@ -36,7 +36,7 @@ def matrixone_module(monkeypatch): for name, module in _build_fake_mo_vector_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.matrixone.matrixone_vector as module + import dify_vdb_matrixone.matrixone_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-milvus/pyproject.toml b/api/providers/vdb/vdb-milvus/pyproject.toml new file mode 100644 index 0000000000..57385a4431 --- /dev/null +++ b/api/providers/vdb/vdb-milvus/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-milvus" +version = "0.0.1" + +dependencies = [ + "pymilvus~=2.6.12", +] +description = "Dify vector store backend (dify-vdb-milvus)." + +[project.entry-points."dify.vector_backends"] +milvus = "dify_vdb_milvus.milvus_vector:MilvusVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/milvus/__init__.py b/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/milvus/__init__.py rename to api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/__init__.py diff --git a/api/core/rag/datasource/vdb/milvus/milvus_vector.py b/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py similarity index 98% rename from api/core/rag/datasource/vdb/milvus/milvus_vector.py rename to api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py index 7cdb2d3a99..46f3224a95 100644 --- a/api/core/rag/datasource/vdb/milvus/milvus_vector.py +++ b/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py @@ -45,7 +45,7 @@ class MilvusConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): """ Validate the configuration values. Raises ValueError if required fields are missing. @@ -302,7 +302,10 @@ class MilvusVector(BaseVector): ) def create_collection( - self, embeddings: list, metadatas: list[dict] | None = None, index_params: dict | None = None + self, + embeddings: list[list[float]], + metadatas: list[dict[str, Any]] | None = None, + index_params: dict[str, Any] | None = None, ): """ Create a new collection in Milvus with the specified schema and index parameters. diff --git a/api/tests/integration_tests/vdb/milvus/test_milvus.py b/api/providers/vdb/vdb-milvus/tests/integration_tests/test_milvus.py similarity index 80% rename from api/tests/integration_tests/vdb/milvus/test_milvus.py rename to api/providers/vdb/vdb-milvus/tests/integration_tests/test_milvus.py index b5fc4b4d10..084d808bed 100644 --- a/api/tests/integration_tests/vdb/milvus/test_milvus.py +++ b/api/providers/vdb/vdb-milvus/tests/integration_tests/test_milvus.py @@ -1,11 +1,10 @@ -from core.rag.datasource.vdb.milvus.milvus_vector import MilvusConfig, MilvusVector -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_milvus.milvus_vector import MilvusConfig, MilvusVector + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, get_example_text, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class MilvusVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/milvus/test_milvus.py b/api/providers/vdb/vdb-milvus/tests/unit_tests/test_milvus.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/milvus/test_milvus.py rename to api/providers/vdb/vdb-milvus/tests/unit_tests/test_milvus.py index 2ac2c40d38..36c0ed8f6f 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/milvus/test_milvus.py +++ b/api/providers/vdb/vdb-milvus/tests/unit_tests/test_milvus.py @@ -103,7 +103,7 @@ def milvus_module(monkeypatch): for name, module in _build_fake_pymilvus_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.milvus.milvus_vector as module + import dify_vdb_milvus.milvus_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-myscale/pyproject.toml b/api/providers/vdb/vdb-myscale/pyproject.toml new file mode 100644 index 0000000000..13e0f35d23 --- /dev/null +++ b/api/providers/vdb/vdb-myscale/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-myscale" +version = "0.0.1" + +dependencies = [ + "clickhouse-connect~=0.15.0", +] +description = "Dify vector store backend (dify-vdb-myscale)." + +[project.entry-points."dify.vector_backends"] +myscale = "dify_vdb_myscale.myscale_vector:MyScaleVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/myscale/__init__.py b/api/providers/vdb/vdb-myscale/src/dify_vdb_myscale/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/myscale/__init__.py rename to api/providers/vdb/vdb-myscale/src/dify_vdb_myscale/__init__.py diff --git a/api/core/rag/datasource/vdb/myscale/myscale_vector.py b/api/providers/vdb/vdb-myscale/src/dify_vdb_myscale/myscale_vector.py similarity index 100% rename from api/core/rag/datasource/vdb/myscale/myscale_vector.py rename to api/providers/vdb/vdb-myscale/src/dify_vdb_myscale/myscale_vector.py diff --git a/api/tests/integration_tests/vdb/myscale/test_myscale.py b/api/providers/vdb/vdb-myscale/tests/integration_tests/test_myscale.py similarity index 76% rename from api/tests/integration_tests/vdb/myscale/test_myscale.py rename to api/providers/vdb/vdb-myscale/tests/integration_tests/test_myscale.py index 74cefad2af..8ea42d5f45 100644 --- a/api/tests/integration_tests/vdb/myscale/test_myscale.py +++ b/api/providers/vdb/vdb-myscale/tests/integration_tests/test_myscale.py @@ -1,10 +1,9 @@ -from core.rag.datasource.vdb.myscale.myscale_vector import MyScaleConfig, MyScaleVector -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_myscale.myscale_vector import MyScaleConfig, MyScaleVector + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class MyScaleVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/myscale/test_myscale_vector.py b/api/providers/vdb/vdb-myscale/tests/unit_tests/test_myscale_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/myscale/test_myscale_vector.py rename to api/providers/vdb/vdb-myscale/tests/unit_tests/test_myscale_vector.py index a75ba82238..228ea92639 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/myscale/test_myscale_vector.py +++ b/api/providers/vdb/vdb-myscale/tests/unit_tests/test_myscale_vector.py @@ -42,7 +42,7 @@ def myscale_module(monkeypatch): fake_module = _build_fake_clickhouse_connect_module() monkeypatch.setitem(sys.modules, "clickhouse_connect", fake_module) - import core.rag.datasource.vdb.myscale.myscale_vector as module + import dify_vdb_myscale.myscale_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-oceanbase/pyproject.toml b/api/providers/vdb/vdb-oceanbase/pyproject.toml new file mode 100644 index 0000000000..887869a41c --- /dev/null +++ b/api/providers/vdb/vdb-oceanbase/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "dify-vdb-oceanbase" +version = "0.0.1" + +dependencies = [ + "pyobvector~=0.2.17", + "mysql-connector-python>=9.3.0", +] +description = "Dify vector store backend (dify-vdb-oceanbase)." + +[project.entry-points."dify.vector_backends"] +oceanbase = "dify_vdb_oceanbase.oceanbase_vector:OceanBaseVectorFactory" +seekdb = "dify_vdb_oceanbase.oceanbase_vector:OceanBaseVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/oceanbase/__init__.py b/api/providers/vdb/vdb-oceanbase/src/dify_vdb_oceanbase/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/oceanbase/__init__.py rename to api/providers/vdb/vdb-oceanbase/src/dify_vdb_oceanbase/__init__.py diff --git a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py b/api/providers/vdb/vdb-oceanbase/src/dify_vdb_oceanbase/oceanbase_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py rename to api/providers/vdb/vdb-oceanbase/src/dify_vdb_oceanbase/oceanbase_vector.py index 82f419871c..69dc42169a 100644 --- a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py +++ b/api/providers/vdb/vdb-oceanbase/src/dify_vdb_oceanbase/oceanbase_vector.py @@ -49,7 +49,7 @@ class OceanBaseVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config OCEANBASE_VECTOR_HOST is required") if not values["port"]: diff --git a/api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py b/api/providers/vdb/vdb-oceanbase/tests/integration_tests/bench_oceanbase.py similarity index 87% rename from api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py rename to api/providers/vdb/vdb-oceanbase/tests/integration_tests/bench_oceanbase.py index 8b57be08c5..50f6736942 100644 --- a/api/tests/integration_tests/vdb/oceanbase/bench_oceanbase.py +++ b/api/providers/vdb/vdb-oceanbase/tests/integration_tests/bench_oceanbase.py @@ -2,11 +2,12 @@ Benchmark: OceanBase vector store — old (single-row) vs new (batch) insertion, metadata query with/without functional index, and vector search across metrics. -Usage: - uv run --project api python -m tests.integration_tests.vdb.oceanbase.bench_oceanbase +Usage (from repo root): + uv run --project api python api/packages/dify-vdb-oceanbase/tests/bench_oceanbase.py """ import json +import logging import random import statistics import time @@ -16,6 +17,8 @@ from pyobvector import VECTOR, ObVecClient, cosine_distance, inner_product, l2_d from sqlalchemy import JSON, Column, String, text from sqlalchemy.dialects.mysql import LONGTEXT +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @@ -114,7 +117,7 @@ def bench_metadata_query(client, table, doc_id, with_index=False): try: client.perform_raw_text_sql(f"CREATE INDEX idx_metadata_doc_id ON `{table}` ((metadata->>'$.document_id'))") except Exception: - pass # already exists + logger.debug("Index idx_metadata_doc_id already exists, skipping creation") sql = text(f"SELECT id FROM `{table}` WHERE metadata->>'$.document_id' = :val") times = [] @@ -164,11 +167,11 @@ def main(): client = _make_client() client_pooled = _make_client(pool_size=5, max_overflow=10, pool_recycle=3600, pool_pre_ping=True) - print("=" * 70) - print("OceanBase Vector Store — Performance Benchmark") - print(f" Endpoint : {HOST}:{PORT}") - print(f" Vec dim : {VEC_DIM}") - print("=" * 70) + logger.info("=" * 70) + logger.info("OceanBase Vector Store — Performance Benchmark") + logger.info(" Endpoint : %s:%s", HOST, PORT) + logger.info(" Vec dim : %s", VEC_DIM) + logger.info("=" * 70) # ------------------------------------------------------------------ # 1. Insertion benchmark @@ -187,10 +190,10 @@ def main(): t_batch = bench_insert_batch(client_pooled, tbl_batch, rows, batch_size=100) speedup = t_single / t_batch if t_batch > 0 else float("inf") - print(f"\n[Insert {n_docs} docs]") - print(f" Single-row : {t_single:.2f}s") - print(f" Batch(100) : {t_batch:.2f}s") - print(f" Speedup : {speedup:.1f}x") + logger.info("\n[Insert %s docs]", n_docs) + logger.info(" Single-row : %.2fs", t_single) + logger.info(" Batch(100) : %.2fs", t_batch) + logger.info(" Speedup : %.1fx", speedup) # ------------------------------------------------------------------ # 2. Metadata query benchmark (use the 1000-doc batch table) @@ -203,16 +206,16 @@ def main(): res = conn.execute(text(f"SELECT metadata->>'$.document_id' FROM `{tbl_meta}` LIMIT 1")) doc_id_1000 = res.fetchone()[0] - print("\n[Metadata filter query — 1000 rows, by document_id]") + logger.info("\n[Metadata filter query — 1000 rows, by document_id]") times_no_idx = bench_metadata_query(client, tbl_meta, doc_id_1000, with_index=False) - print(f" Without index : {_fmt(times_no_idx)}") + logger.info(" Without index : %s", _fmt(times_no_idx)) times_with_idx = bench_metadata_query(client, tbl_meta, doc_id_1000, with_index=True) - print(f" With index : {_fmt(times_with_idx)}") + logger.info(" With index : %s", _fmt(times_with_idx)) # ------------------------------------------------------------------ # 3. Vector search benchmark — across metrics # ------------------------------------------------------------------ - print("\n[Vector search — top-10, 20 queries each, on 1000 rows]") + logger.info("\n[Vector search — top-10, 20 queries each, on 1000 rows]") for metric in ["l2", "cosine", "inner_product"]: tbl_vs = f"bench_vs_{metric}" @@ -222,7 +225,7 @@ def main(): rows_vs, _ = _gen_rows(1000) bench_insert_batch(client_pooled, tbl_vs, rows_vs, batch_size=100) times = bench_vector_search(client_pooled, tbl_vs, metric, topk=10, n_queries=20) - print(f" {metric:15s}: {_fmt(times)}") + logger.info(" %-15s: %s", metric, _fmt(times)) _drop(client_pooled, tbl_vs) # ------------------------------------------------------------------ @@ -232,9 +235,9 @@ def main(): _drop(client, f"bench_single_{n}") _drop(client, f"bench_batch_{n}") - print("\n" + "=" * 70) - print("Benchmark complete.") - print("=" * 70) + logger.info("\n%s", "=" * 70) + logger.info("Benchmark complete.") + logger.info("=" * 70) if __name__ == "__main__": diff --git a/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py b/api/providers/vdb/vdb-oceanbase/tests/integration_tests/test_oceanbase.py similarity index 82% rename from api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py rename to api/providers/vdb/vdb-oceanbase/tests/integration_tests/test_oceanbase.py index 410de2c5ad..28f22d3cbc 100644 --- a/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py +++ b/api/providers/vdb/vdb-oceanbase/tests/integration_tests/test_oceanbase.py @@ -1,15 +1,13 @@ import pytest - -from core.rag.datasource.vdb.oceanbase.oceanbase_vector import ( +from dify_vdb_oceanbase.oceanbase_vector import ( OceanBaseVector, OceanBaseVectorConfig, ) -from tests.integration_tests.vdb.test_vector_store import ( + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - @pytest.fixture def oceanbase_vector(): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/oceanbase/test_oceanbase_vector.py b/api/providers/vdb/vdb-oceanbase/tests/unit_tests/test_oceanbase_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/oceanbase/test_oceanbase_vector.py rename to api/providers/vdb/vdb-oceanbase/tests/unit_tests/test_oceanbase_vector.py index 27d8198ec0..31f9ff3e56 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/oceanbase/test_oceanbase_vector.py +++ b/api/providers/vdb/vdb-oceanbase/tests/unit_tests/test_oceanbase_vector.py @@ -56,7 +56,7 @@ def _build_fake_pyobvector_module(): def oceanbase_module(monkeypatch): monkeypatch.setitem(sys.modules, "pyobvector", _build_fake_pyobvector_module()) - import core.rag.datasource.vdb.oceanbase.oceanbase_vector as module + import dify_vdb_oceanbase.oceanbase_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-opengauss/pyproject.toml b/api/providers/vdb/vdb-opengauss/pyproject.toml new file mode 100644 index 0000000000..79be94b9e3 --- /dev/null +++ b/api/providers/vdb/vdb-opengauss/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "dify-vdb-opengauss" +version = "0.0.1" + +dependencies = [] +description = "Dify vector store backend (dify-vdb-opengauss)." + +[project.entry-points."dify.vector_backends"] +opengauss = "dify_vdb_opengauss.opengauss:OpenGaussFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/opengauss/__init__.py b/api/providers/vdb/vdb-opengauss/src/dify_vdb_opengauss/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/opengauss/__init__.py rename to api/providers/vdb/vdb-opengauss/src/dify_vdb_opengauss/__init__.py diff --git a/api/core/rag/datasource/vdb/opengauss/opengauss.py b/api/providers/vdb/vdb-opengauss/src/dify_vdb_opengauss/opengauss.py similarity index 99% rename from api/core/rag/datasource/vdb/opengauss/opengauss.py rename to api/providers/vdb/vdb-opengauss/src/dify_vdb_opengauss/opengauss.py index f9dbfbeeaf..acd2471cf6 100644 --- a/api/core/rag/datasource/vdb/opengauss/opengauss.py +++ b/api/providers/vdb/vdb-opengauss/src/dify_vdb_opengauss/opengauss.py @@ -29,7 +29,7 @@ class OpenGaussConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config OPENGAUSS_HOST is required") if not values["port"]: diff --git a/api/tests/integration_tests/vdb/opengauss/test_opengauss.py b/api/providers/vdb/vdb-opengauss/tests/integration_tests/test_opengauss.py similarity index 82% rename from api/tests/integration_tests/vdb/opengauss/test_opengauss.py rename to api/providers/vdb/vdb-opengauss/tests/integration_tests/test_opengauss.py index 78436a19ee..8b444527d7 100644 --- a/api/tests/integration_tests/vdb/opengauss/test_opengauss.py +++ b/api/providers/vdb/vdb-opengauss/tests/integration_tests/test_opengauss.py @@ -1,14 +1,12 @@ import time import psycopg2 +from dify_vdb_opengauss.opengauss import OpenGauss, OpenGaussConfig -from core.rag.datasource.vdb.opengauss.opengauss import OpenGauss, OpenGaussConfig -from tests.integration_tests.vdb.test_vector_store import ( +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class OpenGaussTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/opengauss/test_opengauss.py b/api/providers/vdb/vdb-opengauss/tests/unit_tests/test_opengauss.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/opengauss/test_opengauss.py rename to api/providers/vdb/vdb-opengauss/tests/unit_tests/test_opengauss.py index 6641dbe4a0..09abd625fc 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/opengauss/test_opengauss.py +++ b/api/providers/vdb/vdb-opengauss/tests/unit_tests/test_opengauss.py @@ -41,7 +41,7 @@ def opengauss_module(monkeypatch): for name, module in _build_fake_psycopg2_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.opengauss.opengauss as module + import dify_vdb_opengauss.opengauss as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-opensearch/pyproject.toml b/api/providers/vdb/vdb-opensearch/pyproject.toml new file mode 100644 index 0000000000..56f303fdf5 --- /dev/null +++ b/api/providers/vdb/vdb-opensearch/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-opensearch" +version = "0.0.1" + +dependencies = [ + "opensearch-py==3.1.0", +] +description = "Dify vector store backend (dify-vdb-opensearch)." + +[project.entry-points."dify.vector_backends"] +opensearch = "dify_vdb_opensearch.opensearch_vector:OpenSearchVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/opensearch/__init__.py b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/opensearch/__init__.py rename to api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/__init__.py diff --git a/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py similarity index 93% rename from api/core/rag/datasource/vdb/opensearch/opensearch_vector.py rename to api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py index 2f77776807..843c495d82 100644 --- a/api/core/rag/datasource/vdb/opensearch/opensearch_vector.py +++ b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py @@ -6,6 +6,7 @@ from uuid import uuid4 from opensearchpy import OpenSearch, Urllib3AWSV4SignerAuth, Urllib3HttpConnection, helpers from opensearchpy.helpers import BulkIndexError from pydantic import BaseModel, model_validator +from typing_extensions import TypedDict from configs import dify_config from configs.middleware.vdb.opensearch_config import AuthMethod @@ -21,6 +22,20 @@ from models.dataset import Dataset logger = logging.getLogger(__name__) +class _OpenSearchHostDict(TypedDict): + host: str + port: int + + +class OpenSearchParamsDict(TypedDict, total=False): + hosts: list[_OpenSearchHostDict] + use_ssl: bool + verify_certs: bool + connection_class: type + pool_maxsize: int + http_auth: tuple[str | None, str | None] | Urllib3AWSV4SignerAuth + + class OpenSearchConfig(BaseModel): host: str port: int @@ -34,7 +49,7 @@ class OpenSearchConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values.get("host"): raise ValueError("config OPENSEARCH_HOST is required") if not values.get("port"): @@ -57,14 +72,14 @@ class OpenSearchConfig(BaseModel): service=self.aws_service, # type: ignore[arg-type] ) - def to_opensearch_params(self) -> dict[str, Any]: - params = { - "hosts": [{"host": self.host, "port": self.port}], - "use_ssl": self.secure, - "verify_certs": self.verify_certs, - "connection_class": Urllib3HttpConnection, - "pool_maxsize": 20, - } + def to_opensearch_params(self) -> OpenSearchParamsDict: + params = OpenSearchParamsDict( + hosts=[{"host": self.host, "port": self.port}], + use_ssl=self.secure, + verify_certs=self.verify_certs, + connection_class=Urllib3HttpConnection, + pool_maxsize=20, + ) if self.auth_method == "basic": logger.info("Using basic authentication for OpenSearch Vector DB") @@ -237,7 +252,10 @@ class OpenSearchVector(BaseVector): return docs def create_collection( - self, embeddings: list, metadatas: list[dict] | None = None, index_params: dict | None = None + self, + embeddings: list[list[float]], + metadatas: list[dict[str, Any]] | None = None, + index_params: dict[str, Any] | None = None, ): lock_name = f"vector_indexing_lock_{self._collection_name.lower()}" with redis_client.lock(lock_name, timeout=20): diff --git a/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch.py b/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch.py new file mode 100644 index 0000000000..f2ed7cb6fb --- /dev/null +++ b/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch.py @@ -0,0 +1,332 @@ +import importlib +import sys +import types +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from core.rag.datasource.vdb.field import Field +from core.rag.models.document import Document +from extensions import ext_redis + + +def _build_fake_opensearch_modules(): + """Build fake opensearchpy modules to avoid the ``from events import Events`` + namespace collision (opensearch-py #756).""" + opensearchpy = types.ModuleType("opensearchpy") + opensearchpy_helpers = types.ModuleType("opensearchpy.helpers") + + class BulkIndexError(Exception): + def __init__(self, errors): + super().__init__("bulk error") + self.errors = errors + + class Urllib3AWSV4SignerAuth: + def __init__(self, credentials, region, service): + self.credentials = credentials + self.region = region + self.service = service + + class Urllib3HttpConnection: + pass + + class _IndicesClient: + def __init__(self): + self.exists = MagicMock(return_value=False) + self.create = MagicMock() + self.delete = MagicMock() + + class OpenSearch: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.indices = _IndicesClient() + self.search = MagicMock(return_value={"hits": {"hits": []}}) + self.get = MagicMock() + + helpers = SimpleNamespace(bulk=MagicMock()) + + opensearchpy.OpenSearch = OpenSearch + opensearchpy.Urllib3AWSV4SignerAuth = Urllib3AWSV4SignerAuth + opensearchpy.Urllib3HttpConnection = Urllib3HttpConnection + opensearchpy.helpers = helpers + opensearchpy_helpers.BulkIndexError = BulkIndexError + + return { + "opensearchpy": opensearchpy, + "opensearchpy.helpers": opensearchpy_helpers, + } + + +@pytest.fixture +def opensearch_module(monkeypatch): + for name, module in _build_fake_opensearch_modules().items(): + monkeypatch.setitem(sys.modules, name, module) + + import dify_vdb_opensearch.opensearch_vector as module + + return importlib.reload(module) + + +def _config(module, **overrides): + values = { + "host": "localhost", + "port": 9200, + "secure": False, + "user": "admin", + "password": "password", + } + values.update(overrides) + return module.OpenSearchConfig.model_validate(values) + + +def get_example_text() -> str: + return "This is a sample text for testing purposes." + + +class TestOpenSearchConfig: + def test_to_opensearch_params(self, opensearch_module): + config = _config(opensearch_module, secure=True) + params = config.to_opensearch_params() + + assert params["hosts"] == [{"host": "localhost", "port": 9200}] + assert params["use_ssl"] is True + assert params["verify_certs"] is True + assert params["connection_class"].__name__ == "Urllib3HttpConnection" + assert params["http_auth"] == ("admin", "password") + + def test_to_opensearch_params_with_aws_managed_iam(self, opensearch_module, monkeypatch): + class _Session: + def get_credentials(self): + return "creds" + + boto3 = types.ModuleType("boto3") + boto3.Session = _Session + monkeypatch.setitem(sys.modules, "boto3", boto3) + + config = _config( + opensearch_module, + secure=True, + auth_method="aws_managed_iam", + aws_region="ap-southeast-2", + aws_service="aoss", + host="aoss-endpoint.ap-southeast-2.aoss.amazonaws.com", + port=9201, + ) + params = config.to_opensearch_params() + + assert params["hosts"] == [{"host": "aoss-endpoint.ap-southeast-2.aoss.amazonaws.com", "port": 9201}] + assert params["use_ssl"] is True + assert params["verify_certs"] is True + assert params["connection_class"].__name__ == "Urllib3HttpConnection" + assert params["http_auth"].credentials == "creds" + assert params["http_auth"].region == "ap-southeast-2" + assert params["http_auth"].service == "aoss" + + +class TestOpenSearchVector: + COLLECTION_NAME = "test_collection" + EXAMPLE_DOC_ID = "example_doc_id" + + def _make_vector(self, module): + vector = module.OpenSearchVector(self.COLLECTION_NAME, _config(module)) + vector._client = MagicMock() + return vector + + @pytest.mark.parametrize( + ("search_response", "expected_length", "expected_doc_id"), + [ + ( + { + "hits": { + "total": {"value": 1}, + "hits": [ + { + "_source": { + "page_content": get_example_text(), + "metadata": {"document_id": "example_doc_id"}, + } + } + ], + } + }, + 1, + "example_doc_id", + ), + ({"hits": {"total": {"value": 0}, "hits": []}}, 0, None), + ], + ) + def test_search_by_full_text(self, opensearch_module, search_response, expected_length, expected_doc_id): + vector = self._make_vector(opensearch_module) + vector._client.search.return_value = search_response + + hits = vector.search_by_full_text(query=get_example_text()) + assert len(hits) == expected_length + if expected_length > 0: + assert hits[0].metadata["document_id"] == expected_doc_id + + def test_search_by_vector(self, opensearch_module): + vector = self._make_vector(opensearch_module) + query_vector = [0.1] * 128 + mock_response = { + "hits": { + "total": {"value": 1}, + "hits": [ + { + "_source": { + Field.CONTENT_KEY: get_example_text(), + Field.METADATA_KEY: {"document_id": self.EXAMPLE_DOC_ID}, + }, + "_score": 1.0, + } + ], + } + } + vector._client.search.return_value = mock_response + + hits = vector.search_by_vector(query_vector=query_vector) + + assert len(hits) > 0 + assert hits[0].metadata["document_id"] == self.EXAMPLE_DOC_ID + + def test_get_ids_by_metadata_field(self, opensearch_module): + vector = self._make_vector(opensearch_module) + mock_response = {"hits": {"total": {"value": 1}, "hits": [{"_id": "mock_id"}]}} + vector._client.search.return_value = mock_response + + doc = Document(page_content="Test content", metadata={"document_id": self.EXAMPLE_DOC_ID}) + embedding = [0.1] * 128 + + opensearch_module.helpers.bulk.reset_mock() + vector.add_texts([doc], [embedding]) + + ids = vector.get_ids_by_metadata_field(key="document_id", value=self.EXAMPLE_DOC_ID) + assert len(ids) == 1 + assert ids[0] == "mock_id" + + def test_add_texts(self, opensearch_module): + vector = self._make_vector(opensearch_module) + vector._client.index.return_value = {"result": "created"} + + doc = Document(page_content="Test content", metadata={"document_id": self.EXAMPLE_DOC_ID}) + embedding = [0.1] * 128 + + opensearch_module.helpers.bulk.reset_mock() + vector.add_texts([doc], [embedding]) + + mock_response = {"hits": {"total": {"value": 1}, "hits": [{"_id": "mock_id"}]}} + vector._client.search.return_value = mock_response + + ids = vector.get_ids_by_metadata_field(key="document_id", value=self.EXAMPLE_DOC_ID) + assert len(ids) == 1 + assert ids[0] == "mock_id" + + def test_delete_nonexistent_index(self, opensearch_module): + """ignore_unavailable=True handles non-existent indices gracefully.""" + vector = self._make_vector(opensearch_module) + vector.delete() + + vector._client.indices.delete.assert_called_once_with( + index=self.COLLECTION_NAME.lower(), ignore_unavailable=True + ) + + def test_delete_existing_index(self, opensearch_module): + vector = self._make_vector(opensearch_module) + vector.delete() + + vector._client.indices.delete.assert_called_once_with( + index=self.COLLECTION_NAME.lower(), ignore_unavailable=True + ) + + +@pytest.fixture(scope="module") +def setup_mock_redis(): + ext_redis.redis_client.get = MagicMock(return_value=None) + ext_redis.redis_client.set = MagicMock(return_value=None) + + mock_redis_lock = MagicMock() + mock_redis_lock.__enter__ = MagicMock() + mock_redis_lock.__exit__ = MagicMock() + ext_redis.redis_client.lock = MagicMock(return_value=mock_redis_lock) + + +@pytest.mark.usefixtures("setup_mock_redis") +class TestOpenSearchVectorWithRedis: + COLLECTION_NAME = "test_collection" + EXAMPLE_DOC_ID = "example_doc_id" + + def _make_vector(self, module): + vector = module.OpenSearchVector(self.COLLECTION_NAME, _config(module)) + vector._client = MagicMock() + return vector + + def test_search_by_full_text(self, opensearch_module): + vector = self._make_vector(opensearch_module) + search_response = { + "hits": { + "total": {"value": 1}, + "hits": [ + {"_source": {"page_content": get_example_text(), "metadata": {"document_id": "example_doc_id"}}} + ], + } + } + vector._client.search.return_value = search_response + + hits = vector.search_by_full_text(query=get_example_text()) + assert len(hits) == 1 + assert hits[0].metadata["document_id"] == "example_doc_id" + + def test_get_ids_by_metadata_field(self, opensearch_module): + vector = self._make_vector(opensearch_module) + mock_response = {"hits": {"total": {"value": 1}, "hits": [{"_id": "mock_id"}]}} + vector._client.search.return_value = mock_response + + doc = Document(page_content="Test content", metadata={"document_id": self.EXAMPLE_DOC_ID}) + embedding = [0.1] * 128 + + opensearch_module.helpers.bulk.reset_mock() + vector.add_texts([doc], [embedding]) + + ids = vector.get_ids_by_metadata_field(key="document_id", value=self.EXAMPLE_DOC_ID) + assert len(ids) == 1 + assert ids[0] == "mock_id" + + def test_add_texts(self, opensearch_module): + vector = self._make_vector(opensearch_module) + vector._client.index.return_value = {"result": "created"} + + doc = Document(page_content="Test content", metadata={"document_id": self.EXAMPLE_DOC_ID}) + embedding = [0.1] * 128 + + opensearch_module.helpers.bulk.reset_mock() + vector.add_texts([doc], [embedding]) + + mock_response = {"hits": {"total": {"value": 1}, "hits": [{"_id": "mock_id"}]}} + vector._client.search.return_value = mock_response + + ids = vector.get_ids_by_metadata_field(key="document_id", value=self.EXAMPLE_DOC_ID) + assert len(ids) == 1 + assert ids[0] == "mock_id" + + def test_search_by_vector(self, opensearch_module): + vector = self._make_vector(opensearch_module) + query_vector = [0.1] * 128 + mock_response = { + "hits": { + "total": {"value": 1}, + "hits": [ + { + "_source": { + Field.CONTENT_KEY: get_example_text(), + Field.METADATA_KEY: {"document_id": self.EXAMPLE_DOC_ID}, + }, + "_score": 1.0, + } + ], + } + } + vector._client.search.return_value = mock_response + + hits = vector.search_by_vector(query_vector=query_vector) + assert len(hits) > 0 + assert hits[0].metadata["document_id"] == self.EXAMPLE_DOC_ID diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/opensearch/test_opensearch_vector.py b/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch_vector.py similarity index 98% rename from api/tests/unit_tests/core/rag/datasource/vdb/opensearch/test_opensearch_vector.py rename to api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch_vector.py index 1030158dd1..1c2921f85b 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/opensearch/test_opensearch_vector.py +++ b/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch_vector.py @@ -10,6 +10,8 @@ from pydantic import ValidationError from core.rag.models.document import Document +# TODO(wylswz): There's a known issue with namespace collision +# https://github.com/langgenius/dify/issues/34732 def _build_fake_opensearch_modules(): opensearchpy = types.ModuleType("opensearchpy") opensearchpy_helpers = types.ModuleType("opensearchpy.helpers") @@ -60,7 +62,7 @@ def opensearch_module(monkeypatch): for name, module in _build_fake_opensearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.opensearch.opensearch_vector as module + import dify_vdb_opensearch.opensearch_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-oracle/pyproject.toml b/api/providers/vdb/vdb-oracle/pyproject.toml new file mode 100644 index 0000000000..6747485041 --- /dev/null +++ b/api/providers/vdb/vdb-oracle/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-oracle" +version = "0.0.1" + +dependencies = [ + "oracledb==3.4.2", +] +description = "Dify vector store backend (dify-vdb-oracle)." + +[project.entry-points."dify.vector_backends"] +oracle = "dify_vdb_oracle.oraclevector:OracleVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/oracle/__init__.py b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/oracle/__init__.py rename to api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/__init__.py diff --git a/api/core/rag/datasource/vdb/oracle/oraclevector.py b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py similarity index 99% rename from api/core/rag/datasource/vdb/oracle/oraclevector.py rename to api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py index cb05c22b55..70377c82c8 100644 --- a/api/core/rag/datasource/vdb/oracle/oraclevector.py +++ b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py @@ -36,7 +36,7 @@ class OracleVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["user"]: raise ValueError("config ORACLE_USER is required") if not values["password"]: diff --git a/api/tests/integration_tests/vdb/oracle/test_oraclevector.py b/api/providers/vdb/vdb-oracle/tests/integration_tests/test_oraclevector.py similarity index 76% rename from api/tests/integration_tests/vdb/oracle/test_oraclevector.py rename to api/providers/vdb/vdb-oracle/tests/integration_tests/test_oraclevector.py index 8920dc97eb..aceb41289c 100644 --- a/api/tests/integration_tests/vdb/oracle/test_oraclevector.py +++ b/api/providers/vdb/vdb-oracle/tests/integration_tests/test_oraclevector.py @@ -1,11 +1,10 @@ -from core.rag.datasource.vdb.oracle.oraclevector import OracleVector, OracleVectorConfig -from core.rag.models.document import Document -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_oracle.oraclevector import OracleVector, OracleVectorConfig + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, get_example_text, ) - -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) +from core.rag.models.document import Document class OracleVectorTest(AbstractVectorTest): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/oracle/test_oraclevector.py b/api/providers/vdb/vdb-oracle/tests/unit_tests/test_oraclevector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/oracle/test_oraclevector.py rename to api/providers/vdb/vdb-oracle/tests/unit_tests/test_oraclevector.py index 817a7d342b..678cf876b0 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/oracle/test_oraclevector.py +++ b/api/providers/vdb/vdb-oracle/tests/unit_tests/test_oraclevector.py @@ -55,7 +55,7 @@ def oracle_module(monkeypatch): for name, module in _build_fake_oracle_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.oracle.oraclevector as module + import dify_vdb_oracle.oraclevector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-pgvecto-rs/pyproject.toml b/api/providers/vdb/vdb-pgvecto-rs/pyproject.toml new file mode 100644 index 0000000000..9a25442e9e --- /dev/null +++ b/api/providers/vdb/vdb-pgvecto-rs/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-pgvecto-rs" +version = "0.0.1" + +dependencies = [ + "pgvecto-rs[sqlalchemy]~=0.2.2", +] +description = "Dify vector store backend (dify-vdb-pgvecto-rs)." + +[project.entry-points."dify.vector_backends"] +pgvecto-rs = "dify_vdb_pgvecto_rs.pgvecto_rs:PGVectoRSFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/pgvecto_rs/__init__.py b/api/providers/vdb/vdb-pgvecto-rs/src/dify_vdb_pgvecto_rs/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/pgvecto_rs/__init__.py rename to api/providers/vdb/vdb-pgvecto-rs/src/dify_vdb_pgvecto_rs/__init__.py diff --git a/api/core/rag/datasource/vdb/pgvecto_rs/collection.py b/api/providers/vdb/vdb-pgvecto-rs/src/dify_vdb_pgvecto_rs/collection.py similarity index 100% rename from api/core/rag/datasource/vdb/pgvecto_rs/collection.py rename to api/providers/vdb/vdb-pgvecto-rs/src/dify_vdb_pgvecto_rs/collection.py diff --git a/api/core/rag/datasource/vdb/pgvecto_rs/pgvecto_rs.py b/api/providers/vdb/vdb-pgvecto-rs/src/dify_vdb_pgvecto_rs/pgvecto_rs.py similarity index 93% rename from api/core/rag/datasource/vdb/pgvecto_rs/pgvecto_rs.py rename to api/providers/vdb/vdb-pgvecto-rs/src/dify_vdb_pgvecto_rs/pgvecto_rs.py index 90d9173409..84b5eba0ac 100644 --- a/api/core/rag/datasource/vdb/pgvecto_rs/pgvecto_rs.py +++ b/api/providers/vdb/vdb-pgvecto-rs/src/dify_vdb_pgvecto_rs/pgvecto_rs.py @@ -9,15 +9,15 @@ from pydantic import BaseModel, model_validator from sqlalchemy import Float, create_engine, insert, select, text from sqlalchemy import text as sql_text from sqlalchemy.dialects import postgresql -from sqlalchemy.orm import Mapped, Session, mapped_column +from sqlalchemy.orm import Mapped, Session, mapped_column, sessionmaker from configs import dify_config -from core.rag.datasource.vdb.pgvecto_rs.collection import CollectionORM from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType from core.rag.embedding.embedding_base import Embeddings from core.rag.models.document import Document +from dify_vdb_pgvecto_rs.collection import CollectionORM from extensions.ext_redis import redis_client from models.dataset import Dataset @@ -33,7 +33,7 @@ class PgvectoRSConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config PGVECTO_RS_HOST is required") if not values["port"]: @@ -55,9 +55,8 @@ class PGVectoRS(BaseVector): f"postgresql+psycopg2://{config.user}:{config.password}@{config.host}:{config.port}/{config.database}" ) self._client = create_engine(self._url) - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: session.execute(text("CREATE EXTENSION IF NOT EXISTS vectors")) - session.commit() self._fields: list[str] = [] class _Table(CollectionORM): @@ -88,7 +87,7 @@ class PGVectoRS(BaseVector): if redis_client.get(collection_exist_cache_key): return index_name = f"{self._collection_name}_embedding_index" - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: create_statement = sql_text(f""" CREATE TABLE IF NOT EXISTS {self._collection_name} ( id UUID PRIMARY KEY, @@ -111,12 +110,11 @@ class PGVectoRS(BaseVector): $$); """) session.execute(index_statement) - session.commit() redis_client.set(collection_exist_cache_key, 1, ex=3600) def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): pks = [] - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: for document, embedding in zip(documents, embeddings): pk = uuid4() session.execute( @@ -128,7 +126,6 @@ class PGVectoRS(BaseVector): ), ) pks.append(pk) - session.commit() return pks @@ -145,10 +142,9 @@ class PGVectoRS(BaseVector): def delete_by_metadata_field(self, key: str, value: str): ids = self.get_ids_by_metadata_field(key, value) if ids: - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: select_statement = sql_text(f"DELETE FROM {self._collection_name} WHERE id = ANY(:ids)") session.execute(select_statement, {"ids": ids}) - session.commit() def delete_by_ids(self, ids: list[str]): with Session(self._client) as session: @@ -159,15 +155,13 @@ class PGVectoRS(BaseVector): if result: ids = [item[0] for item in result] if ids: - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: select_statement = sql_text(f"DELETE FROM {self._collection_name} WHERE id = ANY(:ids)") session.execute(select_statement, {"ids": ids}) - session.commit() def delete(self): - with Session(self._client) as session: + with sessionmaker(bind=self._client).begin() as session: session.execute(sql_text(f"DROP TABLE IF EXISTS {self._collection_name}")) - session.commit() def text_exists(self, id: str) -> bool: with Session(self._client) as session: diff --git a/api/tests/integration_tests/vdb/pgvecto_rs/test_pgvecto_rs.py b/api/providers/vdb/vdb-pgvecto-rs/tests/integration_tests/test_pgvecto_rs.py similarity index 82% rename from api/tests/integration_tests/vdb/pgvecto_rs/test_pgvecto_rs.py rename to api/providers/vdb/vdb-pgvecto-rs/tests/integration_tests/test_pgvecto_rs.py index 6210613d42..9fc8627851 100644 --- a/api/tests/integration_tests/vdb/pgvecto_rs/test_pgvecto_rs.py +++ b/api/providers/vdb/vdb-pgvecto-rs/tests/integration_tests/test_pgvecto_rs.py @@ -1,11 +1,10 @@ -from core.rag.datasource.vdb.pgvecto_rs.pgvecto_rs import PGVectoRS, PgvectoRSConfig -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_pgvecto_rs.pgvecto_rs import PGVectoRS, PgvectoRSConfig + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, get_example_text, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class PGVectoRSVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/pgvecto_rs/test_pgvecto_rs.py b/api/providers/vdb/vdb-pgvecto-rs/tests/unit_tests/test_pgvecto_rs.py similarity index 87% rename from api/tests/unit_tests/core/rag/datasource/vdb/pgvecto_rs/test_pgvecto_rs.py rename to api/providers/vdb/vdb-pgvecto-rs/tests/unit_tests/test_pgvecto_rs.py index 1aec81b8ac..c3291f7f12 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/pgvecto_rs/test_pgvecto_rs.py +++ b/api/providers/vdb/vdb-pgvecto-rs/tests/unit_tests/test_pgvecto_rs.py @@ -53,13 +53,38 @@ def _session_factory(calls, execute_results=None): return _session +class _FakeBeginContext: + def __init__(self, session): + self._session = session + + def __enter__(self): + return self._session + + def __exit__(self, exc_type, exc, tb): + return None + + +def _sessionmaker_factory(calls, execute_results=None): + def _sessionmaker(*args, **kwargs): + session = _FakeSessionContext(calls=calls, execute_results=execute_results) + return MagicMock(begin=MagicMock(return_value=_FakeBeginContext(session))) + + return _sessionmaker + + +def _patch_both(monkeypatch, module, calls, execute_results=None): + """Patch both Session and sessionmaker on the module with the same call tracker.""" + monkeypatch.setattr(module, "Session", _session_factory(calls, execute_results)) + monkeypatch.setattr(module, "sessionmaker", _sessionmaker_factory(calls, execute_results)) + + @pytest.fixture def pgvecto_module(monkeypatch): for name, module in _build_fake_pgvecto_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.pgvecto_rs.collection as collection_module - import core.rag.datasource.vdb.pgvecto_rs.pgvecto_rs as module + import dify_vdb_pgvecto_rs.collection as collection_module + import dify_vdb_pgvecto_rs.pgvecto_rs as module return importlib.reload(module), importlib.reload(collection_module) @@ -105,7 +130,7 @@ def test_init_get_type_and_create_delegate(pgvecto_module, monkeypatch): module, _ = pgvecto_module session_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) - monkeypatch.setattr(module, "Session", _session_factory(session_calls)) + _patch_both(monkeypatch, module, session_calls) vector = module.PGVectoRS("collection_1", _config(module), dim=3) vector.create_collection = MagicMock() @@ -124,7 +149,7 @@ def test_create_collection_cache_and_sql_execution(pgvecto_module, monkeypatch): module, _ = pgvecto_module session_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) - monkeypatch.setattr(module, "Session", _session_factory(session_calls)) + _patch_both(monkeypatch, module, session_calls) lock = MagicMock() lock.__enter__.return_value = None @@ -151,10 +176,10 @@ def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch): execute_results = [SimpleNamespace(fetchall=lambda: [("id-1",), ("id-2",)]), SimpleNamespace(fetchall=lambda: [])] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) - monkeypatch.setattr(module, "Session", _session_factory(init_calls)) + _patch_both(monkeypatch, module, init_calls) vector = module.PGVectoRS("collection_1", _config(module), dim=3) - monkeypatch.setattr(module, "Session", _session_factory(runtime_calls, execute_results=list(execute_results))) + _patch_both(monkeypatch, module, runtime_calls, execute_results=list(execute_results)) class _InsertBuilder: def __init__(self, table): @@ -179,6 +204,7 @@ def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch): "Session", _session_factory(runtime_calls, execute_results=[SimpleNamespace(fetchall=lambda: [("id-1",), ("id-2",)])]), ) + monkeypatch.setattr(module, "sessionmaker", _sessionmaker_factory(runtime_calls)) assert vector.get_ids_by_metadata_field("document_id", "doc-1") == ["id-1", "id-2"] monkeypatch.setattr( @@ -204,12 +230,13 @@ def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch): ], ), ) + monkeypatch.setattr(module, "sessionmaker", _sessionmaker_factory(runtime_calls)) vector.delete_by_ids(["doc-1"]) assert any("meta->>'doc_id' = ANY (:doc_ids)" in str(args[0]) for args, _ in runtime_calls) assert any("DELETE FROM collection_1 WHERE id = ANY(:ids)" in str(args[0]) for args, _ in runtime_calls) runtime_calls.clear() - monkeypatch.setattr(module, "Session", _session_factory(runtime_calls, execute_results=[MagicMock()])) + _patch_both(monkeypatch, module, runtime_calls, execute_results=[MagicMock()]) vector.delete() assert any("DROP TABLE IF EXISTS collection_1" in str(args[0]) for args, _ in runtime_calls) @@ -218,7 +245,7 @@ def test_text_exists_search_and_full_text(pgvecto_module, monkeypatch): module, _ = pgvecto_module init_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) - monkeypatch.setattr(module, "Session", _session_factory(init_calls)) + _patch_both(monkeypatch, module, init_calls) vector = module.PGVectoRS("collection_1", _config(module), dim=3) runtime_calls = [] @@ -277,7 +304,7 @@ def test_text_exists_search_and_full_text(pgvecto_module, monkeypatch): (SimpleNamespace(meta={"doc_id": "1"}, text="text-1"), 0.1), (SimpleNamespace(meta={"doc_id": "2"}, text="text-2"), 0.8), ] - monkeypatch.setattr(module, "Session", _session_factory(runtime_calls, execute_results=[rows])) + _patch_both(monkeypatch, module, runtime_calls, execute_results=[rows]) docs = vector.search_by_vector([0.1, 0.2], top_k=2, score_threshold=0.5, document_ids_filter=["d-1"]) assert len(docs) == 1 diff --git a/api/providers/vdb/vdb-pgvector/pyproject.toml b/api/providers/vdb/vdb-pgvector/pyproject.toml new file mode 100644 index 0000000000..2a972aa277 --- /dev/null +++ b/api/providers/vdb/vdb-pgvector/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-pgvector" +version = "0.0.1" + +dependencies = [ + "pgvector==0.4.2", +] +description = "Dify vector store backend (dify-vdb-pgvector)." + +[project.entry-points."dify.vector_backends"] +pgvector = "dify_vdb_pgvector.pgvector:PGVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/pgvector/__init__.py b/api/providers/vdb/vdb-pgvector/src/dify_vdb_pgvector/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/pgvector/__init__.py rename to api/providers/vdb/vdb-pgvector/src/dify_vdb_pgvector/__init__.py diff --git a/api/core/rag/datasource/vdb/pgvector/pgvector.py b/api/providers/vdb/vdb-pgvector/src/dify_vdb_pgvector/pgvector.py similarity index 99% rename from api/core/rag/datasource/vdb/pgvector/pgvector.py rename to api/providers/vdb/vdb-pgvector/src/dify_vdb_pgvector/pgvector.py index 0615b8312c..b1bdce0ad4 100644 --- a/api/core/rag/datasource/vdb/pgvector/pgvector.py +++ b/api/providers/vdb/vdb-pgvector/src/dify_vdb_pgvector/pgvector.py @@ -34,7 +34,7 @@ class PGVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config PGVECTOR_HOST is required") if not values["port"]: diff --git a/api/tests/integration_tests/vdb/pgvector/test_pgvector.py b/api/providers/vdb/vdb-pgvector/tests/integration_tests/test_pgvector.py similarity index 73% rename from api/tests/integration_tests/vdb/pgvector/test_pgvector.py rename to api/providers/vdb/vdb-pgvector/tests/integration_tests/test_pgvector.py index 4fdeca5a3a..974657510e 100644 --- a/api/tests/integration_tests/vdb/pgvector/test_pgvector.py +++ b/api/providers/vdb/vdb-pgvector/tests/integration_tests/test_pgvector.py @@ -1,10 +1,9 @@ -from core.rag.datasource.vdb.pgvector.pgvector import PGVector, PGVectorConfig -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_pgvector.pgvector import PGVector, PGVectorConfig + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class PGVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/pgvector/test_pgvector.py b/api/providers/vdb/vdb-pgvector/tests/unit_tests/test_pgvector.py similarity index 92% rename from api/tests/unit_tests/core/rag/datasource/vdb/pgvector/test_pgvector.py rename to api/providers/vdb/vdb-pgvector/tests/unit_tests/test_pgvector.py index 7505262eb7..99a6e00c16 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/pgvector/test_pgvector.py +++ b/api/providers/vdb/vdb-pgvector/tests/unit_tests/test_pgvector.py @@ -2,13 +2,10 @@ from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import MagicMock, patch +import dify_vdb_pgvector.pgvector as pgvector_module import pytest +from dify_vdb_pgvector.pgvector import PGVector, PGVectorConfig -import core.rag.datasource.vdb.pgvector.pgvector as pgvector_module -from core.rag.datasource.vdb.pgvector.pgvector import ( - PGVector, - PGVectorConfig, -) from core.rag.models.document import Document @@ -26,7 +23,7 @@ class TestPGVector: ) self.collection_name = "test_collection" - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") def test_init(self, mock_pool_class): """Test PGVector initialization.""" mock_pool = MagicMock() @@ -41,7 +38,7 @@ class TestPGVector: assert pgvector.pg_bigm is False assert pgvector.index_hash is not None - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") def test_init_with_pg_bigm(self, mock_pool_class): """Test PGVector initialization with pg_bigm enabled.""" config = PGVectorConfig( @@ -61,8 +58,8 @@ class TestPGVector: assert pgvector.pg_bigm is True - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") - @patch("core.rag.datasource.vdb.pgvector.pgvector.redis_client") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.redis_client") def test_create_collection_basic(self, mock_redis, mock_pool_class): """Test basic collection creation.""" # Mock Redis operations @@ -104,8 +101,8 @@ class TestPGVector: # Verify Redis cache was set mock_redis.set.assert_called_once() - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") - @patch("core.rag.datasource.vdb.pgvector.pgvector.redis_client") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.redis_client") def test_create_collection_with_large_dimension(self, mock_redis, mock_pool_class): """Test collection creation with dimension > 2000 (no HNSW index).""" # Mock Redis operations @@ -139,8 +136,8 @@ class TestPGVector: hnsw_index_calls = [call for call in mock_cursor.execute.call_args_list if "hnsw" in str(call)] assert len(hnsw_index_calls) == 0 - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") - @patch("core.rag.datasource.vdb.pgvector.pgvector.redis_client") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.redis_client") def test_create_collection_with_pg_bigm(self, mock_redis, mock_pool_class): """Test collection creation with pg_bigm enabled.""" config = PGVectorConfig( @@ -180,8 +177,8 @@ class TestPGVector: bigm_index_calls = [call for call in mock_cursor.execute.call_args_list if "gin_bigm_ops" in str(call)] assert len(bigm_index_calls) == 1 - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") - @patch("core.rag.datasource.vdb.pgvector.pgvector.redis_client") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.redis_client") def test_create_collection_creates_vector_extension(self, mock_redis, mock_pool_class): """Test that vector extension is created if it doesn't exist.""" # Mock Redis operations @@ -213,8 +210,8 @@ class TestPGVector: ] assert len(create_extension_calls) == 1 - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") - @patch("core.rag.datasource.vdb.pgvector.pgvector.redis_client") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.redis_client") def test_create_collection_with_cache_hit(self, mock_redis, mock_pool_class): """Test that collection creation is skipped when cache exists.""" # Mock Redis operations - cache exists @@ -240,8 +237,8 @@ class TestPGVector: # Check that no SQL was executed (early return due to cache) assert mock_cursor.execute.call_count == 0 - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") - @patch("core.rag.datasource.vdb.pgvector.pgvector.redis_client") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.redis_client") def test_create_collection_with_redis_lock(self, mock_redis, mock_pool_class): """Test that Redis lock is used during collection creation.""" # Mock Redis operations @@ -273,7 +270,7 @@ class TestPGVector: mock_lock.__enter__.assert_called_once() mock_lock.__exit__.assert_called_once() - @patch("core.rag.datasource.vdb.pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") + @patch("dify_vdb_pgvector.pgvector.psycopg2.pool.SimpleConnectionPool") def test_get_cursor_context_manager(self, mock_pool_class): """Test that _get_cursor properly manages connection lifecycle.""" mock_pool = MagicMock() diff --git a/api/providers/vdb/vdb-qdrant/pyproject.toml b/api/providers/vdb/vdb-qdrant/pyproject.toml new file mode 100644 index 0000000000..6dd0b9560b --- /dev/null +++ b/api/providers/vdb/vdb-qdrant/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-qdrant" +version = "0.0.1" + +dependencies = [ + "qdrant-client==1.9.0", +] +description = "Dify vector store backend (dify-vdb-qdrant)." + +[project.entry-points."dify.vector_backends"] +qdrant = "dify_vdb_qdrant.qdrant_vector:QdrantVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/pyvastbase/__init__.py b/api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/pyvastbase/__init__.py rename to api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/__init__.py diff --git a/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py b/api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/qdrant_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/qdrant/qdrant_vector.py rename to api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/qdrant_vector.py index f4fcb975c3..b5ff87fc5d 100644 --- a/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py +++ b/api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/qdrant_vector.py @@ -3,7 +3,7 @@ import os import uuid from collections.abc import Generator, Iterable, Sequence from itertools import islice -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import qdrant_client from flask import current_app @@ -32,7 +32,6 @@ from extensions.ext_redis import redis_client from models.dataset import Dataset, DatasetCollectionBinding if TYPE_CHECKING: - from qdrant_client import grpc # noqa from qdrant_client.conversions import common_types from qdrant_client.http import models as rest @@ -180,7 +179,7 @@ class QdrantVector(BaseVector): for batch_ids, points in self._generate_rest_batches( texts, embeddings, filtered_metadatas, uuids, 64, self._group_id ): - self._client.upsert(collection_name=self._collection_name, points=points) + self._client.upsert(collection_name=self._collection_name, points=cast("common_types.Points", points)) added_ids.extend(batch_ids) return added_ids @@ -472,7 +471,7 @@ class QdrantVector(BaseVector): def _reload_if_needed(self): if isinstance(self._client, QdrantLocal): - self._client._load() + self._client._load() # pyright: ignore[reportPrivateUsage] @classmethod def _document_from_scored_point( diff --git a/api/tests/integration_tests/vdb/qdrant/test_qdrant.py b/api/providers/vdb/vdb-qdrant/tests/integration_tests/test_qdrant.py similarity index 95% rename from api/tests/integration_tests/vdb/qdrant/test_qdrant.py rename to api/providers/vdb/vdb-qdrant/tests/integration_tests/test_qdrant.py index 709cc2e14e..e0badeb5de 100644 --- a/api/tests/integration_tests/vdb/qdrant/test_qdrant.py +++ b/api/providers/vdb/vdb-qdrant/tests/integration_tests/test_qdrant.py @@ -1,12 +1,11 @@ import uuid -from core.rag.datasource.vdb.qdrant.qdrant_vector import QdrantConfig, QdrantVector -from core.rag.models.document import Document -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_qdrant.qdrant_vector import QdrantConfig, QdrantVector + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) - -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) +from core.rag.models.document import Document class QdrantVectorTest(AbstractVectorTest): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/qdrant/test_qdrant_vector.py b/api/providers/vdb/vdb-qdrant/tests/unit_tests/test_qdrant_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/qdrant/test_qdrant_vector.py rename to api/providers/vdb/vdb-qdrant/tests/unit_tests/test_qdrant_vector.py index 0408506563..0ed5491fbe 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/qdrant/test_qdrant_vector.py +++ b/api/providers/vdb/vdb-qdrant/tests/unit_tests/test_qdrant_vector.py @@ -125,7 +125,7 @@ def qdrant_module(monkeypatch): for name, module in _build_fake_qdrant_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.qdrant.qdrant_vector as module + import dify_vdb_qdrant.qdrant_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-relyt/pyproject.toml b/api/providers/vdb/vdb-relyt/pyproject.toml new file mode 100644 index 0000000000..2a7c7fac87 --- /dev/null +++ b/api/providers/vdb/vdb-relyt/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "dify-vdb-relyt" +version = "0.0.1" + +dependencies = [] +description = "Dify vector store backend (dify-vdb-relyt)." + +[project.entry-points."dify.vector_backends"] +relyt = "dify_vdb_relyt.relyt_vector:RelytVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/qdrant/__init__.py b/api/providers/vdb/vdb-relyt/src/dify_vdb_relyt/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/qdrant/__init__.py rename to api/providers/vdb/vdb-relyt/src/dify_vdb_relyt/__init__.py diff --git a/api/core/rag/datasource/vdb/relyt/relyt_vector.py b/api/providers/vdb/vdb-relyt/src/dify_vdb_relyt/relyt_vector.py similarity index 97% rename from api/core/rag/datasource/vdb/relyt/relyt_vector.py rename to api/providers/vdb/vdb-relyt/src/dify_vdb_relyt/relyt_vector.py index e486375ec2..336c2d3c8a 100644 --- a/api/core/rag/datasource/vdb/relyt/relyt_vector.py +++ b/api/providers/vdb/vdb-relyt/src/dify_vdb_relyt/relyt_vector.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, model_validator from sqlalchemy import Column, String, Table, create_engine, insert from sqlalchemy import text as sql_text from sqlalchemy.dialects.postgresql import JSON, TEXT -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -26,7 +26,7 @@ from extensions.ext_redis import redis_client logger = logging.getLogger(__name__) -Base = declarative_base() # type: Any +Base: Any = declarative_base() class RelytConfig(BaseModel): @@ -38,7 +38,7 @@ class RelytConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config RELYT_HOST is required") if not values["port"]: @@ -79,7 +79,7 @@ class RelytVector(BaseVector): if redis_client.get(collection_exist_cache_key): return index_name = f"{self._collection_name}_embedding_index" - with Session(self.client) as session: + with sessionmaker(bind=self.client).begin() as session: drop_statement = sql_text(f"""DROP TABLE IF EXISTS "{self._collection_name}"; """) session.execute(drop_statement) create_statement = sql_text(f""" @@ -104,7 +104,6 @@ class RelytVector(BaseVector): $$); """) session.execute(index_statement) - session.commit() redis_client.set(collection_exist_cache_key, 1, ex=3600) def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): @@ -208,9 +207,8 @@ class RelytVector(BaseVector): self.delete_by_uuids(ids) def delete(self): - with Session(self.client) as session: + with sessionmaker(bind=self.client).begin() as session: session.execute(sql_text(f"""DROP TABLE IF EXISTS "{self._collection_name}";""")) - session.commit() def text_exists(self, id: str) -> bool: with Session(self.client) as session: @@ -241,7 +239,7 @@ class RelytVector(BaseVector): self, embedding: list[float], k: int = 4, - filter: dict | None = None, + filter: dict[str, Any] | None = None, ) -> list[tuple[Document, float]]: # Add the filter if provided diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/relyt/test_relyt_vector.py b/api/providers/vdb/vdb-relyt/tests/unit_tests/test_relyt_vector.py similarity index 93% rename from api/tests/unit_tests/core/rag/datasource/vdb/relyt/test_relyt_vector.py rename to api/providers/vdb/vdb-relyt/tests/unit_tests/test_relyt_vector.py index ca8cd5e514..f97ad1400a 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/relyt/test_relyt_vector.py +++ b/api/providers/vdb/vdb-relyt/tests/unit_tests/test_relyt_vector.py @@ -39,12 +39,31 @@ class _FakeSession: return None +class _FakeBeginContext: + def __init__(self, session): + self._session = session + + def __enter__(self): + return self._session + + def __exit__(self, exc_type, exc, tb): + return None + + +def _patch_both(monkeypatch, module, session): + """Patch both Session and sessionmaker on the module.""" + monkeypatch.setattr(module, "Session", lambda _client: session) + monkeypatch.setattr( + module, "sessionmaker", lambda **kwargs: MagicMock(begin=MagicMock(return_value=_FakeBeginContext(session))) + ) + + @pytest.fixture def relyt_module(monkeypatch): for name, module in _build_fake_relyt_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.relyt.relyt_vector as module + import dify_vdb_relyt.relyt_vector as module return importlib.reload(module) @@ -108,13 +127,13 @@ def test_create_collection_cache_and_sql_execution(relyt_module, monkeypatch): monkeypatch.setattr(relyt_module.redis_client, "get", MagicMock(return_value=1)) session = _FakeSession() - monkeypatch.setattr(relyt_module, "Session", lambda _client: session) + _patch_both(monkeypatch, relyt_module, session) vector.create_collection(3) session.execute.assert_not_called() monkeypatch.setattr(relyt_module.redis_client, "get", MagicMock(return_value=None)) session = _FakeSession() - monkeypatch.setattr(relyt_module, "Session", lambda _client: session) + _patch_both(monkeypatch, relyt_module, session) vector.create_collection(3) executed_sql = [str(call.args[0]) for call in session.execute.call_args_list] assert any("DROP TABLE IF EXISTS" in sql for sql in executed_sql) @@ -265,15 +284,15 @@ def test_search_by_vector_filters_by_score_and_ids(relyt_module): # 8. delete commits session -def test_delete_commits_session(relyt_module, monkeypatch): +def test_delete_drops_table(relyt_module, monkeypatch): vector = relyt_module.RelytVector.__new__(relyt_module.RelytVector) vector._collection_name = "collection_1" vector.client = MagicMock() vector.embedding_dimension = 3 session = _FakeSession() - monkeypatch.setattr(relyt_module, "Session", lambda _client: session) + _patch_both(monkeypatch, relyt_module, session) vector.delete() - session.commit.assert_called_once() + session.execute.assert_called_once() def test_relyt_factory_existing_and_generated_collection(relyt_module, monkeypatch): diff --git a/api/providers/vdb/vdb-tablestore/pyproject.toml b/api/providers/vdb/vdb-tablestore/pyproject.toml new file mode 100644 index 0000000000..fd1a2d54e0 --- /dev/null +++ b/api/providers/vdb/vdb-tablestore/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-tablestore" +version = "0.0.1" + +dependencies = [ + "tablestore==6.4.4", +] +description = "Dify vector store backend (dify-vdb-tablestore)." + +[project.entry-points."dify.vector_backends"] +tablestore = "dify_vdb_tablestore.tablestore_vector:TableStoreVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/relyt/__init__.py b/api/providers/vdb/vdb-tablestore/src/dify_vdb_tablestore/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/relyt/__init__.py rename to api/providers/vdb/vdb-tablestore/src/dify_vdb_tablestore/__init__.py diff --git a/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py b/api/providers/vdb/vdb-tablestore/src/dify_vdb_tablestore/tablestore_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/tablestore/tablestore_vector.py rename to api/providers/vdb/vdb-tablestore/src/dify_vdb_tablestore/tablestore_vector.py index 4a734232ec..f9deac11e5 100644 --- a/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py +++ b/api/providers/vdb/vdb-tablestore/src/dify_vdb_tablestore/tablestore_vector.py @@ -30,7 +30,7 @@ class TableStoreConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["access_key_id"]: raise ValueError("config ACCESS_KEY_ID is required") if not values["access_key_secret"]: diff --git a/api/tests/integration_tests/vdb/tablestore/test_tablestore.py b/api/providers/vdb/vdb-tablestore/tests/integration_tests/test_tablestore.py similarity index 93% rename from api/tests/integration_tests/vdb/tablestore/test_tablestore.py rename to api/providers/vdb/vdb-tablestore/tests/integration_tests/test_tablestore.py index b60e26a881..97c9626ee1 100644 --- a/api/tests/integration_tests/vdb/tablestore/test_tablestore.py +++ b/api/providers/vdb/vdb-tablestore/tests/integration_tests/test_tablestore.py @@ -1,20 +1,21 @@ +import logging import os import uuid import tablestore from _pytest.python_api import approx - -from core.rag.datasource.vdb.tablestore.tablestore_vector import ( +from dify_vdb_tablestore.tablestore_vector import ( TableStoreConfig, TableStoreVector, ) -from tests.integration_tests.vdb.test_vector_store import ( + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, get_example_document, get_example_text, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) +logger = logging.getLogger(__name__) class TableStoreVectorTest(AbstractVectorTest): @@ -90,7 +91,7 @@ class TableStoreVectorTest(AbstractVectorTest): try: self.vector.delete() except Exception: - pass + logger.debug("Failed to delete vector store during test setup, it may not exist yet") return super().run_all_tests() diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/tablestore/test_tablestore_vector.py b/api/providers/vdb/vdb-tablestore/tests/unit_tests/test_tablestore_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/tablestore/test_tablestore_vector.py rename to api/providers/vdb/vdb-tablestore/tests/unit_tests/test_tablestore_vector.py index e3b6676d9b..62a11e0445 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/tablestore/test_tablestore_vector.py +++ b/api/providers/vdb/vdb-tablestore/tests/unit_tests/test_tablestore_vector.py @@ -81,7 +81,7 @@ def tablestore_module(monkeypatch): fake_module = _build_fake_tablestore_module() monkeypatch.setitem(sys.modules, "tablestore", fake_module) - import core.rag.datasource.vdb.tablestore.tablestore_vector as module + import dify_vdb_tablestore.tablestore_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-tencent/pyproject.toml b/api/providers/vdb/vdb-tencent/pyproject.toml new file mode 100644 index 0000000000..7bb761b169 --- /dev/null +++ b/api/providers/vdb/vdb-tencent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-tencent" +version = "0.0.1" + +dependencies = [ + "tcvectordb~=2.1.0", +] +description = "Dify vector store backend (dify-vdb-tencent)." + +[project.entry-points."dify.vector_backends"] +tencent = "dify_vdb_tencent.tencent_vector:TencentVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/tablestore/__init__.py b/api/providers/vdb/vdb-tencent/src/dify_vdb_tencent/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/tablestore/__init__.py rename to api/providers/vdb/vdb-tencent/src/dify_vdb_tencent/__init__.py diff --git a/api/core/rag/datasource/vdb/tencent/tencent_vector.py b/api/providers/vdb/vdb-tencent/src/dify_vdb_tencent/tencent_vector.py similarity index 100% rename from api/core/rag/datasource/vdb/tencent/tencent_vector.py rename to api/providers/vdb/vdb-tencent/src/dify_vdb_tencent/tencent_vector.py diff --git a/api/tests/integration_tests/vdb/__mock/tcvectordb.py b/api/providers/vdb/vdb-tencent/tests/integration_tests/conftest.py similarity index 100% rename from api/tests/integration_tests/vdb/__mock/tcvectordb.py rename to api/providers/vdb/vdb-tencent/tests/integration_tests/conftest.py diff --git a/api/tests/integration_tests/vdb/tcvectordb/test_tencent.py b/api/providers/vdb/vdb-tencent/tests/integration_tests/test_tencent.py similarity index 76% rename from api/tests/integration_tests/vdb/tcvectordb/test_tencent.py rename to api/providers/vdb/vdb-tencent/tests/integration_tests/test_tencent.py index 3d6deff2a0..a53ec87f92 100644 --- a/api/tests/integration_tests/vdb/tcvectordb/test_tencent.py +++ b/api/providers/vdb/vdb-tencent/tests/integration_tests/test_tencent.py @@ -1,12 +1,8 @@ from unittest.mock import MagicMock -from core.rag.datasource.vdb.tencent.tencent_vector import TencentConfig, TencentVector -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text +from dify_vdb_tencent.tencent_vector import TencentConfig, TencentVector -pytest_plugins = ( - "tests.integration_tests.vdb.test_vector_store", - "tests.integration_tests.vdb.__mock.tcvectordb", -) +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest, get_example_text mock_client = MagicMock() mock_client.list_databases.return_value = [{"name": "test"}] diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/tencent/test_tencent_vector.py b/api/providers/vdb/vdb-tencent/tests/unit_tests/test_tencent_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/tencent/test_tencent_vector.py rename to api/providers/vdb/vdb-tencent/tests/unit_tests/test_tencent_vector.py index d8f35a6019..299e40ee1e 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/tencent/test_tencent_vector.py +++ b/api/providers/vdb/vdb-tencent/tests/unit_tests/test_tencent_vector.py @@ -140,7 +140,7 @@ def tencent_module(monkeypatch): for name, module in _build_fake_tencent_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.tencent.tencent_vector as module + import dify_vdb_tencent.tencent_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/pyproject.toml b/api/providers/vdb/vdb-tidb-on-qdrant/pyproject.toml new file mode 100644 index 0000000000..5040fb38ba --- /dev/null +++ b/api/providers/vdb/vdb-tidb-on-qdrant/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-tidb-on-qdrant" +version = "0.0.1" + +dependencies = [ + "qdrant-client==1.9.0", +] +description = "Dify vector store backend (dify-vdb-tidb-on-qdrant)." + +[project.entry-points."dify.vector_backends"] +tidb_on_qdrant = "dify_vdb_tidb_on_qdrant.tidb_on_qdrant_vector:TidbOnQdrantVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/tencent/__init__.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/tencent/__init__.py rename to api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/__init__.py diff --git a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py similarity index 96% rename from api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py rename to api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py index c40d71bf11..98dd662b32 100644 --- a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py @@ -24,12 +24,12 @@ from sqlalchemy import select from configs import dify_config from core.rag.datasource.vdb.field import Field -from core.rag.datasource.vdb.tidb_on_qdrant.tidb_service import TidbService from core.rag.datasource.vdb.vector_base import BaseVector, VectorIndexStructDict from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType from core.rag.embedding.embedding_base import Embeddings from core.rag.models.document import Document +from dify_vdb_tidb_on_qdrant.tidb_service import TidbService from extensions.ext_database import db from extensions.ext_redis import redis_client from models.dataset import Dataset, TidbAuthBinding @@ -292,26 +292,27 @@ class TidbOnQdrantVector(BaseVector): if not ids: return - try: - filter = models.Filter( - must=[ - models.FieldCondition( - key="metadata.doc_id", - match=models.MatchAny(any=ids), - ), - ], - ) - self._client.delete( - collection_name=self._collection_name, - points_selector=FilterSelector(filter=filter), - ) - except UnexpectedResponse as e: - # Collection does not exist, so return - if e.status_code == 404: - return - # Some other error occurred, so re-raise the exception - else: - raise e + batch_size = 1000 + for i in range(0, len(ids), batch_size): + batch = ids[i : i + batch_size] + + try: + filter = models.Filter( + must=[ + models.FieldCondition( + key="metadata.doc_id", + match=models.MatchAny(any=batch), + ), + ], + ) + self._client.delete( + collection_name=self._collection_name, + points_selector=FilterSelector(filter=filter), + ) + except UnexpectedResponse as e: + # Collection does not exist, so return + if e.status_code != 404: + raise e def text_exists(self, id: str) -> bool: all_collection_name = [] diff --git a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py similarity index 100% rename from api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py rename to api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/tidb_on_qdrant/test_tidb_on_qdrant_vector.py b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py similarity index 96% rename from api/tests/unit_tests/core/rag/datasource/vdb/tidb_on_qdrant/test_tidb_on_qdrant_vector.py rename to api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py index c25af79ae4..3e9229fea5 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/tidb_on_qdrant/test_tidb_on_qdrant_vector.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py @@ -2,13 +2,12 @@ from unittest.mock import patch import httpx import pytest -from qdrant_client.http import models as rest -from qdrant_client.http.exceptions import UnexpectedResponse - -from core.rag.datasource.vdb.tidb_on_qdrant.tidb_on_qdrant_vector import ( +from dify_vdb_tidb_on_qdrant.tidb_on_qdrant_vector import ( TidbOnQdrantConfig, TidbOnQdrantVector, ) +from qdrant_client.http import models as rest +from qdrant_client.http.exceptions import UnexpectedResponse class TestTidbOnQdrantVectorDeleteByIds: @@ -22,7 +21,7 @@ class TestTidbOnQdrantVectorDeleteByIds: api_key="test_api_key", ) - with patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_on_qdrant_vector.qdrant_client.QdrantClient"): + with patch("dify_vdb_tidb_on_qdrant.tidb_on_qdrant_vector.qdrant_client.QdrantClient"): vector = TidbOnQdrantVector( collection_name="test_collection", group_id="test_group", diff --git a/api/providers/vdb/vdb-tidb-vector/pyproject.toml b/api/providers/vdb/vdb-tidb-vector/pyproject.toml new file mode 100644 index 0000000000..0e2f0ad88f --- /dev/null +++ b/api/providers/vdb/vdb-tidb-vector/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-tidb-vector" +version = "0.0.1" + +dependencies = [ + "tidb-vector==0.0.15", +] +description = "Dify vector store backend (dify-vdb-tidb-vector)." + +[project.entry-points."dify.vector_backends"] +tidb_vector = "dify_vdb_tidb_vector.tidb_vector:TiDBVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/tidb_on_qdrant/__init__.py b/api/providers/vdb/vdb-tidb-vector/src/dify_vdb_tidb_vector/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/tidb_on_qdrant/__init__.py rename to api/providers/vdb/vdb-tidb-vector/src/dify_vdb_tidb_vector/__init__.py diff --git a/api/core/rag/datasource/vdb/tidb_vector/tidb_vector.py b/api/providers/vdb/vdb-tidb-vector/src/dify_vdb_tidb_vector/tidb_vector.py similarity index 97% rename from api/core/rag/datasource/vdb/tidb_vector/tidb_vector.py rename to api/providers/vdb/vdb-tidb-vector/src/dify_vdb_tidb_vector/tidb_vector.py index c948917374..c696a685dd 100644 --- a/api/core/rag/datasource/vdb/tidb_vector/tidb_vector.py +++ b/api/providers/vdb/vdb-tidb-vector/src/dify_vdb_tidb_vector/tidb_vector.py @@ -6,7 +6,7 @@ import sqlalchemy from pydantic import BaseModel, model_validator from sqlalchemy import JSON, TEXT, Column, DateTime, String, Table, create_engine, insert from sqlalchemy import text as sql_text -from sqlalchemy.orm import Session, declarative_base +from sqlalchemy.orm import Session, declarative_base, sessionmaker from configs import dify_config from core.rag.datasource.vdb.field import Field, parse_metadata_json @@ -31,7 +31,7 @@ class TiDBVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config TIDB_VECTOR_HOST is required") if not values["port"]: @@ -97,8 +97,7 @@ class TiDBVector(BaseVector): if redis_client.get(collection_exist_cache_key): return tidb_dist_func = self._get_distance_func() - with Session(self._engine) as session: - session.begin() + with sessionmaker(bind=self._engine).begin() as session: create_statement = sql_text(f""" CREATE TABLE IF NOT EXISTS {self._collection_name} ( id CHAR(36) PRIMARY KEY, @@ -115,7 +114,6 @@ class TiDBVector(BaseVector): ); """) session.execute(create_statement) - session.commit() redis_client.set(collection_exist_cache_key, 1, ex=3600) def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs): @@ -238,9 +236,8 @@ class TiDBVector(BaseVector): return [] def delete(self): - with Session(self._engine) as session: + with sessionmaker(bind=self._engine).begin() as session: session.execute(sql_text(f"""DROP TABLE IF EXISTS {self._collection_name};""")) - session.commit() def _get_distance_func(self) -> str: match self._distance_func: diff --git a/api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py b/api/providers/vdb/vdb-tidb-vector/tests/integration_tests/check_tiflash_ready.py similarity index 72% rename from api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py rename to api/providers/vdb/vdb-tidb-vector/tests/integration_tests/check_tiflash_ready.py index f76700aa0e..97f8406e42 100644 --- a/api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py +++ b/api/providers/vdb/vdb-tidb-vector/tests/integration_tests/check_tiflash_ready.py @@ -1,9 +1,13 @@ +import logging import time import pymysql +logger = logging.getLogger(__name__) + def check_tiflash_ready() -> bool: + connection = None try: connection = pymysql.connect( host="localhost", @@ -23,8 +27,8 @@ def check_tiflash_ready() -> bool: cursor.execute(select_tiflash_query) result = cursor.fetchall() return result is not None and len(result) > 0 - except Exception as e: - print(f"TiFlash is not ready. Exception: {e}") + except Exception: + logger.exception("TiFlash is not ready.") return False finally: if connection: @@ -38,20 +42,20 @@ def main(): for attempt in range(max_attempts): try: is_tiflash_ready = check_tiflash_ready() - except Exception as e: - print(f"TiFlash is not ready. Exception: {e}") + except Exception: + logger.exception("TiFlash is not ready.") is_tiflash_ready = False if is_tiflash_ready: break else: - print(f"Attempt {attempt + 1} failed, retry in {retry_interval_seconds} seconds...") + logger.error("Attempt %s failed, retry in %s seconds...", attempt + 1, retry_interval_seconds) time.sleep(retry_interval_seconds) if is_tiflash_ready: - print("TiFlash is ready in TiDB.") + logger.info("TiFlash is ready in TiDB.") else: - print(f"TiFlash is not ready in TiDB after {max_attempts} attempting checks.") + logger.error("TiFlash is not ready in TiDB after %s attempting checks.", max_attempts) exit(1) diff --git a/api/tests/integration_tests/vdb/tidb_vector/test_tidb_vector.py b/api/providers/vdb/vdb-tidb-vector/tests/integration_tests/test_tidb_vector.py similarity index 77% rename from api/tests/integration_tests/vdb/tidb_vector/test_tidb_vector.py rename to api/providers/vdb/vdb-tidb-vector/tests/integration_tests/test_tidb_vector.py index 14c6d1c67c..ac854acbf9 100644 --- a/api/tests/integration_tests/vdb/tidb_vector/test_tidb_vector.py +++ b/api/providers/vdb/vdb-tidb-vector/tests/integration_tests/test_tidb_vector.py @@ -1,10 +1,8 @@ import pytest +from dify_vdb_tidb_vector.tidb_vector import TiDBVector, TiDBVectorConfig -from core.rag.datasource.vdb.tidb_vector.tidb_vector import TiDBVector, TiDBVectorConfig -from models.dataset import Document -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text - -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest, get_example_text +from core.rag.models.document import Document @pytest.fixture diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/tidb_vector/test_tidb_vector.py b/api/providers/vdb/vdb-tidb-vector/tests/unit_tests/test_tidb_vector.py similarity index 97% rename from api/tests/unit_tests/core/rag/datasource/vdb/tidb_vector/test_tidb_vector.py rename to api/providers/vdb/vdb-tidb-vector/tests/unit_tests/test_tidb_vector.py index 951a920f3b..bdbed2f740 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/tidb_vector/test_tidb_vector.py +++ b/api/providers/vdb/vdb-tidb-vector/tests/unit_tests/test_tidb_vector.py @@ -12,7 +12,7 @@ from core.rag.models.document import Document @pytest.fixture def tidb_module(): - import core.rag.datasource.vdb.tidb_vector.tidb_vector as module + import dify_vdb_tidb_vector.tidb_vector as module return importlib.reload(module) @@ -137,14 +137,15 @@ def test_create_collection_executes_create_sql_and_sets_cache(tidb_module, monke session = MagicMock() - class _SessionCtx: + class _BeginCtx: def __enter__(self): return session def __exit__(self, exc_type, exc, tb): return False - monkeypatch.setattr(tidb_module, "Session", lambda _engine: _SessionCtx()) + mock_sm = MagicMock(begin=MagicMock(return_value=_BeginCtx())) + monkeypatch.setattr(tidb_module, "sessionmaker", lambda **kwargs: mock_sm) vector = tidb_module.TiDBVector.__new__(tidb_module.TiDBVector) vector._collection_name = "collection_1" @@ -153,11 +154,9 @@ def test_create_collection_executes_create_sql_and_sets_cache(tidb_module, monke vector._create_collection(3) - session.begin.assert_called_once() sql = str(session.execute.call_args.args[0]) assert "VECTOR(3)" in sql assert "VEC_L2_DISTANCE" in sql - session.commit.assert_called_once() tidb_module.redis_client.set.assert_called_once() @@ -396,23 +395,22 @@ def test_search_by_vector_filters_and_scores(tidb_module, monkeypatch): def test_delete_drops_table(tidb_module, monkeypatch): session = MagicMock() session.execute.return_value = None - session.commit = MagicMock() - class _SessionCtx: + class _BeginCtx: def __enter__(self): return session def __exit__(self, exc_type, exc, tb): return False - monkeypatch.setattr(tidb_module, "Session", lambda _engine: _SessionCtx()) + mock_sm = MagicMock(begin=MagicMock(return_value=_BeginCtx())) + monkeypatch.setattr(tidb_module, "sessionmaker", lambda **kwargs: mock_sm) vector = tidb_module.TiDBVector.__new__(tidb_module.TiDBVector) vector._collection_name = "collection_1" vector._engine = MagicMock() vector.delete() drop_sql = str(session.execute.call_args.args[0]) assert "DROP TABLE IF EXISTS collection_1" in drop_sql - session.commit.assert_called_once() def test_tidb_factory_uses_existing_or_generated_collection(tidb_module, monkeypatch): diff --git a/api/providers/vdb/vdb-upstash/pyproject.toml b/api/providers/vdb/vdb-upstash/pyproject.toml new file mode 100644 index 0000000000..f71773cdbb --- /dev/null +++ b/api/providers/vdb/vdb-upstash/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-upstash" +version = "0.0.1" + +dependencies = [ + "upstash-vector==0.8.0", +] +description = "Dify vector store backend (dify-vdb-upstash)." + +[project.entry-points."dify.vector_backends"] +upstash = "dify_vdb_upstash.upstash_vector:UpstashVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/tidb_vector/__init__.py b/api/providers/vdb/vdb-upstash/src/dify_vdb_upstash/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/tidb_vector/__init__.py rename to api/providers/vdb/vdb-upstash/src/dify_vdb_upstash/__init__.py diff --git a/api/core/rag/datasource/vdb/upstash/upstash_vector.py b/api/providers/vdb/vdb-upstash/src/dify_vdb_upstash/upstash_vector.py similarity index 98% rename from api/core/rag/datasource/vdb/upstash/upstash_vector.py rename to api/providers/vdb/vdb-upstash/src/dify_vdb_upstash/upstash_vector.py index 289d971853..75d70a1964 100644 --- a/api/core/rag/datasource/vdb/upstash/upstash_vector.py +++ b/api/providers/vdb/vdb-upstash/src/dify_vdb_upstash/upstash_vector.py @@ -20,7 +20,7 @@ class UpstashVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["url"]: raise ValueError("Upstash URL is required") if not values["token"]: diff --git a/api/tests/integration_tests/vdb/__mock/upstashvectordb.py b/api/providers/vdb/vdb-upstash/tests/integration_tests/conftest.py similarity index 94% rename from api/tests/integration_tests/vdb/__mock/upstashvectordb.py rename to api/providers/vdb/vdb-upstash/tests/integration_tests/conftest.py index 70c85d4c98..adba0c150c 100644 --- a/api/tests/integration_tests/vdb/__mock/upstashvectordb.py +++ b/api/providers/vdb/vdb-upstash/tests/integration_tests/conftest.py @@ -6,7 +6,6 @@ from _pytest.monkeypatch import MonkeyPatch from upstash_vector import Index -# Mocking the Index class from upstash_vector class MockIndex: def __init__(self, url="", token=""): self.url = url @@ -37,7 +36,6 @@ class MockIndex: namespace: str = "", include_data: bool = False, ): - # Simple mock query, in real scenario you would calculate similarity mock_result = [] for vector_data in self.vectors: mock_result.append(vector_data) diff --git a/api/tests/integration_tests/vdb/upstash/test_upstash_vector.py b/api/providers/vdb/vdb-upstash/tests/integration_tests/test_upstash_vector.py similarity index 75% rename from api/tests/integration_tests/vdb/upstash/test_upstash_vector.py rename to api/providers/vdb/vdb-upstash/tests/integration_tests/test_upstash_vector.py index 8cea0a05eb..f4a65030b6 100644 --- a/api/tests/integration_tests/vdb/upstash/test_upstash_vector.py +++ b/api/providers/vdb/vdb-upstash/tests/integration_tests/test_upstash_vector.py @@ -1,8 +1,7 @@ -from core.rag.datasource.vdb.upstash.upstash_vector import UpstashVector, UpstashVectorConfig -from core.rag.models.document import Document -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text +from dify_vdb_upstash.upstash_vector import UpstashVector, UpstashVectorConfig -pytest_plugins = ("tests.integration_tests.vdb.__mock.upstashvectordb",) +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest, get_example_text +from core.rag.models.document import Document class UpstashVectorTest(AbstractVectorTest): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/upstash/test_upstash_vector.py b/api/providers/vdb/vdb-upstash/tests/unit_tests/test_upstash_vector.py similarity index 97% rename from api/tests/unit_tests/core/rag/datasource/vdb/upstash/test_upstash_vector.py rename to api/providers/vdb/vdb-upstash/tests/unit_tests/test_upstash_vector.py index ac8a63a44b..a884275c89 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/upstash/test_upstash_vector.py +++ b/api/providers/vdb/vdb-upstash/tests/unit_tests/test_upstash_vector.py @@ -38,11 +38,11 @@ def _build_fake_upstash_module(): @pytest.fixture def upstash_module(monkeypatch): # Remove patched modules if present - for modname in ["upstash_vector", "core.rag.datasource.vdb.upstash.upstash_vector"]: + for modname in ["upstash_vector", "dify_vdb_upstash.upstash_vector"]: if modname in sys.modules: monkeypatch.delitem(sys.modules, modname, raising=False) monkeypatch.setitem(sys.modules, "upstash_vector", _build_fake_upstash_module()) - module = importlib.import_module("core.rag.datasource.vdb.upstash.upstash_vector") + module = importlib.import_module("dify_vdb_upstash.upstash_vector") return module diff --git a/api/providers/vdb/vdb-vastbase/pyproject.toml b/api/providers/vdb/vdb-vastbase/pyproject.toml new file mode 100644 index 0000000000..287eb147dc --- /dev/null +++ b/api/providers/vdb/vdb-vastbase/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-vastbase" +version = "0.0.1" + +dependencies = [ + "pyobvector~=0.2.17", +] +description = "Dify vector store backend (dify-vdb-vastbase)." + +[project.entry-points."dify.vector_backends"] +vastbase = "dify_vdb_vastbase.vastbase_vector:VastbaseVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/upstash/__init__.py b/api/providers/vdb/vdb-vastbase/src/dify_vdb_vastbase/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/upstash/__init__.py rename to api/providers/vdb/vdb-vastbase/src/dify_vdb_vastbase/__init__.py diff --git a/api/core/rag/datasource/vdb/pyvastbase/vastbase_vector.py b/api/providers/vdb/vdb-vastbase/src/dify_vdb_vastbase/vastbase_vector.py similarity index 99% rename from api/core/rag/datasource/vdb/pyvastbase/vastbase_vector.py rename to api/providers/vdb/vdb-vastbase/src/dify_vdb_vastbase/vastbase_vector.py index d080e8da58..ab00f9db28 100644 --- a/api/core/rag/datasource/vdb/pyvastbase/vastbase_vector.py +++ b/api/providers/vdb/vdb-vastbase/src/dify_vdb_vastbase/vastbase_vector.py @@ -28,7 +28,7 @@ class VastbaseVectorConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict): + def validate_config(cls, values: dict[str, Any]): if not values["host"]: raise ValueError("config VASTBASE_HOST is required") if not values["port"]: diff --git a/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py b/api/providers/vdb/vdb-vastbase/tests/integration_tests/test_vastbase_vector.py similarity index 72% rename from api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py rename to api/providers/vdb/vdb-vastbase/tests/integration_tests/test_vastbase_vector.py index a47f13625c..0467dec37a 100644 --- a/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py +++ b/api/providers/vdb/vdb-vastbase/tests/integration_tests/test_vastbase_vector.py @@ -1,10 +1,9 @@ -from core.rag.datasource.vdb.pyvastbase.vastbase_vector import VastbaseVector, VastbaseVectorConfig -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_vastbase.vastbase_vector import VastbaseVector, VastbaseVectorConfig + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class VastbaseVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/pyvastbase/test_vastbase_vector.py b/api/providers/vdb/vdb-vastbase/tests/unit_tests/test_vastbase_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/pyvastbase/test_vastbase_vector.py rename to api/providers/vdb/vdb-vastbase/tests/unit_tests/test_vastbase_vector.py index bd8df520ba..4dfb956c00 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/pyvastbase/test_vastbase_vector.py +++ b/api/providers/vdb/vdb-vastbase/tests/unit_tests/test_vastbase_vector.py @@ -41,7 +41,7 @@ def vastbase_module(monkeypatch): for name, module in _build_fake_psycopg2_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.pyvastbase.vastbase_vector as module + import dify_vdb_vastbase.vastbase_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-vikingdb/pyproject.toml b/api/providers/vdb/vdb-vikingdb/pyproject.toml new file mode 100644 index 0000000000..fdf59f76a4 --- /dev/null +++ b/api/providers/vdb/vdb-vikingdb/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-vikingdb" +version = "0.0.1" + +dependencies = [ + "volcengine-compat~=1.0.0", +] +description = "Dify vector store backend (dify-vdb-vikingdb)." + +[project.entry-points."dify.vector_backends"] +vikingdb = "dify_vdb_vikingdb.vikingdb_vector:VikingDBVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/vikingdb/__init__.py b/api/providers/vdb/vdb-vikingdb/src/dify_vdb_vikingdb/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/vikingdb/__init__.py rename to api/providers/vdb/vdb-vikingdb/src/dify_vdb_vikingdb/__init__.py diff --git a/api/core/rag/datasource/vdb/vikingdb/vikingdb_vector.py b/api/providers/vdb/vdb-vikingdb/src/dify_vdb_vikingdb/vikingdb_vector.py similarity index 100% rename from api/core/rag/datasource/vdb/vikingdb/vikingdb_vector.py rename to api/providers/vdb/vdb-vikingdb/src/dify_vdb_vikingdb/vikingdb_vector.py diff --git a/api/tests/integration_tests/vdb/__mock/vikingdb.py b/api/providers/vdb/vdb-vikingdb/tests/integration_tests/conftest.py similarity index 100% rename from api/tests/integration_tests/vdb/__mock/vikingdb.py rename to api/providers/vdb/vdb-vikingdb/tests/integration_tests/conftest.py diff --git a/api/tests/integration_tests/vdb/vikingdb/test_vikingdb.py b/api/providers/vdb/vdb-vikingdb/tests/integration_tests/test_vikingdb.py similarity index 78% rename from api/tests/integration_tests/vdb/vikingdb/test_vikingdb.py rename to api/providers/vdb/vdb-vikingdb/tests/integration_tests/test_vikingdb.py index 56311acd25..5a3908d14b 100644 --- a/api/tests/integration_tests/vdb/vikingdb/test_vikingdb.py +++ b/api/providers/vdb/vdb-vikingdb/tests/integration_tests/test_vikingdb.py @@ -1,10 +1,6 @@ -from core.rag.datasource.vdb.vikingdb.vikingdb_vector import VikingDBConfig, VikingDBVector -from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text +from dify_vdb_vikingdb.vikingdb_vector import VikingDBConfig, VikingDBVector -pytest_plugins = ( - "tests.integration_tests.vdb.test_vector_store", - "tests.integration_tests.vdb.__mock.vikingdb", -) +from core.rag.datasource.vdb.vector_integration_test_support import AbstractVectorTest, get_example_text class VikingDBVectorTest(AbstractVectorTest): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/vikingdb/test_vikingdb_vector.py b/api/providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py similarity index 99% rename from api/tests/unit_tests/core/rag/datasource/vdb/vikingdb/test_vikingdb_vector.py rename to api/providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py index 9da92af2d0..544b8163be 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/vikingdb/test_vikingdb_vector.py +++ b/api/providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py @@ -83,7 +83,7 @@ def vikingdb_module(monkeypatch): for name, module in _build_fake_vikingdb_modules().items(): monkeypatch.setitem(sys.modules, name, module) - import core.rag.datasource.vdb.vikingdb.vikingdb_vector as module + import dify_vdb_vikingdb.vikingdb_vector as module return importlib.reload(module) diff --git a/api/providers/vdb/vdb-weaviate/pyproject.toml b/api/providers/vdb/vdb-weaviate/pyproject.toml new file mode 100644 index 0000000000..035fbd396d --- /dev/null +++ b/api/providers/vdb/vdb-weaviate/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "dify-vdb-weaviate" +version = "0.0.1" + +dependencies = [ + "weaviate-client==4.20.5", +] +description = "Dify vector store backend (dify-vdb-weaviate)." + +[project.entry-points."dify.vector_backends"] +weaviate = "dify_vdb_weaviate.weaviate_vector:WeaviateVectorFactory" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/api/core/rag/datasource/vdb/weaviate/__init__.py b/api/providers/vdb/vdb-weaviate/src/dify_vdb_weaviate/__init__.py similarity index 100% rename from api/core/rag/datasource/vdb/weaviate/__init__.py rename to api/providers/vdb/vdb-weaviate/src/dify_vdb_weaviate/__init__.py diff --git a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py b/api/providers/vdb/vdb-weaviate/src/dify_vdb_weaviate/weaviate_vector.py similarity index 90% rename from api/core/rag/datasource/vdb/weaviate/weaviate_vector.py rename to api/providers/vdb/vdb-weaviate/src/dify_vdb_weaviate/weaviate_vector.py index 25b65b82a9..902e6a03a8 100644 --- a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py +++ b/api/providers/vdb/vdb-weaviate/src/dify_vdb_weaviate/weaviate_vector.py @@ -20,7 +20,7 @@ from pydantic import BaseModel, model_validator from weaviate.classes.data import DataObject from weaviate.classes.init import Auth from weaviate.classes.query import Filter, MetadataQuery -from weaviate.exceptions import UnexpectedStatusCodeError +from weaviate.exceptions import UnexpectedStatusCodeError, WeaviateQueryError from configs import dify_config from core.rag.datasource.vdb.field import Field @@ -82,7 +82,7 @@ class WeaviateConfig(BaseModel): @model_validator(mode="before") @classmethod - def validate_config(cls, values: dict) -> dict: + def validate_config(cls, values: dict[str, Any]) -> dict[str, Any]: """Validates that required configuration values are present.""" if not values["endpoint"]: raise ValueError("config WEAVIATE_ENDPOINT is required") @@ -230,6 +230,8 @@ class WeaviateVector(BaseVector): wc.Property(name="doc_id", data_type=wc.DataType.TEXT), wc.Property(name="doc_type", data_type=wc.DataType.TEXT), wc.Property(name="chunk_index", data_type=wc.DataType.INT), + wc.Property(name="is_summary", data_type=wc.DataType.BOOL), + wc.Property(name="original_chunk_id", data_type=wc.DataType.TEXT), ], vector_config=wc.Configure.Vectors.self_provided(), ) @@ -262,6 +264,10 @@ class WeaviateVector(BaseVector): to_add.append(wc.Property(name="doc_type", data_type=wc.DataType.TEXT)) if "chunk_index" not in existing: to_add.append(wc.Property(name="chunk_index", data_type=wc.DataType.INT)) + if "is_summary" not in existing: + to_add.append(wc.Property(name="is_summary", data_type=wc.DataType.BOOL)) + if "original_chunk_id" not in existing: + to_add.append(wc.Property(name="original_chunk_id", data_type=wc.DataType.TEXT)) for prop in to_add: try: @@ -400,15 +406,27 @@ class WeaviateVector(BaseVector): top_k = int(kwargs.get("top_k", 4)) score_threshold = float(kwargs.get("score_threshold") or 0.0) - res = col.query.near_vector( - near_vector=query_vector, - limit=top_k, - return_properties=props, - return_metadata=MetadataQuery(distance=True), - include_vector=False, - filters=where, - target_vector="default", - ) + try: + res = col.query.near_vector( + near_vector=query_vector, + limit=top_k, + return_properties=props, + return_metadata=MetadataQuery(distance=True), + include_vector=False, + filters=where, + target_vector="default", + ) + except WeaviateQueryError: + self._ensure_properties() + res = col.query.near_vector( + near_vector=query_vector, + limit=top_k, + return_properties=props, + return_metadata=MetadataQuery(distance=True), + include_vector=False, + filters=where, + target_vector="default", + ) docs: list[Document] = [] for obj in res.objects: @@ -446,14 +464,25 @@ class WeaviateVector(BaseVector): top_k = int(kwargs.get("top_k", 4)) - res = col.query.bm25( - query=query, - query_properties=[Field.TEXT_KEY.value], - limit=top_k, - return_properties=props, - include_vector=True, - filters=where, - ) + try: + res = col.query.bm25( + query=query, + query_properties=[Field.TEXT_KEY.value], + limit=top_k, + return_properties=props, + include_vector=True, + filters=where, + ) + except WeaviateQueryError: + self._ensure_properties() + res = col.query.bm25( + query=query, + query_properties=[Field.TEXT_KEY.value], + limit=top_k, + return_properties=props, + include_vector=True, + filters=where, + ) docs: list[Document] = [] for obj in res.objects: diff --git a/api/tests/integration_tests/vdb/weaviate/test_weaviate.py b/api/providers/vdb/vdb-weaviate/tests/integration_tests/test_weaviate.py similarity index 72% rename from api/tests/integration_tests/vdb/weaviate/test_weaviate.py rename to api/providers/vdb/vdb-weaviate/tests/integration_tests/test_weaviate.py index a1d9850979..631d23d653 100644 --- a/api/tests/integration_tests/vdb/weaviate/test_weaviate.py +++ b/api/providers/vdb/vdb-weaviate/tests/integration_tests/test_weaviate.py @@ -1,10 +1,9 @@ -from core.rag.datasource.vdb.weaviate.weaviate_vector import WeaviateConfig, WeaviateVector -from tests.integration_tests.vdb.test_vector_store import ( +from dify_vdb_weaviate.weaviate_vector import WeaviateConfig, WeaviateVector + +from core.rag.datasource.vdb.vector_integration_test_support import ( AbstractVectorTest, ) -pytest_plugins = ("tests.integration_tests.vdb.test_vector_store",) - class WeaviateVectorTest(AbstractVectorTest): def __init__(self): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/weaviate/test_weavaite.py b/api/providers/vdb/vdb-weaviate/tests/unit_tests/test_weavaite.py similarity index 92% rename from api/tests/unit_tests/core/rag/datasource/vdb/weaviate/test_weavaite.py rename to api/providers/vdb/vdb-weaviate/tests/unit_tests/test_weavaite.py index baf8c9e5f8..c773e4d552 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/weaviate/test_weavaite.py +++ b/api/providers/vdb/vdb-weaviate/tests/unit_tests/test_weavaite.py @@ -1,6 +1,6 @@ from unittest.mock import MagicMock, patch -from core.rag.datasource.vdb.weaviate.weaviate_vector import WeaviateConfig, WeaviateVector +from dify_vdb_weaviate.weaviate_vector import WeaviateConfig, WeaviateVector def test_init_client_with_valid_config(): diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/weaviate/test_weaviate_vector.py b/api/providers/vdb/vdb-weaviate/tests/unit_tests/test_weaviate_vector.py similarity index 84% rename from api/tests/unit_tests/core/rag/datasource/vdb/weaviate/test_weaviate_vector.py rename to api/providers/vdb/vdb-weaviate/tests/unit_tests/test_weaviate_vector.py index 69d1833001..b40f7e52ca 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/weaviate/test_weaviate_vector.py +++ b/api/providers/vdb/vdb-weaviate/tests/unit_tests/test_weaviate_vector.py @@ -14,9 +14,9 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from dify_vdb_weaviate import weaviate_vector as weaviate_vector_module +from dify_vdb_weaviate.weaviate_vector import WeaviateConfig, WeaviateVector -from core.rag.datasource.vdb.weaviate import weaviate_vector as weaviate_vector_module -from core.rag.datasource.vdb.weaviate.weaviate_vector import WeaviateConfig, WeaviateVector from core.rag.models.document import Document @@ -40,7 +40,7 @@ class TestWeaviateVector(unittest.TestCase): with pytest.raises(ValueError, match="config WEAVIATE_ENDPOINT is required"): WeaviateConfig(endpoint="") - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def _create_weaviate_vector(self, mock_weaviate_module): """Helper to create a WeaviateVector instance with mocked client.""" mock_client = MagicMock() @@ -66,7 +66,7 @@ class TestWeaviateVector(unittest.TestCase): mock_client.close.assert_called_once() mock_debug.assert_called_once() - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate.connect_to_custom") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate.connect_to_custom") def test_init_client_reuses_cached_client_without_reconnect(self, mock_connect): cached_client = MagicMock() cached_client.is_ready.return_value = True @@ -79,7 +79,7 @@ class TestWeaviateVector(unittest.TestCase): assert client is cached_client mock_connect.assert_not_called() - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate.connect_to_custom") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate.connect_to_custom") def test_init_client_reuses_cached_client_after_lock_recheck(self, mock_connect): cached_client = MagicMock() cached_client.is_ready.side_effect = [False, True] @@ -92,8 +92,8 @@ class TestWeaviateVector(unittest.TestCase): assert client is cached_client mock_connect.assert_not_called() - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.Auth.api_key", return_value="auth-token") - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate.connect_to_custom") + @patch("dify_vdb_weaviate.weaviate_vector.Auth.api_key", return_value="auth-token") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate.connect_to_custom") def test_init_client_parses_custom_grpc_endpoint_without_scheme(self, mock_connect, mock_api_key): mock_client = MagicMock() mock_client.is_ready.return_value = True @@ -122,7 +122,7 @@ class TestWeaviateVector(unittest.TestCase): } mock_api_key.assert_called_once_with("test-key") - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate.connect_to_custom") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate.connect_to_custom") def test_init_client_raises_when_database_not_ready(self, mock_connect): mock_client = MagicMock() mock_client.is_ready.return_value = False @@ -133,7 +133,7 @@ class TestWeaviateVector(unittest.TestCase): with pytest.raises(ConnectionError, match="Vector database is not ready"): wv._init_client(self.config) - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_init(self, mock_weaviate_module): """Test WeaviateVector initialization stores attributes including doc_type.""" mock_client = MagicMock() @@ -183,9 +183,9 @@ class TestWeaviateVector(unittest.TestCase): wv._create_collection.assert_called_once() wv.add_texts.assert_called_once_with([doc], [[0.1, 0.2]]) - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.redis_client") - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.dify_config") - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.redis_client") + @patch("dify_vdb_weaviate.weaviate_vector.dify_config") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_create_collection_includes_doc_type_property(self, mock_weaviate_module, mock_dify_config, mock_redis): """Test that _create_collection defines doc_type in the schema properties.""" # Mock Redis @@ -232,7 +232,7 @@ class TestWeaviateVector(unittest.TestCase): f"doc_type should be in collection schema properties, got: {property_names}" ) - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.redis_client") + @patch("dify_vdb_weaviate.weaviate_vector.redis_client") def test_create_collection_returns_early_when_cache_key_exists(self, mock_redis): mock_lock = MagicMock() mock_lock.__enter__ = MagicMock() @@ -251,7 +251,7 @@ class TestWeaviateVector(unittest.TestCase): wv._ensure_properties.assert_not_called() mock_redis.set.assert_not_called() - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.redis_client") + @patch("dify_vdb_weaviate.weaviate_vector.redis_client") def test_create_collection_logs_and_reraises_errors(self, mock_redis): mock_lock = MagicMock() mock_lock.__enter__ = MagicMock() @@ -270,7 +270,7 @@ class TestWeaviateVector(unittest.TestCase): mock_exception.assert_called_once() - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_ensure_properties_adds_missing_doc_type(self, mock_weaviate_module): """Test that _ensure_properties adds doc_type when it's missing from existing schema.""" mock_client = MagicMock() @@ -305,7 +305,7 @@ class TestWeaviateVector(unittest.TestCase): added_names = [call.args[0].name for call in add_calls] assert "doc_type" in added_names, f"doc_type should be added to existing collection, added: {added_names}" - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_ensure_properties_adds_all_missing_core_properties(self, mock_weaviate_module): mock_client = MagicMock() mock_client.is_ready.return_value = True @@ -326,9 +326,9 @@ class TestWeaviateVector(unittest.TestCase): add_calls = mock_col.config.add_property.call_args_list added_names = [call.args[0].name for call in add_calls] - assert added_names == ["document_id", "doc_id", "doc_type", "chunk_index"] + assert added_names == ["document_id", "doc_id", "doc_type", "chunk_index", "is_summary", "original_chunk_id"] - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_ensure_properties_skips_existing_doc_type(self, mock_weaviate_module): """Test that _ensure_properties does not add doc_type when it already exists.""" mock_client = MagicMock() @@ -346,6 +346,8 @@ class TestWeaviateVector(unittest.TestCase): SimpleNamespace(name="doc_id"), SimpleNamespace(name="doc_type"), SimpleNamespace(name="chunk_index"), + SimpleNamespace(name="is_summary"), + SimpleNamespace(name="original_chunk_id"), ] mock_cfg = MagicMock() mock_cfg.properties = existing_props @@ -361,7 +363,7 @@ class TestWeaviateVector(unittest.TestCase): # No properties should be added mock_col.config.add_property.assert_not_called() - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_ensure_properties_logs_warning_when_property_addition_fails(self, mock_weaviate_module): mock_client = MagicMock() mock_client.is_ready.return_value = True @@ -383,9 +385,9 @@ class TestWeaviateVector(unittest.TestCase): with patch.object(weaviate_vector_module.logger, "warning") as mock_warning: wv._ensure_properties() - assert mock_warning.call_count == 4 + assert mock_warning.call_count == 6 - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_search_by_vector_returns_doc_type_in_metadata(self, mock_weaviate_module): """Test that search_by_vector returns doc_type in document metadata. @@ -432,7 +434,7 @@ class TestWeaviateVector(unittest.TestCase): assert len(docs) == 1 assert docs[0].metadata.get("doc_type") == "image" - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_search_by_vector_uses_document_filter_and_default_distance(self, mock_weaviate_module): mock_client = MagicMock() mock_client.is_ready.return_value = True @@ -469,7 +471,7 @@ class TestWeaviateVector(unittest.TestCase): assert docs[0].metadata["score"] == 0.0 assert mock_col.query.near_vector.call_args.kwargs["filters"] is not None - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_search_by_vector_returns_empty_when_collection_is_missing(self, mock_weaviate_module): mock_client = MagicMock() mock_client.is_ready.return_value = True @@ -484,7 +486,57 @@ class TestWeaviateVector(unittest.TestCase): assert wv.search_by_vector(query_vector=[0.1] * 3) == [] - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") + def test_search_by_vector_retries_on_weaviate_query_error(self, mock_weaviate_module): + """Test that search_by_vector catches WeaviateQueryError, calls _ensure_properties, and retries.""" + from weaviate.exceptions import WeaviateQueryError + + mock_client = MagicMock() + mock_client.is_ready.return_value = True + mock_weaviate_module.connect_to_custom.return_value = mock_client + + mock_client.collections.exists.return_value = True + mock_col = MagicMock() + mock_client.collections.use.return_value = mock_col + + # First call raises WeaviateQueryError, second call succeeds + mock_obj = MagicMock() + mock_obj.properties = {"text": "retry result", "document_id": "doc-1"} + mock_obj.metadata.distance = 0.2 + + mock_result = MagicMock() + mock_result.objects = [mock_obj] + + mock_col.query.near_vector.side_effect = [ + WeaviateQueryError("missing property", "gRPC"), + mock_result, + ] + + # Mock _ensure_properties dependencies + mock_cfg = MagicMock() + mock_cfg.properties = [ + SimpleNamespace(name="text"), + SimpleNamespace(name="document_id"), + SimpleNamespace(name="doc_id"), + SimpleNamespace(name="doc_type"), + SimpleNamespace(name="chunk_index"), + SimpleNamespace(name="is_summary"), + SimpleNamespace(name="original_chunk_id"), + ] + mock_col.config.get.return_value = mock_cfg + + wv = WeaviateVector( + collection_name=self.collection_name, + config=self.config, + attributes=self.attributes, + ) + docs = wv.search_by_vector(query_vector=[0.1] * 3, top_k=1) + + assert mock_col.query.near_vector.call_count == 2 + assert len(docs) == 1 + assert docs[0].metadata["score"] == pytest.approx(0.8) + + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_search_by_full_text_returns_doc_type_in_metadata(self, mock_weaviate_module): """Test that search_by_full_text also returns doc_type in document metadata.""" mock_client = MagicMock() @@ -526,7 +578,7 @@ class TestWeaviateVector(unittest.TestCase): assert len(docs) == 1 assert docs[0].metadata.get("doc_type") == "image" - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_search_by_full_text_uses_document_filter(self, mock_weaviate_module): mock_client = MagicMock() mock_client.is_ready.return_value = True @@ -554,7 +606,7 @@ class TestWeaviateVector(unittest.TestCase): assert docs[0].vector == [0.3, 0.4] assert mock_col.query.bm25.call_args.kwargs["filters"] is not None - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_search_by_full_text_returns_empty_when_collection_is_missing(self, mock_weaviate_module): mock_client = MagicMock() mock_client.is_ready.return_value = True @@ -569,7 +621,57 @@ class TestWeaviateVector(unittest.TestCase): assert wv.search_by_full_text(query="missing") == [] - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") + def test_search_by_full_text_retries_on_weaviate_query_error(self, mock_weaviate_module): + """Test that search_by_full_text catches WeaviateQueryError, calls _ensure_properties, and retries.""" + from weaviate.exceptions import WeaviateQueryError + + mock_client = MagicMock() + mock_client.is_ready.return_value = True + mock_weaviate_module.connect_to_custom.return_value = mock_client + + mock_client.collections.exists.return_value = True + mock_col = MagicMock() + mock_client.collections.use.return_value = mock_col + + # First call raises WeaviateQueryError, second call succeeds + mock_obj = MagicMock() + mock_obj.properties = {"text": "retry bm25 result", "doc_id": "segment-1"} + mock_obj.vector = {"default": [0.5, 0.6]} + + mock_result = MagicMock() + mock_result.objects = [mock_obj] + + mock_col.query.bm25.side_effect = [ + WeaviateQueryError("missing property", "gRPC"), + mock_result, + ] + + # Mock _ensure_properties dependencies + mock_cfg = MagicMock() + mock_cfg.properties = [ + SimpleNamespace(name="text"), + SimpleNamespace(name="document_id"), + SimpleNamespace(name="doc_id"), + SimpleNamespace(name="doc_type"), + SimpleNamespace(name="chunk_index"), + SimpleNamespace(name="is_summary"), + SimpleNamespace(name="original_chunk_id"), + ] + mock_col.config.get.return_value = mock_cfg + + wv = WeaviateVector( + collection_name=self.collection_name, + config=self.config, + attributes=self.attributes, + ) + docs = wv.search_by_full_text(query="retry", top_k=1) + + assert mock_col.query.bm25.call_count == 2 + assert len(docs) == 1 + assert docs[0].page_content == "retry bm25 result" + + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_add_texts_stores_doc_type_in_properties(self, mock_weaviate_module): """Test that add_texts includes doc_type from document metadata in stored properties.""" mock_client = MagicMock() @@ -611,7 +713,7 @@ class TestWeaviateVector(unittest.TestCase): stored_props = call_kwargs.kwargs.get("properties") assert stored_props.get("doc_type") == "image", f"doc_type should be stored in properties, got: {stored_props}" - @patch("core.rag.datasource.vdb.weaviate.weaviate_vector.weaviate") + @patch("dify_vdb_weaviate.weaviate_vector.weaviate") def test_add_texts_falls_back_to_random_uuid_and_serializes_datetime_metadata(self, mock_weaviate_module): mock_client = MagicMock() mock_client.is_ready.return_value = True @@ -635,7 +737,7 @@ class TestWeaviateVector(unittest.TestCase): with ( patch.object(wv, "_get_uuids", return_value=["not-a-uuid"]), - patch("core.rag.datasource.vdb.weaviate.weaviate_vector._uuid.uuid4", return_value="fallback-uuid"), + patch("dify_vdb_weaviate.weaviate_vector._uuid.uuid4", return_value="fallback-uuid"), ): ids = wv.add_texts(documents=[doc], embeddings=[[]]) @@ -775,9 +877,7 @@ class TestWeaviateVectorFactory(unittest.TestCase): patch.object(weaviate_vector_module.dify_config, "WEAVIATE_GRPC_ENDPOINT", "localhost:50051"), patch.object(weaviate_vector_module.dify_config, "WEAVIATE_API_KEY", "api-key"), patch.object(weaviate_vector_module.dify_config, "WEAVIATE_BATCH_SIZE", 88), - patch( - "core.rag.datasource.vdb.weaviate.weaviate_vector.WeaviateVector", return_value="vector" - ) as mock_vector, + patch("dify_vdb_weaviate.weaviate_vector.WeaviateVector", return_value="vector") as mock_vector, ): factory = weaviate_vector_module.WeaviateVectorFactory() result = factory.init_vector(dataset, attributes, MagicMock()) @@ -806,9 +906,7 @@ class TestWeaviateVectorFactory(unittest.TestCase): "gen_collection_name_by_id", return_value="GeneratedCollection_Node", ), - patch( - "core.rag.datasource.vdb.weaviate.weaviate_vector.WeaviateVector", return_value="vector" - ) as mock_vector, + patch("dify_vdb_weaviate.weaviate_vector.WeaviateVector", return_value="vector") as mock_vector, ): factory = weaviate_vector_module.WeaviateVectorFactory() result = factory.init_vector(dataset, attributes, MagicMock()) diff --git a/api/pyproject.toml b/api/pyproject.toml index dab420fc87..f22bafb03a 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -4,76 +4,76 @@ version = "1.13.3" requires-python = "~=3.12.0" dependencies = [ - "aliyun-log-python-sdk~=0.9.37", + "aliyun-log-python-sdk~=0.9.44", "arize-phoenix-otel~=0.15.0", "azure-identity==1.25.3", "beautifulsoup4==4.14.3", - "boto3==1.42.83", + "boto3==1.42.88", "bs4~=0.0.1", - "cachetools~=5.3.0", - "celery~=5.6.2", - "charset-normalizer>=3.4.4", - "flask~=3.1.2", - "flask-compress>=1.17,<1.25", - "flask-cors~=6.0.0", + "cachetools~=7.0.5", + "celery~=5.6.3", + "charset-normalizer>=3.4.7", + "flask~=3.1.3", + "flask-compress>=1.24,<1.25", + "flask-cors~=6.0.2", "flask-login~=0.6.3", "flask-migrate~=4.1.0", "flask-orjson~=2.0.0", "flask-sqlalchemy~=3.1.1", - "gevent~=25.9.1", + "gevent~=26.4.0", "gmpy2~=2.3.0", - "google-api-core>=2.19.1", - "google-api-python-client==2.193.0", - "google-auth>=2.47.0", + "google-api-core>=2.30.3", + "google-api-python-client==2.194.0", + "google-auth>=2.49.2", "google-auth-httplib2==0.3.1", - "google-cloud-aiplatform>=1.123.0", - "googleapis-common-protos>=1.65.0", + "google-cloud-aiplatform>=1.147.0", + "googleapis-common-protos>=1.74.0", "graphon>=0.1.2", "gunicorn~=25.3.0", - "httpx[socks]~=0.28.0", + "httpx[socks]~=0.28.1", "jieba==0.42.1", - "json-repair>=0.55.1", - "langfuse>=3.0.0,<5.0.0", - "langsmith~=0.7.16", + "json-repair>=0.59.2", + "langfuse>=4.2.0,<5.0.0", + "langsmith~=0.7.30", "markdown~=3.10.2", - "mlflow-skinny>=3.0.0", - "numpy~=1.26.4", + "mlflow-skinny>=3.11.1", + "numpy~=2.4.4", "openpyxl~=3.1.5", - "opik~=1.10.37", - "litellm==1.82.6", # Pinned to avoid madoka dependency issue - "opentelemetry-api==1.40.0", - "opentelemetry-distro==0.61b0", - "opentelemetry-exporter-otlp==1.40.0", - "opentelemetry-exporter-otlp-proto-common==1.40.0", - "opentelemetry-exporter-otlp-proto-grpc==1.40.0", - "opentelemetry-exporter-otlp-proto-http==1.40.0", - "opentelemetry-instrumentation==0.61b0", - "opentelemetry-instrumentation-celery==0.61b0", - "opentelemetry-instrumentation-flask==0.61b0", - "opentelemetry-instrumentation-httpx==0.61b0", - "opentelemetry-instrumentation-redis==0.61b0", - "opentelemetry-instrumentation-sqlalchemy==0.61b0", - "opentelemetry-propagator-b3==1.40.0", - "opentelemetry-proto==1.40.0", - "opentelemetry-sdk==1.40.0", - "opentelemetry-semantic-conventions==0.61b0", - "opentelemetry-util-http==0.61b0", - "pandas[excel,output-formatting,performance]~=3.0.1", + "opik~=1.11.2", + "litellm==1.83.0", # Pinned to avoid madoka dependency issue + "opentelemetry-api==1.41.0", + "opentelemetry-distro==0.62b0", + "opentelemetry-exporter-otlp==1.41.0", + "opentelemetry-exporter-otlp-proto-common==1.41.0", + "opentelemetry-exporter-otlp-proto-grpc==1.41.0", + "opentelemetry-exporter-otlp-proto-http==1.41.0", + "opentelemetry-instrumentation==0.62b0", + "opentelemetry-instrumentation-celery==0.62b0", + "opentelemetry-instrumentation-flask==0.62b0", + "opentelemetry-instrumentation-httpx==0.62b0", + "opentelemetry-instrumentation-redis==0.62b0", + "opentelemetry-instrumentation-sqlalchemy==0.62b0", + "opentelemetry-propagator-b3==1.41.0", + "opentelemetry-proto==1.41.0", + "opentelemetry-sdk==1.41.0", + "opentelemetry-semantic-conventions==0.62b0", + "opentelemetry-util-http==0.62b0", + "pandas[excel,output-formatting,performance]~=3.0.2", "psycogreen~=1.0.2", - "psycopg2-binary~=2.9.6", + "psycopg2-binary~=2.9.11", "pycryptodome==3.23.0", "pydantic~=2.12.5", "pydantic-settings~=2.13.1", - "pyjwt~=2.12.0", + "pyjwt~=2.12.1", "pypdfium2==5.6.0", "python-docx~=1.2.0", "python-dotenv==1.2.2", "pyyaml~=6.0.1", "readabilipy~=0.3.0", "redis[hiredis]~=7.4.0", - "resend~=2.26.0", - "sentry-sdk[flask]~=2.55.0", - "sqlalchemy~=2.0.29", + "resend~=2.27.0", + "sentry-sdk[flask]~=2.57.0", + "sqlalchemy~=2.0.49", "starlette==1.0.0", "tiktoken~=0.12.0", "transformers~=5.3.0", @@ -82,13 +82,12 @@ dependencies = [ "yarl~=1.23.0", "sseclient-py~=1.9.0", "httpx-sse~=0.4.0", - "sendgrid~=6.12.3", + "sendgrid~=6.12.5", "flask-restx~=1.3.2", - "packaging~=23.2", - "croniter>=6.0.0", - "weaviate-client==4.20.4", - "apscheduler>=3.11.0", - "weave>=0.52.16", + "packaging~=26.0", + "croniter>=6.2.2", + "apscheduler>=3.11.2", + "weave>=0.52.36", "fastopenapi[flask]>=0.7.0", "bleach~=6.3.0", ] @@ -98,9 +97,48 @@ dependencies = [ [tool.setuptools] packages = [] +[tool.uv.workspace] +members = ["providers/vdb/*"] +exclude = ["providers/vdb/__pycache__"] + +[tool.uv.sources] +dify-vdb-alibabacloud-mysql = { workspace = true } +dify-vdb-analyticdb = { workspace = true } +dify-vdb-baidu = { workspace = true } +dify-vdb-chroma = { workspace = true } +dify-vdb-clickzetta = { workspace = true } +dify-vdb-couchbase = { workspace = true } +dify-vdb-elasticsearch = { workspace = true } +dify-vdb-hologres = { workspace = true } +dify-vdb-huawei-cloud = { workspace = true } +dify-vdb-iris = { workspace = true } +dify-vdb-lindorm = { workspace = true } +dify-vdb-matrixone = { workspace = true } +dify-vdb-milvus = { workspace = true } +dify-vdb-myscale = { workspace = true } +dify-vdb-oceanbase = { workspace = true } +dify-vdb-opengauss = { workspace = true } +dify-vdb-opensearch = { workspace = true } +dify-vdb-oracle = { workspace = true } +dify-vdb-pgvecto-rs = { workspace = true } +dify-vdb-pgvector = { workspace = true } +dify-vdb-qdrant = { workspace = true } +dify-vdb-relyt = { workspace = true } +dify-vdb-tablestore = { workspace = true } +dify-vdb-tencent = { workspace = true } +dify-vdb-tidb-on-qdrant = { workspace = true } +dify-vdb-tidb-vector = { workspace = true } +dify-vdb-upstash = { workspace = true } +dify-vdb-vastbase = { workspace = true } +dify-vdb-vikingdb = { workspace = true } +dify-vdb-weaviate = { workspace = true } + [tool.uv] -default-groups = ["storage", "tools", "vdb"] +default-groups = ["storage", "tools", "vdb-all"] package = false +override-dependencies = [ + "pyarrow>=18.0.0", +] [dependency-groups] @@ -111,16 +149,16 @@ package = false dev = [ "coverage~=7.13.4", "dotenv-linter~=0.7.0", - "faker~=40.12.0", + "faker~=40.13.0", "lxml-stubs~=0.5.1", "basedpyright~=1.39.0", - "ruff~=0.15.5", - "pytest~=9.0.2", + "ruff~=0.15.10", + "pytest~=9.0.3", "pytest-benchmark~=5.2.3", "pytest-cov~=7.1.0", "pytest-env~=1.6.0", "pytest-mock~=3.15.1", - "testcontainers~=4.14.1", + "testcontainers~=4.14.2", "types-aiofiles~=25.1.0", "types-beautifulsoup4~=4.12.0", "types-cachetools~=6.2.0", @@ -130,8 +168,8 @@ dev = [ "types-docutils~=0.22.3", "types-flask-cors~=6.0.0", "types-flask-migrate~=4.1.0", - "types-gevent~=25.9.0", - "types-greenlet~=3.3.0", + "types-gevent~=26.4.0", + "types-greenlet~=3.4.0", "types-html5lib~=1.1.11", "types-markdown~=3.10.2", "types-oauthlib~=3.3.0", @@ -149,29 +187,30 @@ dev = [ "types-pyyaml~=6.0.12", "types-regex~=2026.4.4", "types-shapely~=2.1.0", - "types-simplejson>=3.20.0", - "types-six>=1.17.0", - "types-tensorflow>=2.18.0", - "types-tqdm>=4.67.0", + "types-simplejson>=3.20.0.20260408", + "types-six>=1.17.0.20260408", + "types-tensorflow>=2.18.0.20260408", + "types-tqdm>=4.67.3.20260408", "types-ujson>=5.10.0", - "boto3-stubs>=1.38.20", - "types-jmespath>=1.0.2.20240106", - "hypothesis>=6.131.15", + "boto3-stubs>=1.42.88", + "types-jmespath>=1.1.0.20260408", + "hypothesis>=6.151.12", "types_pyOpenSSL>=24.1.0", - "types_cffi>=1.17.0", - "types_setuptools>=80.9.0", + "types_cffi>=2.0.0.20260408", + "types_setuptools>=82.0.0.20260408", "pandas-stubs~=3.0.0", "scipy-stubs>=1.15.3.0", - "types-python-http-client>=3.3.7.20240910", + "types-python-http-client>=3.3.7.20260408", "import-linter>=2.3", "types-redis>=4.6.0.20241004", "celery-types>=0.23.0", - "mypy~=1.20.0", + "mypy~=1.20.1", # "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved. "sseclient-py>=1.8.0", "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", - "pyrefly>=0.59.1", + "pyrefly>=0.60.0", + "xinference-client~=2.4.0", ] ############################################################ @@ -180,10 +219,10 @@ dev = [ ############################################################ storage = [ "azure-storage-blob==12.28.0", - "bce-python-sdk~=0.9.23", + "bce-python-sdk~=0.9.69", "cos-python-sdk-v5==1.9.41", "esdk-obs-python==3.26.2", - "google-cloud-storage>=3.0.0", + "google-cloud-storage>=3.10.1", "opendal~=0.46.0", "oss2==2.19.1", "supabase~=2.18.1", @@ -196,37 +235,74 @@ storage = [ tools = ["cloudscraper~=1.2.71", "nltk~=3.9.1"] ############################################################ -# [ VDB ] dependency group -# Required by vector store clients +# [ VDB ] workspace plugins — hollow packages under providers/vdb/* +# Each declares its own third-party deps and registers dify.vector_backends entry points. +# Use: uv sync --group vdb-all | uv sync --group vdb-qdrant ############################################################ -vdb = [ - "alibabacloud_gpdb20160503~=5.2.0", - "alibabacloud_tea_openapi~=0.4.3", - "chromadb==0.5.20", - "clickhouse-connect~=0.15.0", - "clickzetta-connector-python>=0.8.102", - "couchbase~=4.6.0", - "elasticsearch==8.14.0", - "opensearch-py==3.1.0", - "oracledb==3.4.2", - "pgvecto-rs[sqlalchemy]~=0.2.1", - "pgvector==0.4.2", - "pymilvus~=2.6.10", - "pymochow==2.4.0", - "pyobvector~=0.2.17", - "qdrant-client==1.9.0", - "intersystems-irispython>=5.1.0", - "tablestore==6.4.3", - "tcvectordb~=2.1.0", - "tidb-vector==0.0.15", - "upstash-vector==0.8.0", - "volcengine-compat~=1.0.0", - "weaviate-client==4.20.4", - "xinference-client~=2.4.0", - "mo-vector~=0.1.13", - "mysql-connector-python>=9.3.0", - "holo-search-sdk>=0.4.1", +vdb-all = [ + "dify-vdb-alibabacloud-mysql", + "dify-vdb-analyticdb", + "dify-vdb-baidu", + "dify-vdb-chroma", + "dify-vdb-clickzetta", + "dify-vdb-couchbase", + "dify-vdb-elasticsearch", + "dify-vdb-hologres", + "dify-vdb-huawei-cloud", + "dify-vdb-iris", + "dify-vdb-lindorm", + "dify-vdb-matrixone", + "dify-vdb-milvus", + "dify-vdb-myscale", + "dify-vdb-oceanbase", + "dify-vdb-opengauss", + "dify-vdb-opensearch", + "dify-vdb-oracle", + "dify-vdb-pgvecto-rs", + "dify-vdb-pgvector", + "dify-vdb-qdrant", + "dify-vdb-relyt", + "dify-vdb-tablestore", + "dify-vdb-tencent", + "dify-vdb-tidb-on-qdrant", + "dify-vdb-tidb-vector", + "dify-vdb-upstash", + "dify-vdb-vastbase", + "dify-vdb-vikingdb", + "dify-vdb-weaviate", ] +vdb-alibabacloud-mysql = ["dify-vdb-alibabacloud-mysql"] +vdb-analyticdb = ["dify-vdb-analyticdb"] +vdb-baidu = ["dify-vdb-baidu"] +vdb-chroma = ["dify-vdb-chroma"] +vdb-clickzetta = ["dify-vdb-clickzetta"] +vdb-couchbase = ["dify-vdb-couchbase"] +vdb-elasticsearch = ["dify-vdb-elasticsearch"] +vdb-hologres = ["dify-vdb-hologres"] +vdb-huawei-cloud = ["dify-vdb-huawei-cloud"] +vdb-iris = ["dify-vdb-iris"] +vdb-lindorm = ["dify-vdb-lindorm"] +vdb-matrixone = ["dify-vdb-matrixone"] +vdb-milvus = ["dify-vdb-milvus"] +vdb-myscale = ["dify-vdb-myscale"] +vdb-oceanbase = ["dify-vdb-oceanbase"] +vdb-opengauss = ["dify-vdb-opengauss"] +vdb-opensearch = ["dify-vdb-opensearch"] +vdb-oracle = ["dify-vdb-oracle"] +vdb-pgvecto-rs = ["dify-vdb-pgvecto-rs"] +vdb-pgvector = ["dify-vdb-pgvector"] +vdb-qdrant = ["dify-vdb-qdrant"] +vdb-relyt = ["dify-vdb-relyt"] +vdb-tablestore = ["dify-vdb-tablestore"] +vdb-tencent = ["dify-vdb-tencent"] +vdb-tidb-on-qdrant = ["dify-vdb-tidb-on-qdrant"] +vdb-tidb-vector = ["dify-vdb-tidb-vector"] +vdb-upstash = ["dify-vdb-upstash"] +vdb-vastbase = ["dify-vdb-vastbase"] +vdb-vikingdb = ["dify-vdb-vikingdb"] +vdb-weaviate = ["dify-vdb-weaviate"] +# Optional client used by some tests / integrations (not a vector backend plugin) +vdb-xinference = ["xinference-client~=2.4.0"] [tool.pyrefly] project-includes = ["."] diff --git a/api/pyrefly-local-excludes.txt b/api/pyrefly-local-excludes.txt index 43f604c2de..3e5ece1fcf 100644 --- a/api/pyrefly-local-excludes.txt +++ b/api/pyrefly-local-excludes.txt @@ -45,31 +45,7 @@ core/plugin/backwards_invocation/model.py core/prompt/utils/extract_thread_messages.py core/rag/datasource/keyword/jieba/jieba.py core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py -core/rag/datasource/vdb/analyticdb/analyticdb_vector.py -core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py -core/rag/datasource/vdb/baidu/baidu_vector.py -core/rag/datasource/vdb/chroma/chroma_vector.py -core/rag/datasource/vdb/clickzetta/clickzetta_vector.py -core/rag/datasource/vdb/couchbase/couchbase_vector.py -core/rag/datasource/vdb/elasticsearch/elasticsearch_vector.py -core/rag/datasource/vdb/huawei/huawei_cloud_vector.py -core/rag/datasource/vdb/lindorm/lindorm_vector.py -core/rag/datasource/vdb/matrixone/matrixone_vector.py -core/rag/datasource/vdb/milvus/milvus_vector.py -core/rag/datasource/vdb/myscale/myscale_vector.py -core/rag/datasource/vdb/oceanbase/oceanbase_vector.py -core/rag/datasource/vdb/opensearch/opensearch_vector.py -core/rag/datasource/vdb/oracle/oraclevector.py -core/rag/datasource/vdb/pgvecto_rs/pgvecto_rs.py -core/rag/datasource/vdb/relyt/relyt_vector.py -core/rag/datasource/vdb/tablestore/tablestore_vector.py -core/rag/datasource/vdb/tencent/tencent_vector.py -core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py -core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py -core/rag/datasource/vdb/tidb_vector/tidb_vector.py -core/rag/datasource/vdb/upstash/upstash_vector.py -core/rag/datasource/vdb/vikingdb/vikingdb_vector.py -core/rag/datasource/vdb/weaviate/weaviate_vector.py +providers/vdb/** core/rag/extractor/csv_extractor.py core/rag/extractor/excel_extractor.py core/rag/extractor/firecrawl/firecrawl_app.py diff --git a/api/pyrightconfig.json b/api/pyrightconfig.json index a8b884ea81..c4582e891d 100644 --- a/api/pyrightconfig.json +++ b/api/pyrightconfig.json @@ -4,7 +4,8 @@ "tests/", ".venv", "migrations/", - "core/rag" + "core/rag", + "providers/", ], "typeCheckingMode": "strict", "allowedUntypedLibraries": [ @@ -36,7 +37,9 @@ "gmpy2", "sendgrid", "sendgrid.helpers.mail", - "holo_search_sdk.types" + "holo_search_sdk.types", + "dify_vdb_qdrant", + "dify_vdb_tidb_on_qdrant" ], "reportUnknownMemberType": "hint", "reportUnknownParameterType": "hint", @@ -47,7 +50,6 @@ "reportMissingTypeArgument": "hint", "reportUnnecessaryComparison": "hint", "reportUnnecessaryIsInstance": "hint", - "reportUntypedFunctionDecorator": "hint", "reportUnnecessaryTypeIgnoreComment": "hint", "reportAttributeAccessIssue": "hint", "pythonVersion": "3.12", diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index 9267be2636..b760696c5e 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -41,7 +41,6 @@ from libs.datetime_utils import naive_utc_now from libs.helper import convert_datetime_to_date from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.time_parser import get_time_threshold -from libs.uuid_utils import uuidv7 from models.enums import WorkflowRunTriggeredFrom from models.human_input import HumanInputForm from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun @@ -744,12 +743,11 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): # Upload the state file # Create the pause record - pause_model = WorkflowPause() - pause_model.id = str(uuidv7()) - pause_model.workflow_id = workflow_run.workflow_id - pause_model.workflow_run_id = workflow_run.id - pause_model.state_object_key = state_obj_key - pause_model.created_at = naive_utc_now() + pause_model = WorkflowPause( + workflow_id=workflow_run.workflow_id, + workflow_run_id=workflow_run.id, + state_object_key=state_obj_key, + ) pause_reason_models = [] for reason in pause_reasons: if isinstance(reason, HumanInputRequired): diff --git a/api/schedule/clean_workflow_runlogs_precise.py b/api/schedule/clean_workflow_runlogs_precise.py index ebb8d52924..c5762fcdad 100644 --- a/api/schedule/clean_workflow_runlogs_precise.py +++ b/api/schedule/clean_workflow_runlogs_precise.py @@ -4,6 +4,7 @@ import time from collections.abc import Sequence import click +from sqlalchemy import delete, select from sqlalchemy.orm import Session, sessionmaker import app @@ -113,11 +114,9 @@ def _delete_batch( try: with session.begin_nested(): workflow_run_ids = [run.id for run in workflow_runs] - message_data = ( - session.query(Message.id, Message.conversation_id) - .where(Message.workflow_run_id.in_(workflow_run_ids)) - .all() - ) + message_data = session.execute( + select(Message.id, Message.conversation_id).where(Message.workflow_run_id.in_(workflow_run_ids)) + ).all() message_id_list = [msg.id for msg in message_data] conversation_id_list = list({msg.conversation_id for msg in message_data if msg.conversation_id}) if message_id_list: @@ -132,23 +131,19 @@ def _delete_batch( SavedMessage, ] for model in message_related_models: - session.query(model).where(model.message_id.in_(message_id_list)).delete(synchronize_session=False) # type: ignore + session.execute(delete(model).where(model.message_id.in_(message_id_list))) # type: ignore # error: "DeclarativeAttributeIntercept" has no attribute "message_id". But this type is only in lib # and these 6 types all have the message_id field. - session.query(Message).where(Message.workflow_run_id.in_(workflow_run_ids)).delete( - synchronize_session=False - ) + session.execute(delete(Message).where(Message.workflow_run_id.in_(workflow_run_ids))) if conversation_id_list: - session.query(ConversationVariable).where( - ConversationVariable.conversation_id.in_(conversation_id_list) - ).delete(synchronize_session=False) - - session.query(Conversation).where(Conversation.id.in_(conversation_id_list)).delete( - synchronize_session=False + session.execute( + delete(ConversationVariable).where(ConversationVariable.conversation_id.in_(conversation_id_list)) ) + session.execute(delete(Conversation).where(Conversation.id.in_(conversation_id_list))) + def _delete_node_executions(active_session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: run_ids = [run.id for run in runs] repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( diff --git a/api/schedule/create_tidb_serverless_task.py b/api/schedule/create_tidb_serverless_task.py index 511de002d2..e242b0c667 100644 --- a/api/schedule/create_tidb_serverless_task.py +++ b/api/schedule/create_tidb_serverless_task.py @@ -1,11 +1,11 @@ import time import click +from dify_vdb_tidb_on_qdrant.tidb_service import TidbService from sqlalchemy import func, select import app from configs import dify_config -from core.rag.datasource.vdb.tidb_on_qdrant.tidb_service import TidbService from extensions.ext_database import db from models.dataset import TidbAuthBinding from models.enums import TidbAuthBindingStatus diff --git a/api/schedule/update_tidb_serverless_status_task.py b/api/schedule/update_tidb_serverless_status_task.py index 10003b1b97..46d1b85aa0 100644 --- a/api/schedule/update_tidb_serverless_status_task.py +++ b/api/schedule/update_tidb_serverless_status_task.py @@ -2,11 +2,11 @@ import time from collections.abc import Sequence import click +from dify_vdb_tidb_on_qdrant.tidb_service import TidbService from sqlalchemy import select import app from configs import dify_config -from core.rag.datasource.vdb.tidb_on_qdrant.tidb_service import TidbService from extensions.ext_database import db from models.dataset import TidbAuthBinding from models.enums import TidbAuthBindingStatus diff --git a/api/services/account_service.py b/api/services/account_service.py index 28c736a1e9..ccc4a7c1fa 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -9,7 +9,8 @@ from typing import Any, TypedDict, cast from pydantic import BaseModel, TypeAdapter from sqlalchemy import delete, func, select, update -from sqlalchemy.orm import Session + +from core.db.session_factory import session_factory class InvitationData(TypedDict): @@ -800,19 +801,19 @@ class AccountService: return token @staticmethod - def get_account_by_email_with_case_fallback(email: str, session: Session | None = None) -> Account | None: + def get_account_by_email_with_case_fallback(email: str) -> Account | None: """ Retrieve an account by email and fall back to the lowercase email if the original lookup fails. This keeps backward compatibility for older records that stored uppercase emails while the rest of the system gradually normalizes new inputs. """ - query_session = session or db.session - account = query_session.execute(select(Account).filter_by(email=email)).scalar_one_or_none() - if account or email == email.lower(): - return account + with session_factory.create_session() as session: + account = session.execute(select(Account).where(Account.email == email)).scalar_one_or_none() + if account or email == email.lower(): + return account - return query_session.execute(select(Account).filter_by(email=email.lower())).scalar_one_or_none() + return session.execute(select(Account).where(Account.email == email.lower())).scalar_one_or_none() @classmethod def get_email_code_login_data(cls, token: str) -> dict[str, Any] | None: @@ -1516,8 +1517,7 @@ class RegisterService: check_workspace_member_invite_permission(tenant.id) - with Session(db.engine) as session: - account = AccountService.get_account_by_email_with_case_fallback(email, session=session) + account = AccountService.get_account_by_email_with_case_fallback(email) if not account: TenantService.check_member_permission(tenant, inviter, None, "add") diff --git a/api/services/advanced_prompt_template_service.py b/api/services/advanced_prompt_template_service.py index a6e6b1bae7..5d136e7393 100644 --- a/api/services/advanced_prompt_template_service.py +++ b/api/services/advanced_prompt_template_service.py @@ -1,4 +1,5 @@ import copy +from typing import Any, TypedDict from core.prompt.prompt_templates.advanced_prompt_templates import ( BAICHUAN_CHAT_APP_CHAT_PROMPT_CONFIG, @@ -15,9 +16,18 @@ from core.prompt.prompt_templates.advanced_prompt_templates import ( from models.model import AppMode +class AdvancedPromptTemplateArgs(TypedDict): + """Expected shape of the args dict passed to AdvancedPromptTemplateService.get_prompt.""" + + app_mode: str + model_mode: str + model_name: str + has_context: str + + class AdvancedPromptTemplateService: @classmethod - def get_prompt(cls, args: dict): + def get_prompt(cls, args: AdvancedPromptTemplateArgs) -> dict[str, Any]: app_mode = args["app_mode"] model_mode = args["model_mode"] model_name = args["model_name"] @@ -29,7 +39,7 @@ class AdvancedPromptTemplateService: return cls.get_common_prompt(app_mode, model_mode, has_context) @classmethod - def get_common_prompt(cls, app_mode: str, model_mode: str, has_context: str): + def get_common_prompt(cls, app_mode: str, model_mode: str, has_context: str) -> dict[str, Any]: context_prompt = copy.deepcopy(CONTEXT) match app_mode: @@ -63,7 +73,7 @@ class AdvancedPromptTemplateService: return {} @classmethod - def get_completion_prompt(cls, prompt_template: dict, has_context: str, context: str): + def get_completion_prompt(cls, prompt_template: dict[str, Any], has_context: str, context: str) -> dict[str, Any]: if has_context == "true": prompt_template["completion_prompt_config"]["prompt"]["text"] = ( context + prompt_template["completion_prompt_config"]["prompt"]["text"] @@ -72,7 +82,7 @@ class AdvancedPromptTemplateService: return prompt_template @classmethod - def get_chat_prompt(cls, prompt_template: dict, has_context: str, context: str): + def get_chat_prompt(cls, prompt_template: dict[str, Any], has_context: str, context: str) -> dict[str, Any]: if has_context == "true": prompt_template["chat_prompt_config"]["prompt"][0]["text"] = ( context + prompt_template["chat_prompt_config"]["prompt"][0]["text"] @@ -81,7 +91,7 @@ class AdvancedPromptTemplateService: return prompt_template @classmethod - def get_baichuan_prompt(cls, app_mode: str, model_mode: str, has_context: str): + def get_baichuan_prompt(cls, app_mode: str, model_mode: str, has_context: str) -> dict[str, Any]: baichuan_context_prompt = copy.deepcopy(BAICHUAN_CONTEXT) match app_mode: diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index ae5facbec0..ff0882ad5c 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -1,11 +1,8 @@ import logging import uuid - -import pandas as pd - -logger = logging.getLogger(__name__) from typing import TypedDict +import pandas as pd from sqlalchemy import delete, or_, select, update from werkzeug.datastructures import FileStorage from werkzeug.exceptions import NotFound @@ -24,6 +21,8 @@ from tasks.annotation.disable_annotation_reply_task import disable_annotation_re from tasks.annotation.enable_annotation_reply_task import enable_annotation_reply_task from tasks.annotation.update_annotation_to_index_task import update_annotation_to_index_task +logger = logging.getLogger(__name__) + class AnnotationJobStatusDict(TypedDict): job_id: str @@ -46,9 +45,50 @@ class AnnotationSettingDisabledDict(TypedDict): enabled: bool +class EnableAnnotationArgs(TypedDict): + """Expected shape of the args dict passed to enable_app_annotation.""" + + score_threshold: float + embedding_provider_name: str + embedding_model_name: str + + +class UpsertAnnotationArgs(TypedDict, total=False): + """Expected shape of the args dict passed to up_insert_app_annotation_from_message.""" + + answer: str + content: str + message_id: str + question: str + + +class InsertAnnotationArgs(TypedDict): + """Expected shape of the args dict passed to insert_app_annotation_directly.""" + + question: str + answer: str + + +class UpdateAnnotationArgs(TypedDict, total=False): + """Expected shape of the args dict passed to update_app_annotation_directly. + + Both fields are optional at the type level; the service validates at runtime + and raises ValueError if either is missing. + """ + + answer: str + question: str + + +class UpdateAnnotationSettingArgs(TypedDict): + """Expected shape of the args dict passed to update_app_annotation_setting.""" + + score_threshold: float + + class AppAnnotationService: @classmethod - def up_insert_app_annotation_from_message(cls, args: dict, app_id: str) -> MessageAnnotation: + def up_insert_app_annotation_from_message(cls, args: UpsertAnnotationArgs, app_id: str) -> MessageAnnotation: # get app info current_user, current_tenant_id = current_account_with_tenant() app = db.session.scalar( @@ -62,8 +102,9 @@ class AppAnnotationService: if answer is None: raise ValueError("Either 'answer' or 'content' must be provided") - if args.get("message_id"): - message_id = str(args["message_id"]) + raw_message_id = args.get("message_id") + if raw_message_id: + message_id = str(raw_message_id) message = db.session.scalar( select(Message).where(Message.id == message_id, Message.app_id == app.id).limit(1) ) @@ -87,9 +128,10 @@ class AppAnnotationService: account_id=current_user.id, ) else: - question = args.get("question") - if not question: + maybe_question = args.get("question") + if not maybe_question: raise ValueError("'question' is required when 'message_id' is not provided") + question = maybe_question annotation = MessageAnnotation(app_id=app.id, content=answer, question=question, account_id=current_user.id) db.session.add(annotation) @@ -110,7 +152,7 @@ class AppAnnotationService: return annotation @classmethod - def enable_app_annotation(cls, args: dict, app_id: str) -> AnnotationJobStatusDict: + def enable_app_annotation(cls, args: EnableAnnotationArgs, app_id: str) -> AnnotationJobStatusDict: enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}" cache_result = redis_client.get(enable_app_annotation_key) if cache_result is not None: @@ -217,7 +259,7 @@ class AppAnnotationService: return annotations @classmethod - def insert_app_annotation_directly(cls, args: dict, app_id: str) -> MessageAnnotation: + def insert_app_annotation_directly(cls, args: InsertAnnotationArgs, app_id: str) -> MessageAnnotation: # get app info current_user, current_tenant_id = current_account_with_tenant() app = db.session.scalar( @@ -251,7 +293,7 @@ class AppAnnotationService: return annotation @classmethod - def update_app_annotation_directly(cls, args: dict, app_id: str, annotation_id: str): + def update_app_annotation_directly(cls, args: UpdateAnnotationArgs, app_id: str, annotation_id: str): # get app info _, current_tenant_id = current_account_with_tenant() app = db.session.scalar( @@ -270,7 +312,11 @@ class AppAnnotationService: if question is None: raise ValueError("'question' is required") - annotation.content = args["answer"] + answer = args.get("answer") + if answer is None: + raise ValueError("'answer' is required") + + annotation.content = answer annotation.question = question db.session.commit() @@ -613,7 +659,7 @@ class AppAnnotationService: @classmethod def update_app_annotation_setting( - cls, app_id: str, annotation_setting_id: str, args: dict + cls, app_id: str, annotation_setting_id: str, args: UpdateAnnotationSettingArgs ) -> AnnotationSettingDict: current_user, current_tenant_id = current_account_with_tenant() # get app info diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index dd73e10374..87c28980ab 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -3,8 +3,7 @@ import hashlib import logging import uuid from collections.abc import Mapping -from enum import StrEnum -from typing import cast +from typing import Any, cast from urllib.parse import urlparse from uuid import uuid4 @@ -19,7 +18,7 @@ from graphon.nodes.question_classifier.entities import QuestionClassifierNodeDat from graphon.nodes.tool.entities import ToolNodeData from packaging import version from packaging.version import parse as parse_version -from pydantic import BaseModel, Field +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session @@ -40,6 +39,7 @@ from libs.datetime_utils import naive_utc_now from models import Account, App, AppMode from models.model import AppModelConfig, AppModelConfigDict, IconType from models.workflow import Workflow +from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.workflow_draft_variable_service import WorkflowDraftVariableService from services.workflow_service import WorkflowService @@ -53,18 +53,6 @@ DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB CURRENT_DSL_VERSION = "0.6.0" -class ImportMode(StrEnum): - YAML_CONTENT = "yaml-content" - YAML_URL = "yaml-url" - - -class ImportStatus(StrEnum): - COMPLETED = "completed" - COMPLETED_WITH_WARNINGS = "completed-with-warnings" - PENDING = "pending" - FAILED = "failed" - - class Import(BaseModel): id: str status: ImportStatus @@ -75,10 +63,6 @@ class Import(BaseModel): error: str = "" -class CheckDependenciesResult(BaseModel): - leaked_dependencies: list[PluginDependency] = Field(default_factory=list) - - def _check_version_compatibility(imported_version: str) -> ImportStatus: """Determine import status based on version comparison""" try: @@ -416,7 +400,7 @@ class AppDslService: self, *, app: App | None, - data: dict, + data: dict[str, Any], account: Account, name: str | None = None, description: str | None = None, @@ -483,61 +467,67 @@ class AppDslService: ) # Initialize app based on mode - if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - workflow_data = data.get("workflow") - if not workflow_data or not isinstance(workflow_data, dict): - raise ValueError("Missing workflow data for workflow/advanced chat app") + match app_mode: + case AppMode.ADVANCED_CHAT | AppMode.WORKFLOW: + workflow_data = data.get("workflow") + if not workflow_data or not isinstance(workflow_data, dict): + raise ValueError("Missing workflow data for workflow/advanced chat app") - environment_variables_list = workflow_data.get("environment_variables", []) - environment_variables = [ - variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list - ] - conversation_variables_list = workflow_data.get("conversation_variables", []) - conversation_variables = [ - variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list - ] + environment_variables_list = workflow_data.get("environment_variables", []) + environment_variables = [ + variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list + ] + conversation_variables_list = workflow_data.get("conversation_variables", []) + conversation_variables = [ + variable_factory.build_conversation_variable_from_mapping(obj) + for obj in conversation_variables_list + ] - workflow_service = WorkflowService() - current_draft_workflow = workflow_service.get_draft_workflow(app_model=app) - if current_draft_workflow: - unique_hash = current_draft_workflow.unique_hash - else: - unique_hash = None - graph = workflow_data.get("graph", {}) - for node in graph.get("nodes", []): - if node.get("data", {}).get("type", "") == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: - dataset_ids = node["data"].get("dataset_ids", []) - node["data"]["dataset_ids"] = [ - decrypted_id - for dataset_id in dataset_ids - if (decrypted_id := self.decrypt_dataset_id(encrypted_data=dataset_id, tenant_id=app.tenant_id)) - ] - workflow_service.sync_draft_workflow( - app_model=app, - graph=workflow_data.get("graph", {}), - features=workflow_data.get("features", {}), - unique_hash=unique_hash, - account=account, - environment_variables=environment_variables, - conversation_variables=conversation_variables, - ) - elif app_mode in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION}: - # Initialize model config - model_config = data.get("model_config") - if not model_config or not isinstance(model_config, dict): - raise ValueError("Missing model_config for chat/agent-chat/completion app") - # Initialize or update model config - if not app.app_model_config: - app_model_config = AppModelConfig( - app_id=app.id, created_by=account.id, updated_by=account.id - ).from_model_config_dict(cast(AppModelConfigDict, model_config)) - app_model_config.id = str(uuid4()) - app.app_model_config_id = app_model_config.id + workflow_service = WorkflowService() + current_draft_workflow = workflow_service.get_draft_workflow(app_model=app) + if current_draft_workflow: + unique_hash = current_draft_workflow.unique_hash + else: + unique_hash = None + graph = workflow_data.get("graph", {}) + for node in graph.get("nodes", []): + if node.get("data", {}).get("type", "") == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: + dataset_ids = node["data"].get("dataset_ids", []) + node["data"]["dataset_ids"] = [ + decrypted_id + for dataset_id in dataset_ids + if ( + decrypted_id := self.decrypt_dataset_id( + encrypted_data=dataset_id, tenant_id=app.tenant_id + ) + ) + ] + workflow_service.sync_draft_workflow( + app_model=app, + graph=workflow_data.get("graph", {}), + features=workflow_data.get("features", {}), + unique_hash=unique_hash, + account=account, + environment_variables=environment_variables, + conversation_variables=conversation_variables, + ) + case AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION: + # Initialize model config + model_config = data.get("model_config") + if not model_config or not isinstance(model_config, dict): + raise ValueError("Missing model_config for chat/agent-chat/completion app") + # Initialize or update model config + if not app.app_model_config: + app_model_config = AppModelConfig( + app_id=app.id, created_by=account.id, updated_by=account.id + ).from_model_config_dict(cast(AppModelConfigDict, model_config)) + app_model_config.id = str(uuid4()) + app.app_model_config_id = app_model_config.id - self._session.add(app_model_config) - app_model_config_was_updated.send(app, app_model_config=app_model_config) - else: - raise ValueError("Invalid app mode") + self._session.add(app_model_config) + app_model_config_was_updated.send(app, app_model_config=app_model_config) + case _: + raise ValueError("Invalid app mode") return app @classmethod @@ -577,7 +567,7 @@ class AppDslService: @classmethod def _append_workflow_export_data( - cls, *, export_data: dict, app_model: App, include_secret: bool, workflow_id: str | None = None + cls, *, export_data: dict[str, Any], app_model: App, include_secret: bool, workflow_id: str | None = None ): """ Append workflow export data @@ -630,7 +620,7 @@ class AppDslService: ] @classmethod - def _append_model_config_export_data(cls, export_data: dict, app_model: App): + def _append_model_config_export_data(cls, export_data: dict[str, Any], app_model: App): """ Append model config export data :param export_data: export data diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 17ed98d301..5e8c7aa337 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -4,7 +4,7 @@ import logging import threading import uuid from collections.abc import Callable, Generator, Mapping -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any from configs import dify_config from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator @@ -88,7 +88,7 @@ class AppGenerateService: def generate( cls, app_model: App, - user: Union[Account, EndUser], + user: Account | EndUser, args: Mapping[str, Any], invoke_from: InvokeFrom, streaming: bool = True, @@ -356,11 +356,11 @@ class AppGenerateService: def generate_more_like_this( cls, app_model: App, - user: Union[Account, EndUser], + user: Account | EndUser, message_id: str, invoke_from: InvokeFrom, streaming: bool = True, - ) -> Union[Mapping, Generator]: + ) -> Mapping | Generator: """ Generate more like this :param app_model: app model diff --git a/api/services/app_model_config_service.py b/api/services/app_model_config_service.py index 2013c869af..8252de7753 100644 --- a/api/services/app_model_config_service.py +++ b/api/services/app_model_config_service.py @@ -1,3 +1,5 @@ +from typing import Any + from core.app.apps.agent_chat.app_config_manager import AgentChatAppConfigManager from core.app.apps.chat.app_config_manager import ChatAppConfigManager from core.app.apps.completion.app_config_manager import CompletionAppConfigManager @@ -6,7 +8,7 @@ from models.model import AppMode, AppModelConfigDict class AppModelConfigService: @classmethod - def validate_configuration(cls, tenant_id: str, config: dict, app_mode: AppMode) -> AppModelConfigDict: + def validate_configuration(cls, tenant_id: str, config: dict[str, Any], app_mode: AppMode) -> AppModelConfigDict: match app_mode: case AppMode.CHAT: return ChatAppConfigManager.config_validate(tenant_id, config) diff --git a/api/services/app_service.py b/api/services/app_service.py index 87d52a3159..ef170c50ba 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) class AppService: - def get_paginate_apps(self, user_id: str, tenant_id: str, args: dict) -> Pagination | None: + def get_paginate_apps(self, user_id: str, tenant_id: str, args: dict[str, Any]) -> Pagination | None: """ Get app list with pagination :param user_id: user id @@ -78,7 +78,7 @@ class AppService: return app_models - def create_app(self, tenant_id: str, args: dict, account: Account) -> App: + def create_app(self, tenant_id: str, args: dict[str, Any], account: Account) -> App: """ Create app :param tenant_id: tenant id @@ -389,7 +389,7 @@ class AppService: """ app_mode = AppMode.value_of(app_model.mode) - meta: dict = {"tool_icons": {}} + meta: dict[str, Any] = {"tool_icons": {}} if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: workflow = app_model.workflow diff --git a/api/services/async_workflow_service.py b/api/services/async_workflow_service.py index 0133634e5a..a731d5c048 100644 --- a/api/services/async_workflow_service.py +++ b/api/services/async_workflow_service.py @@ -7,11 +7,11 @@ with support for different subscription tiers, rate limiting, and execution trac import json from datetime import UTC, datetime -from typing import Any, Union +from typing import Any from celery.result import AsyncResult from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from enums.quota_type import QuotaType from extensions.ext_database import db @@ -50,7 +50,7 @@ class AsyncWorkflowService: @classmethod def trigger_workflow_async( - cls, session: Session, user: Union[Account, EndUser], trigger_data: TriggerData + cls, session: Session, user: Account | EndUser, trigger_data: TriggerData ) -> AsyncTriggerResponse: """ Universal entry point for async workflow execution - THIS METHOD WILL NOT BLOCK @@ -177,7 +177,7 @@ class AsyncWorkflowService: @classmethod def reinvoke_trigger( - cls, session: Session, user: Union[Account, EndUser], workflow_trigger_log_id: str + cls, session: Session, user: Account | EndUser, workflow_trigger_log_id: str ) -> AsyncTriggerResponse: """ Re-invoke a previously failed or rate-limited trigger - THIS METHOD WILL NOT BLOCK @@ -237,7 +237,7 @@ class AsyncWorkflowService: Returns: Trigger log as dictionary or None if not found """ - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) trigger_log = trigger_log_repo.get_by_id(workflow_trigger_log_id, tenant_id) @@ -263,7 +263,7 @@ class AsyncWorkflowService: Returns: List of trigger logs as dictionaries """ - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) logs = trigger_log_repo.get_recent_logs( tenant_id=tenant_id, app_id=app_id, hours=hours, limit=limit, offset=offset @@ -286,7 +286,7 @@ class AsyncWorkflowService: Returns: List of failed trigger logs as dictionaries """ - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) logs = trigger_log_repo.get_failed_for_retry( tenant_id=tenant_id, max_retry_count=max_retry_count, limit=limit diff --git a/api/services/auth/api_key_auth_service.py b/api/services/auth/api_key_auth_service.py index 3282dcfb11..36b1517056 100644 --- a/api/services/auth/api_key_auth_service.py +++ b/api/services/auth/api_key_auth_service.py @@ -1,4 +1,5 @@ import json +from typing import Any from sqlalchemy import select @@ -19,7 +20,7 @@ class ApiKeyAuthService: return data_source_api_key_bindings @staticmethod - def create_provider_auth(tenant_id: str, args: dict): + def create_provider_auth(tenant_id: str, args: dict[str, Any]): auth_result = ApiKeyAuthFactory(args["provider"], args["credentials"]).validate_credentials() if auth_result: # Encrypt the api key diff --git a/api/services/billing_service.py b/api/services/billing_service.py index e096a0f2ba..a1362ccad6 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -2,7 +2,7 @@ import json import logging import os from collections.abc import Sequence -from typing import Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict import httpx from pydantic import TypeAdapter @@ -32,6 +32,60 @@ class SubscriptionPlan(TypedDict): expiration_date: int +class _BillingQuota(TypedDict): + size: int + limit: int + + +class _VectorSpaceQuota(TypedDict): + size: float + limit: int + + +class _KnowledgeRateLimit(TypedDict): + # NOTE (hj24): + # 1. Return for sandbox users but is null for other plans, it's defined but never used. + # 2. Keep it for compatibility for now, can be deprecated in future versions. + size: NotRequired[int] + # NOTE END + limit: int + + +class _BillingSubscription(TypedDict): + plan: str + interval: str + education: bool + + +class BillingInfo(TypedDict): + """Response of /subscription/info. + + NOTE (hj24): + - Fields not listed here (e.g. trigger_event, api_rate_limit) are stripped by TypeAdapter.validate_python() + - To ensure the precision, billing may convert fields like int as str, be careful when use TypeAdapter: + 1. validate_python in non-strict mode will coerce it to the expected type + 2. In strict mode, it will raise ValidationError + 3. To preserve compatibility, always keep non-strict mode here and avoid strict mode + """ + + enabled: bool + subscription: _BillingSubscription + members: _BillingQuota + apps: _BillingQuota + vector_space: _VectorSpaceQuota + knowledge_rate_limit: _KnowledgeRateLimit + documents_upload_quota: _BillingQuota + annotation_quota_limit: _BillingQuota + docs_processing: str + can_replace_logo: bool + model_load_balancing_enabled: bool + knowledge_pipeline_publish_enabled: bool + next_credit_reset_date: NotRequired[int] + + +_billing_info_adapter = TypeAdapter(BillingInfo) + + class KnowledgeRateLimitDict(TypedDict): limit: int subscription_plan: str @@ -87,11 +141,11 @@ class BillingService: _PLAN_CACHE_TTL = 600 @classmethod - def get_info(cls, tenant_id: str): + def get_info(cls, tenant_id: str) -> BillingInfo: params = {"tenant_id": tenant_id} billing_info = cls._send_request("GET", "/subscription/info", params=params) - return billing_info + return _billing_info_adapter.validate_python(billing_info) @classmethod def get_tenant_feature_plan_usage_info(cls, tenant_id: str): @@ -487,7 +541,7 @@ class BillingService: start_time / end_time: RFC3339 strings (e.g. "2026-03-01T00:00:00Z"), optional. Returns {"notification_id": str}. """ - payload: dict = { + payload: dict[str, Any] = { "contents": contents, "frequency": frequency, "status": status, diff --git a/api/services/clear_free_plan_tenant_expired_logs.py b/api/services/clear_free_plan_tenant_expired_logs.py index 1c128524ad..ea12e40420 100644 --- a/api/services/clear_free_plan_tenant_expired_logs.py +++ b/api/services/clear_free_plan_tenant_expired_logs.py @@ -7,7 +7,7 @@ from concurrent.futures import ThreadPoolExecutor import click from flask import Flask, current_app from graphon.model_runtime.utils.encoders import jsonable_encoder -from sqlalchemy import select +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session, sessionmaker from configs import dify_config @@ -62,13 +62,11 @@ class ClearFreePlanTenantExpiredLogs: for model, table_name in related_tables: # Query records related to expired messages - records = ( - session.query(model) - .where( + records = session.scalars( + select(model).where( model.message_id.in_(batch_message_ids), # type: ignore ) - .all() - ) + ).all() if len(records) == 0: continue @@ -103,9 +101,13 @@ class ClearFreePlanTenantExpiredLogs: except Exception: logger.exception("Failed to save %s records", table_name) - session.query(model).where( - model.id.in_(record_ids), # type: ignore - ).delete(synchronize_session=False) + session.execute( + delete(model) + .where( + model.id.in_(record_ids), # type: ignore + ) + .execution_options(synchronize_session=False) + ) click.echo( click.style( @@ -120,16 +122,15 @@ class ClearFreePlanTenantExpiredLogs: apps = db.session.scalars(select(App).where(App.tenant_id == tenant_id)).all() app_ids = [app.id for app in apps] while True: - with Session(db.engine).no_autoflush as session: - messages = ( - session.query(Message) + with sessionmaker(bind=db.engine, autoflush=False).begin() as session: + messages = session.scalars( + select(Message) .where( Message.app_id.in_(app_ids), Message.created_at < datetime.datetime.now() - datetime.timedelta(days=days), ) .limit(batch) - .all() - ) + ).all() if len(messages) == 0: break @@ -147,12 +148,11 @@ class ClearFreePlanTenantExpiredLogs: message_ids = [message.id for message in messages] # delete messages - session.query(Message).where( - Message.id.in_(message_ids), - ).delete(synchronize_session=False) + session.execute( + delete(Message).where(Message.id.in_(message_ids)).execution_options(synchronize_session=False) + ) cls._clear_message_related_tables(session, tenant_id, message_ids) - session.commit() click.echo( click.style( @@ -161,16 +161,15 @@ class ClearFreePlanTenantExpiredLogs: ) while True: - with Session(db.engine).no_autoflush as session: - conversations = ( - session.query(Conversation) + with sessionmaker(bind=db.engine, autoflush=False).begin() as session: + conversations = session.scalars( + select(Conversation) .where( Conversation.app_id.in_(app_ids), Conversation.updated_at < datetime.datetime.now() - datetime.timedelta(days=days), ) .limit(batch) - .all() - ) + ).all() if len(conversations) == 0: break @@ -187,10 +186,11 @@ class ClearFreePlanTenantExpiredLogs: ) conversation_ids = [conversation.id for conversation in conversations] - session.query(Conversation).where( - Conversation.id.in_(conversation_ids), - ).delete(synchronize_session=False) - session.commit() + session.execute( + delete(Conversation) + .where(Conversation.id.in_(conversation_ids)) + .execution_options(synchronize_session=False) + ) click.echo( click.style( @@ -294,16 +294,15 @@ class ClearFreePlanTenantExpiredLogs: break while True: - with Session(db.engine).no_autoflush as session: - workflow_app_logs = ( - session.query(WorkflowAppLog) + with sessionmaker(bind=db.engine, autoflush=False).begin() as session: + workflow_app_logs = session.scalars( + select(WorkflowAppLog) .where( WorkflowAppLog.tenant_id == tenant_id, WorkflowAppLog.created_at < datetime.datetime.now() - datetime.timedelta(days=days), ) .limit(batch) - .all() - ) + ).all() if len(workflow_app_logs) == 0: break @@ -323,10 +322,11 @@ class ClearFreePlanTenantExpiredLogs: workflow_app_log_ids = [workflow_app_log.id for workflow_app_log in workflow_app_logs] # delete workflow app logs - session.query(WorkflowAppLog).where(WorkflowAppLog.id.in_(workflow_app_log_ids)).delete( - synchronize_session=False + session.execute( + delete(WorkflowAppLog) + .where(WorkflowAppLog.id.in_(workflow_app_log_ids)) + .execution_options(synchronize_session=False) ) - session.commit() click.echo( click.style( @@ -346,8 +346,8 @@ class ClearFreePlanTenantExpiredLogs: started_at = datetime.datetime(2023, 4, 3, 8, 59, 24) current_time = started_at - with Session(db.engine) as session: - total_tenant_count = session.query(Tenant.id).count() + with sessionmaker(db.engine).begin() as session: + total_tenant_count = session.scalar(select(func.count(Tenant.id))) or 0 click.echo(click.style(f"Total tenant count: {total_tenant_count}", fg="white")) @@ -398,7 +398,7 @@ class ClearFreePlanTenantExpiredLogs: # Initial interval of 1 day, will be dynamically adjusted based on tenant count interval = datetime.timedelta(days=1) # Process tenants in this batch - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: # Calculate tenant count in next batch with current interval # Try different intervals until we find one with a reasonable tenant count test_intervals = [ @@ -412,9 +412,12 @@ class ClearFreePlanTenantExpiredLogs: tenant_count = 0 for test_interval in test_intervals: tenant_count = ( - session.query(Tenant.id) - .where(Tenant.created_at.between(current_time, current_time + test_interval)) - .count() + session.scalar( + select(func.count(Tenant.id)).where( + Tenant.created_at.between(current_time, current_time + test_interval) + ) + ) + or 0 ) if tenant_count <= 100: interval = test_interval @@ -436,8 +439,8 @@ class ClearFreePlanTenantExpiredLogs: batch_end = min(current_time + interval, ended_at) - rs = ( - session.query(Tenant.id) + rs = session.execute( + select(Tenant.id) .where(Tenant.created_at.between(current_time, batch_end)) .order_by(Tenant.created_at) ) diff --git a/api/services/credit_pool_service.py b/api/services/credit_pool_service.py index 7826695366..2d210db121 100644 --- a/api/services/credit_pool_service.py +++ b/api/services/credit_pool_service.py @@ -1,7 +1,7 @@ import logging from sqlalchemy import select, update -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from configs import dify_config from core.errors.error import QuotaExceededError @@ -29,14 +29,15 @@ class CreditPoolService: @classmethod def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None: """get tenant credit pool""" - return db.session.scalar( - select(TenantCreditPool) - .where( - TenantCreditPool.tenant_id == tenant_id, - TenantCreditPool.pool_type == pool_type, + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: + return session.scalar( + select(TenantCreditPool) + .where( + TenantCreditPool.tenant_id == tenant_id, + TenantCreditPool.pool_type == pool_type, + ) + .limit(1) ) - .limit(1) - ) @classmethod def check_credits_available( @@ -71,7 +72,7 @@ class CreditPoolService: actual_credits = min(credits_required, pool.remaining_credits) try: - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: stmt = ( update(TenantCreditPool) .where( @@ -81,7 +82,6 @@ class CreditPoolService: .values(quota_used=TenantCreditPool.quota_used + actual_credits) ) session.execute(stmt) - session.commit() except Exception: logger.exception("Failed to deduct credits for tenant %s", tenant_id) raise QuotaExceededError("Failed to deduct credits") diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 6be929677e..6c6de192c6 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -15,7 +15,7 @@ from graphon.model_runtime.entities.model_entities import ModelFeature, ModelTyp from graphon.model_runtime.model_providers.__base.text_embedding_model import TextEmbeddingModel from redis.exceptions import LockNotOwnedError from sqlalchemy import delete, exists, func, select, update -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import Forbidden, NotFound from configs import dify_config @@ -233,7 +233,7 @@ class DatasetService: embedding_model_provider: str | None = None, embedding_model_name: str | None = None, retrieval_model: RetrievalModel | None = None, - summary_index_setting: dict | None = None, + summary_index_setting: dict[str, Any] | None = None, ): # check if dataset name already exists if db.session.scalar(select(Dataset).where(Dataset.name == name, Dataset.tenant_id == tenant_id).limit(1)): @@ -528,6 +528,8 @@ class DatasetService: raise ValueError("External knowledge id is required.") if not external_knowledge_api_id: raise ValueError("External knowledge api id is required.") + # Ensure the referenced external API template exists and belongs to the dataset tenant. + ExternalDatasetService.get_external_knowledge_api(external_knowledge_api_id, dataset.tenant_id) # Update metadata fields dataset.updated_by = user.id if user else None dataset.updated_at = naive_utc_now() @@ -551,22 +553,22 @@ class DatasetService: external_knowledge_id: External knowledge identifier external_knowledge_api_id: External knowledge API identifier """ - with Session(db.engine) as session: - external_knowledge_binding = ( - session.query(ExternalKnowledgeBindings).filter_by(dataset_id=dataset_id).first() + with sessionmaker(db.engine).begin() as session: + external_knowledge_binding = session.scalar( + select(ExternalKnowledgeBindings).where(ExternalKnowledgeBindings.dataset_id == dataset_id).limit(1) ) if not external_knowledge_binding: raise ValueError("External knowledge binding not found.") - # Update binding if values have changed - if ( - external_knowledge_binding.external_knowledge_id != external_knowledge_id - or external_knowledge_binding.external_knowledge_api_id != external_knowledge_api_id - ): - external_knowledge_binding.external_knowledge_id = external_knowledge_id - external_knowledge_binding.external_knowledge_api_id = external_knowledge_api_id - db.session.add(external_knowledge_binding) + # Update binding if values have changed + if ( + external_knowledge_binding.external_knowledge_id != external_knowledge_id + or external_knowledge_binding.external_knowledge_api_id != external_knowledge_api_id + ): + external_knowledge_binding.external_knowledge_id = external_knowledge_id + external_knowledge_binding.external_knowledge_api_id = external_knowledge_api_id + session.add(external_knowledge_binding) @staticmethod def _update_internal_dataset(dataset, data, user): @@ -1454,15 +1456,17 @@ class DocumentService: document_id_list: list[str] = [str(document_id) for document_id in document_ids] with session_factory.create_session() as session: - updated_count = ( - session.query(Document) - .filter( + result = session.execute( + update(Document) + .where( Document.id.in_(document_id_list), Document.dataset_id == dataset_id, Document.doc_form != IndexStructureType.QA_INDEX, # Skip qa_model documents ) - .update({Document.need_summary: need_summary}, synchronize_session=False) + .values(need_summary=need_summary) + .execution_options(synchronize_session=False) ) + updated_count = result.rowcount # type: ignore[union-attr,attr-defined] session.commit() logger.info( "Updated need_summary to %s for %d documents in dataset %s", @@ -2489,7 +2493,7 @@ class DocumentService: data_source_type: str, document_form: str, document_language: str, - data_source_info: dict, + data_source_info: dict[str, Any], created_from: str, position: int, account: Account, @@ -2822,6 +2826,10 @@ class DocumentService: knowledge_config.process_rule.rules.pre_processing_rules = list(unique_pre_processing_rule_dicts.values()) + if knowledge_config.process_rule.mode == ProcessRuleMode.HIERARCHICAL: + if not knowledge_config.process_rule.rules.parent_mode: + knowledge_config.process_rule.rules.parent_mode = "paragraph" + if not knowledge_config.process_rule.rules.segmentation: raise ValueError("Process rule segmentation is required") @@ -2842,7 +2850,7 @@ class DocumentService: raise ValueError("Process rule segmentation max_tokens is invalid") @classmethod - def estimate_args_validate(cls, args: dict): + def estimate_args_validate(cls, args: dict[str, Any]): if "info_list" not in args or not args["info_list"]: raise ValueError("Data source info is required") @@ -3124,7 +3132,7 @@ class DocumentService: class SegmentService: @classmethod - def segment_create_args_validate(cls, args: dict, document: Document): + def segment_create_args_validate(cls, args: dict[str, Any], document: Document): if document.doc_form == IndexStructureType.QA_INDEX: if "answer" not in args or not args["answer"]: raise ValueError("Answer is required") @@ -3141,7 +3149,7 @@ class SegmentService: raise ValueError(f"Exceeded maximum attachment limit of {single_chunk_attachment_limit}") @classmethod - def create_segment(cls, args: dict, document: Document, dataset: Dataset): + def create_segment(cls, args: dict[str, Any], document: Document, dataset: Dataset): assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None diff --git a/api/services/datasource_provider_service.py b/api/services/datasource_provider_service.py index faa978afdc..364c4a86a0 100644 --- a/api/services/datasource_provider_service.py +++ b/api/services/datasource_provider_service.py @@ -4,8 +4,8 @@ from collections.abc import Mapping from typing import Any from graphon.model_runtime.entities.provider_entities import FormType -from sqlalchemy import func, select -from sqlalchemy.orm import Session +from sqlalchemy import delete, func, select, update +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from constants import HIDDEN_VALUE, UNKNOWN_VALUE @@ -53,13 +53,14 @@ class DatasourceProviderService: """ remove oauth custom client params """ - with Session(db.engine) as session: - session.query(DatasourceOauthTenantParamConfig).filter_by( - tenant_id=tenant_id, - provider=datasource_provider_id.provider_name, - plugin_id=datasource_provider_id.plugin_id, - ).delete() - session.commit() + with sessionmaker(bind=db.engine).begin() as session: + session.execute( + delete(DatasourceOauthTenantParamConfig).where( + DatasourceOauthTenantParamConfig.tenant_id == tenant_id, + DatasourceOauthTenantParamConfig.provider == datasource_provider_id.provider_name, + DatasourceOauthTenantParamConfig.plugin_id == datasource_provider_id.plugin_id, + ) + ) def decrypt_datasource_provider_credentials( self, @@ -109,17 +110,23 @@ class DatasourceProviderService: """ get credential by id """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: if credential_id: - datasource_provider = ( - session.query(DatasourceProvider).filter_by(tenant_id=tenant_id, id=credential_id).first() + datasource_provider = session.scalar( + select(DatasourceProvider) + .where(DatasourceProvider.tenant_id == tenant_id, DatasourceProvider.id == credential_id) + .limit(1) ) else: - datasource_provider = ( - session.query(DatasourceProvider) - .filter_by(tenant_id=tenant_id, provider=provider, plugin_id=plugin_id) + datasource_provider = session.scalar( + select(DatasourceProvider) + .where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.provider == provider, + DatasourceProvider.plugin_id == plugin_id, + ) .order_by(DatasourceProvider.is_default.desc(), DatasourceProvider.created_at.asc()) - .first() + .limit(1) ) if not datasource_provider: return {} @@ -156,7 +163,6 @@ class DatasourceProviderService: datasource_provider=datasource_provider, ) datasource_provider.expires_at = refreshed_credentials.expires_at - session.commit() return self.decrypt_datasource_provider_credentials( tenant_id=tenant_id, @@ -174,13 +180,16 @@ class DatasourceProviderService: """ get all datasource credentials by provider """ - with Session(db.engine) as session: - datasource_providers = ( - session.query(DatasourceProvider) - .filter_by(tenant_id=tenant_id, provider=provider, plugin_id=plugin_id) + with sessionmaker(bind=db.engine).begin() as session: + datasource_providers = session.scalars( + select(DatasourceProvider) + .where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.provider == provider, + DatasourceProvider.plugin_id == plugin_id, + ) .order_by(DatasourceProvider.is_default.desc(), DatasourceProvider.created_at.asc()) - .all() - ) + ).all() if not datasource_providers: return [] current_user = get_current_user() @@ -224,7 +233,6 @@ class DatasourceProviderService: provider=provider, ) real_credentials_list.append(real_credentials) - session.commit() return real_credentials_list @@ -234,16 +242,16 @@ class DatasourceProviderService: """ update datasource provider name """ - with Session(db.engine) as session: - target_provider = ( - session.query(DatasourceProvider) - .filter_by( - tenant_id=tenant_id, - id=credential_id, - provider=datasource_provider_id.provider_name, - plugin_id=datasource_provider_id.plugin_id, + with sessionmaker(bind=db.engine).begin() as session: + target_provider = session.scalar( + select(DatasourceProvider) + .where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.id == credential_id, + DatasourceProvider.provider == datasource_provider_id.provider_name, + DatasourceProvider.plugin_id == datasource_provider_id.plugin_id, ) - .first() + .limit(1) ) if target_provider is None: raise ValueError("provider not found") @@ -253,20 +261,19 @@ class DatasourceProviderService: # check name is exist if ( - session.query(DatasourceProvider) - .filter_by( - tenant_id=tenant_id, - name=name, - provider=datasource_provider_id.provider_name, - plugin_id=datasource_provider_id.plugin_id, + session.scalar( + select(func.count(DatasourceProvider.id)).where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.name == name, + DatasourceProvider.provider == datasource_provider_id.provider_name, + DatasourceProvider.plugin_id == datasource_provider_id.plugin_id, + ) ) - .count() - > 0 - ): + or 0 + ) > 0: raise ValueError("Authorization name is already exists") target_provider.name = name - session.commit() return def set_default_datasource_provider( @@ -275,39 +282,43 @@ class DatasourceProviderService: """ set default datasource provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # get provider - target_provider = ( - session.query(DatasourceProvider) - .filter_by( - tenant_id=tenant_id, - id=credential_id, - provider=datasource_provider_id.provider_name, - plugin_id=datasource_provider_id.plugin_id, + target_provider = session.scalar( + select(DatasourceProvider) + .where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.id == credential_id, + DatasourceProvider.provider == datasource_provider_id.provider_name, + DatasourceProvider.plugin_id == datasource_provider_id.plugin_id, ) - .first() + .limit(1) ) if target_provider is None: raise ValueError("provider not found") # clear default provider - session.query(DatasourceProvider).filter_by( - tenant_id=tenant_id, - provider=target_provider.provider, - plugin_id=target_provider.plugin_id, - is_default=True, - ).update({"is_default": False}) + session.execute( + update(DatasourceProvider) + .where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.provider == target_provider.provider, + DatasourceProvider.plugin_id == target_provider.plugin_id, + DatasourceProvider.is_default.is_(True), + ) + .values(is_default=False) + .execution_options(synchronize_session=False) + ) # set new default provider target_provider.is_default = True - session.commit() return {"result": "success"} def setup_oauth_custom_client_params( self, tenant_id: str, datasource_provider_id: DatasourceProviderID, - client_params: dict | None, + client_params: dict[str, Any] | None, enabled: bool | None, ): """ @@ -315,15 +326,15 @@ class DatasourceProviderService: """ if client_params is None and enabled is None: return - with Session(db.engine) as session: - tenant_oauth_client_params = ( - session.query(DatasourceOauthTenantParamConfig) - .filter_by( - tenant_id=tenant_id, - provider=datasource_provider_id.provider_name, - plugin_id=datasource_provider_id.plugin_id, + with sessionmaker(bind=db.engine).begin() as session: + tenant_oauth_client_params = session.scalar( + select(DatasourceOauthTenantParamConfig) + .where( + DatasourceOauthTenantParamConfig.tenant_id == tenant_id, + DatasourceOauthTenantParamConfig.provider == datasource_provider_id.provider_name, + DatasourceOauthTenantParamConfig.plugin_id == datasource_provider_id.plugin_id, ) - .first() + .limit(1) ) if not tenant_oauth_client_params: @@ -341,7 +352,7 @@ class DatasourceProviderService: original_params = ( encrypter.decrypt(tenant_oauth_client_params.client_params) if tenant_oauth_client_params else {} ) - new_params: dict = { + new_params: dict[str, Any] = { key: value if value != HIDDEN_VALUE else original_params.get(key, UNKNOWN_VALUE) for key, value in client_params.items() } @@ -349,7 +360,6 @@ class DatasourceProviderService: if enabled is not None: tenant_oauth_client_params.enabled = enabled - session.commit() def is_system_oauth_params_exist(self, datasource_provider_id: DatasourceProviderID) -> bool: """ @@ -357,9 +367,14 @@ class DatasourceProviderService: """ with Session(db.engine).no_autoflush as session: return ( - session.query(DatasourceOauthParamConfig) - .filter_by(provider=datasource_provider_id.provider_name, plugin_id=datasource_provider_id.plugin_id) - .first() + session.scalar( + select(DatasourceOauthParamConfig) + .where( + DatasourceOauthParamConfig.provider == datasource_provider_id.provider_name, + DatasourceOauthParamConfig.plugin_id == datasource_provider_id.plugin_id, + ) + .limit(1) + ) is not None ) @@ -429,15 +444,15 @@ class DatasourceProviderService: plugin_id = datasource_provider_id.plugin_id with Session(db.engine).no_autoflush as session: # get tenant oauth client params - tenant_oauth_client_params = ( - session.query(DatasourceOauthTenantParamConfig) - .filter_by( - tenant_id=tenant_id, - provider=provider, - plugin_id=plugin_id, - enabled=True, + tenant_oauth_client_params = session.scalar( + select(DatasourceOauthTenantParamConfig) + .where( + DatasourceOauthTenantParamConfig.tenant_id == tenant_id, + DatasourceOauthTenantParamConfig.provider == provider, + DatasourceOauthTenantParamConfig.plugin_id == plugin_id, + DatasourceOauthTenantParamConfig.enabled.is_(True), ) - .first() + .limit(1) ) if tenant_oauth_client_params: encrypter, _ = self.get_oauth_encrypter(tenant_id, datasource_provider_id) @@ -449,8 +464,13 @@ class DatasourceProviderService: is_verified = PluginService.is_plugin_verified(tenant_id, provider_controller.plugin_unique_identifier) if is_verified: # fallback to system oauth client params - oauth_client_params = ( - session.query(DatasourceOauthParamConfig).filter_by(provider=provider, plugin_id=plugin_id).first() + oauth_client_params = session.scalar( + select(DatasourceOauthParamConfig) + .where( + DatasourceOauthParamConfig.provider == provider, + DatasourceOauthParamConfig.plugin_id == plugin_id, + ) + .limit(1) ) if oauth_client_params: return oauth_client_params.system_credentials @@ -461,15 +481,13 @@ class DatasourceProviderService: def generate_next_datasource_provider_name( session: Session, tenant_id: str, provider_id: DatasourceProviderID, credential_type: CredentialType ) -> str: - db_providers = ( - session.query(DatasourceProvider) - .filter_by( - tenant_id=tenant_id, - provider=provider_id.provider_name, - plugin_id=provider_id.plugin_id, + db_providers = session.scalars( + select(DatasourceProvider).where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.provider == provider_id.provider_name, + DatasourceProvider.plugin_id == provider_id.plugin_id, ) - .all() - ) + ).all() return generate_incremental_name( [provider.name for provider in db_providers], f"{credential_type.get_name()}", @@ -482,17 +500,19 @@ class DatasourceProviderService: provider_id: DatasourceProviderID, avatar_url: str | None, expire_at: int, - credentials: dict, + credentials: dict[str, Any], credential_id: str, ) -> None: """ update datasource oauth provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{CredentialType.OAUTH2.value}" with redis_client.lock(lock, timeout=20): - target_provider = ( - session.query(DatasourceProvider).filter_by(id=credential_id, tenant_id=tenant_id).first() + target_provider = session.scalar( + select(DatasourceProvider) + .where(DatasourceProvider.id == credential_id, DatasourceProvider.tenant_id == tenant_id) + .limit(1) ) if target_provider is None: raise ValueError("provider not found") @@ -502,25 +522,28 @@ class DatasourceProviderService: db_provider_name = target_provider.name else: name_conflict = ( - session.query(DatasourceProvider) - .filter_by( - tenant_id=tenant_id, - name=db_provider_name, - provider=provider_id.provider_name, - plugin_id=provider_id.plugin_id, - auth_type=CredentialType.OAUTH2.value, + session.scalar( + select(func.count(DatasourceProvider.id)).where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.name == db_provider_name, + DatasourceProvider.provider == provider_id.provider_name, + DatasourceProvider.plugin_id == provider_id.plugin_id, + DatasourceProvider.auth_type == CredentialType.OAUTH2.value, + ) ) - .count() + or 0 ) if name_conflict > 0: db_provider_name = generate_incremental_name( [ provider.name - for provider in session.query(DatasourceProvider).filter_by( - tenant_id=tenant_id, - provider=provider_id.provider_name, - plugin_id=provider_id.plugin_id, - ) + for provider in session.scalars( + select(DatasourceProvider).where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.provider == provider_id.provider_name, + DatasourceProvider.plugin_id == provider_id.plugin_id, + ) + ).all() ], db_provider_name, ) @@ -535,7 +558,6 @@ class DatasourceProviderService: target_provider.expires_at = expire_at target_provider.encrypted_credentials = credentials target_provider.avatar_url = avatar_url or target_provider.avatar_url - session.commit() def add_datasource_oauth_provider( self, @@ -544,13 +566,13 @@ class DatasourceProviderService: provider_id: DatasourceProviderID, avatar_url: str | None, expire_at: int, - credentials: dict, + credentials: dict[str, Any], ) -> None: """ add datasource oauth provider """ credential_type = CredentialType.OAUTH2 - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{credential_type.value}" with redis_client.lock(lock, timeout=60): db_provider_name = name @@ -563,25 +585,27 @@ class DatasourceProviderService: ) else: if ( - session.query(DatasourceProvider) - .filter_by( - tenant_id=tenant_id, - name=db_provider_name, - provider=provider_id.provider_name, - plugin_id=provider_id.plugin_id, - auth_type=credential_type.value, + session.scalar( + select(func.count(DatasourceProvider.id)).where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.name == db_provider_name, + DatasourceProvider.provider == provider_id.provider_name, + DatasourceProvider.plugin_id == provider_id.plugin_id, + DatasourceProvider.auth_type == credential_type.value, + ) ) - .count() - > 0 - ): + or 0 + ) > 0: db_provider_name = generate_incremental_name( [ provider.name - for provider in session.query(DatasourceProvider).filter_by( - tenant_id=tenant_id, - provider=provider_id.provider_name, - plugin_id=provider_id.plugin_id, - ) + for provider in session.scalars( + select(DatasourceProvider).where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.provider == provider_id.provider_name, + DatasourceProvider.plugin_id == provider_id.plugin_id, + ) + ).all() ], db_provider_name, ) @@ -604,14 +628,13 @@ class DatasourceProviderService: expires_at=expire_at, ) session.add(datasource_provider) - session.commit() def add_datasource_api_key_provider( self, name: str | None, tenant_id: str, provider_id: DatasourceProviderID, - credentials: dict, + credentials: dict[str, Any], ) -> None: """ validate datasource provider credentials. @@ -623,7 +646,7 @@ class DatasourceProviderService: provider_name = provider_id.provider_name plugin_id = provider_id.plugin_id - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{CredentialType.API_KEY}" with redis_client.lock(lock, timeout=20): db_provider_name = name or self.generate_next_datasource_provider_name( @@ -635,11 +658,16 @@ class DatasourceProviderService: # check name is exist if ( - session.query(DatasourceProvider) - .filter_by(tenant_id=tenant_id, plugin_id=plugin_id, provider=provider_name, name=db_provider_name) - .count() - > 0 - ): + session.scalar( + select(func.count(DatasourceProvider.id)).where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.plugin_id == plugin_id, + DatasourceProvider.provider == provider_name, + DatasourceProvider.name == db_provider_name, + ) + ) + or 0 + ) > 0: raise ValueError("Authorization name is already exists") try: @@ -670,7 +698,6 @@ class DatasourceProviderService: encrypted_credentials=credentials, ) session.add(datasource_provider) - session.commit() def extract_secret_variables(self, tenant_id: str, provider_id: str, credential_type: CredentialType) -> list[str]: """ @@ -920,28 +947,44 @@ class DatasourceProviderService: return copy_credentials_list def update_datasource_credentials( - self, tenant_id: str, auth_id: str, provider: str, plugin_id: str, credentials: dict | None, name: str | None + self, + tenant_id: str, + auth_id: str, + provider: str, + plugin_id: str, + credentials: dict[str, Any] | None, + name: str | None, ) -> None: """ update datasource credentials. """ - with Session(db.engine) as session: - datasource_provider = ( - session.query(DatasourceProvider) - .filter_by(tenant_id=tenant_id, id=auth_id, provider=provider, plugin_id=plugin_id) - .first() + with sessionmaker(bind=db.engine).begin() as session: + datasource_provider = session.scalar( + select(DatasourceProvider) + .where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.id == auth_id, + DatasourceProvider.provider == provider, + DatasourceProvider.plugin_id == plugin_id, + ) + .limit(1) ) if not datasource_provider: raise ValueError("Datasource provider not found") # update name if name and name != datasource_provider.name: if ( - session.query(DatasourceProvider) - .filter_by(tenant_id=tenant_id, name=name, provider=provider, plugin_id=plugin_id) - .count() - > 0 - ): + session.scalar( + select(func.count(DatasourceProvider.id)).where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.name == name, + DatasourceProvider.provider == provider, + DatasourceProvider.plugin_id == plugin_id, + ) + ) + or 0 + ) > 0: raise ValueError("Authorization name is already exists") datasource_provider.name = name @@ -980,7 +1023,6 @@ class DatasourceProviderService: encrypted_credentials[key] = value datasource_provider.encrypted_credentials = encrypted_credentials - session.commit() def remove_datasource_credentials(self, tenant_id: str, auth_id: str, provider: str, plugin_id: str) -> None: """ diff --git a/api/services/end_user_service.py b/api/services/end_user_service.py index 29ada270ec..749d8dbc30 100644 --- a/api/services/end_user_service.py +++ b/api/services/end_user_service.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping from sqlalchemy import case, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db @@ -24,7 +24,7 @@ class EndUserService: when an end-user ID is known. """ - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: return session.scalar( select(EndUser) .where( @@ -54,7 +54,7 @@ class EndUserService: if not user_id: user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # Query with ORDER BY to prioritize exact type matches while maintaining backward compatibility # This single query approach is more efficient than separate queries end_user = session.scalar( @@ -82,7 +82,6 @@ class EndUserService: user_id, ) end_user.type = type - session.commit() else: # Create new end user if none exists end_user = EndUser( @@ -94,7 +93,6 @@ class EndUserService: external_user_id=user_id, ) session.add(end_user) - session.commit() return end_user @@ -135,7 +133,7 @@ class EndUserService: if not unique_app_ids: return result - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # Fetch existing end users for all target apps in a single query existing_end_users: list[EndUser] = list( session.scalars( @@ -174,7 +172,6 @@ class EndUserService: ) session.add_all(new_end_users) - session.commit() for eu in new_end_users: result[eu.app_id] = eu diff --git a/api/services/entities/auth_entities.py b/api/services/entities/auth_entities.py index 6b720a4607..e3fb249692 100644 --- a/api/services/entities/auth_entities.py +++ b/api/services/entities/auth_entities.py @@ -1,9 +1,25 @@ +from enum import StrEnum, auto + from pydantic import BaseModel, Field, field_validator from libs.helper import EmailStr from libs.password import valid_password +class LoginFailureReason(StrEnum): + """Bounded reason codes for failed login audit logs.""" + + ACCOUNT_BANNED = auto() + ACCOUNT_IN_FREEZE = auto() + ACCOUNT_NOT_FOUND = auto() + EMAIL_CODE_EMAIL_MISMATCH = auto() + INVALID_CREDENTIALS = auto() + INVALID_EMAIL_CODE = auto() + INVALID_EMAIL_CODE_TOKEN = auto() + INVALID_INVITATION_EMAIL = auto() + LOGIN_RATE_LIMITED = auto() + + class LoginPayloadBase(BaseModel): email: EmailStr password: str diff --git a/api/services/entities/dsl_entities.py b/api/services/entities/dsl_entities.py new file mode 100644 index 0000000000..05baa51fbd --- /dev/null +++ b/api/services/entities/dsl_entities.py @@ -0,0 +1,21 @@ +from enum import StrEnum + +from pydantic import BaseModel, Field + +from core.plugin.entities.plugin import PluginDependency + + +class ImportMode(StrEnum): + YAML_CONTENT = "yaml-content" + YAML_URL = "yaml-url" + + +class ImportStatus(StrEnum): + COMPLETED = "completed" + COMPLETED_WITH_WARNINGS = "completed-with-warnings" + PENDING = "pending" + FAILED = "failed" + + +class CheckDependenciesResult(BaseModel): + leaked_dependencies: list[PluginDependency] = Field(default_factory=list) diff --git a/api/services/entities/external_knowledge_entities/external_knowledge_entities.py b/api/services/entities/external_knowledge_entities/external_knowledge_entities.py index c9fb1c9e21..110dbe5a5e 100644 --- a/api/services/entities/external_knowledge_entities/external_knowledge_entities.py +++ b/api/services/entities/external_knowledge_entities/external_knowledge_entities.py @@ -1,4 +1,4 @@ -from typing import Literal, Union +from typing import Any, Literal, Union from pydantic import BaseModel @@ -22,5 +22,5 @@ class ProcessStatusSetting(BaseModel): class ExternalKnowledgeApiSetting(BaseModel): url: str request_method: str - headers: dict | None = None - params: dict | None = None + headers: dict[str, Any] | None = None + params: dict[str, Any] | None = None diff --git a/api/services/entities/knowledge_entities/knowledge_entities.py b/api/services/entities/knowledge_entities/knowledge_entities.py index cb38104e8c..f6a670415b 100644 --- a/api/services/entities/knowledge_entities/knowledge_entities.py +++ b/api/services/entities/knowledge_entities/knowledge_entities.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, field_validator @@ -7,6 +7,11 @@ from core.rag.index_processor.constant.index_type import IndexStructureType from core.rag.retrieval.retrieval_methods import RetrievalMethod +class RerankingModel(BaseModel): + reranking_provider_name: str | None = None + reranking_model_name: str | None = None + + class NotionIcon(BaseModel): type: str url: str | None = None @@ -53,11 +58,6 @@ class ProcessRule(BaseModel): rules: Rule | None = None -class RerankingModel(BaseModel): - reranking_provider_name: str | None = None - reranking_model_name: str | None = None - - class WeightVectorSetting(BaseModel): vector_weight: float embedding_provider_name: str @@ -97,7 +97,7 @@ class KnowledgeConfig(BaseModel): data_source: DataSource | None = None process_rule: ProcessRule | None = None retrieval_model: RetrievalModel | None = None - summary_index_setting: dict | None = None + summary_index_setting: dict[str, Any] | None = None doc_form: str = "text_model" doc_language: str = "English" embedding_model: str | None = None diff --git a/api/services/entities/knowledge_entities/rag_pipeline_entities.py b/api/services/entities/knowledge_entities/rag_pipeline_entities.py index 07fbe963d6..7fb7ed12bf 100644 --- a/api/services/entities/knowledge_entities/rag_pipeline_entities.py +++ b/api/services/entities/knowledge_entities/rag_pipeline_entities.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, field_validator @@ -6,6 +6,24 @@ from core.rag.entities import KeywordSetting, VectorSetting from core.rag.retrieval.retrieval_methods import RetrievalMethod +class RerankingModelConfig(BaseModel): + """ + Reranking Model Config. + """ + + reranking_provider_name: str | None = "" + reranking_model_name: str | None = "" + + +class WeightedScoreConfig(BaseModel): + """ + Weighted score Config. + """ + + vector_setting: VectorSetting | None + keyword_setting: KeywordSetting | None + + class IconInfo(BaseModel): icon: str icon_background: str | None = None @@ -28,41 +46,6 @@ class RagPipelineDatasetCreateEntity(BaseModel): yaml_content: str | None = None -class RerankingModelConfig(BaseModel): - """ - Reranking Model Config. - """ - - reranking_provider_name: str | None = "" - reranking_model_name: str | None = "" - - -class WeightedScoreConfig(BaseModel): - """ - Weighted score Config. - """ - - vector_setting: VectorSetting | None - keyword_setting: KeywordSetting | None - - -class EmbeddingSetting(BaseModel): - """ - Embedding Setting. - """ - - embedding_provider_name: str - embedding_model_name: str - - -class EconomySetting(BaseModel): - """ - Economy Setting. - """ - - keyword_number: int - - class RetrievalSetting(BaseModel): """ Retrieval Setting. @@ -78,16 +61,6 @@ class RetrievalSetting(BaseModel): weights: WeightedScoreConfig | None = None -class IndexMethod(BaseModel): - """ - Knowledge Index Setting. - """ - - indexing_technique: Literal["high_quality", "economy"] - embedding_setting: EmbeddingSetting - economy_setting: EconomySetting - - class KnowledgeConfiguration(BaseModel): """ Knowledge Base Configuration. @@ -100,7 +73,7 @@ class KnowledgeConfiguration(BaseModel): keyword_number: int | None = 10 retrieval_model: RetrievalSetting # add summary index setting - summary_index_setting: dict | None = None + summary_index_setting: dict[str, Any] | None = None @field_validator("embedding_model_provider", mode="before") @classmethod diff --git a/api/services/external_knowledge_service.py b/api/services/external_knowledge_service.py index d30ec940f5..6dcedfdced 100644 --- a/api/services/external_knowledge_service.py +++ b/api/services/external_knowledge_service.py @@ -1,6 +1,6 @@ import json from copy import deepcopy -from typing import Any, Union, cast +from typing import Any, cast from urllib.parse import urlparse import httpx @@ -47,7 +47,7 @@ class ExternalDatasetService: return external_knowledge_apis.items, external_knowledge_apis.total @classmethod - def validate_api_list(cls, api_settings: dict): + def validate_api_list(cls, api_settings: dict[str, Any]): if not api_settings: raise ValueError("api list is empty") if not api_settings.get("endpoint"): @@ -56,7 +56,7 @@ class ExternalDatasetService: raise ValueError("api_key is required") @staticmethod - def create_external_knowledge_api(tenant_id: str, user_id: str, args: dict) -> ExternalKnowledgeApis: + def create_external_knowledge_api(tenant_id: str, user_id: str, args: dict[str, Any]) -> ExternalKnowledgeApis: settings = args.get("settings") if settings is None: raise ValueError("settings is required") @@ -75,7 +75,7 @@ class ExternalDatasetService: return external_knowledge_api @staticmethod - def check_endpoint_and_api_key(settings: dict): + def check_endpoint_and_api_key(settings: dict[str, Any]): if "endpoint" not in settings or not settings["endpoint"]: raise ValueError("endpoint is required") if "api_key" not in settings or not settings["api_key"]: @@ -148,18 +148,23 @@ class ExternalDatasetService: db.session.commit() @staticmethod - def external_knowledge_api_use_check(external_knowledge_api_id: str) -> tuple[bool, int]: + def external_knowledge_api_use_check(external_knowledge_api_id: str, tenant_id: str) -> tuple[bool, int]: + """ + Return usage for an external knowledge API within a single tenant. + + The caller already scopes access by tenant, so this query must do the + same; otherwise the endpoint becomes a cross-tenant UUID oracle. + """ count = ( db.session.scalar( select(func.count(ExternalKnowledgeBindings.id)).where( - ExternalKnowledgeBindings.external_knowledge_api_id == external_knowledge_api_id + ExternalKnowledgeBindings.external_knowledge_api_id == external_knowledge_api_id, + ExternalKnowledgeBindings.tenant_id == tenant_id, ) ) or 0 ) - if count > 0: - return True, count - return False, 0 + return count > 0, count @staticmethod def get_external_knowledge_binding_with_dataset_id(tenant_id: str, dataset_id: str) -> ExternalKnowledgeBindings: @@ -173,7 +178,9 @@ class ExternalDatasetService: return external_knowledge_binding @staticmethod - def document_create_args_validate(tenant_id: str, external_knowledge_api_id: str, process_parameter: dict): + def document_create_args_validate( + tenant_id: str, external_knowledge_api_id: str, process_parameter: dict[str, Any] + ): external_knowledge_api = db.session.scalar( select(ExternalKnowledgeApis) .where(ExternalKnowledgeApis.id == external_knowledge_api_id, ExternalKnowledgeApis.tenant_id == tenant_id) @@ -190,9 +197,7 @@ class ExternalDatasetService: raise ValueError(f"{parameter.get('name')} is required") @staticmethod - def process_external_api( - settings: ExternalKnowledgeApiSetting, files: Union[None, dict[str, Any]] - ) -> httpx.Response: + def process_external_api(settings: ExternalKnowledgeApiSetting, files: dict[str, Any] | None) -> httpx.Response: """ do http request depending on api bundle """ @@ -219,7 +224,7 @@ class ExternalDatasetService: return response @staticmethod - def assembling_headers(authorization: Authorization, headers: dict | None = None) -> dict[str, Any]: + def assembling_headers(authorization: Authorization, headers: dict[str, Any] | None = None) -> dict[str, Any]: authorization = deepcopy(authorization) if headers: headers = deepcopy(headers) @@ -245,11 +250,11 @@ class ExternalDatasetService: return headers @staticmethod - def get_external_knowledge_api_settings(settings: dict) -> ExternalKnowledgeApiSetting: + def get_external_knowledge_api_settings(settings: dict[str, Any]) -> ExternalKnowledgeApiSetting: return ExternalKnowledgeApiSetting.model_validate(settings) @staticmethod - def create_external_dataset(tenant_id: str, user_id: str, args: dict) -> Dataset: + def create_external_dataset(tenant_id: str, user_id: str, args: dict[str, Any]) -> Dataset: # check if dataset name already exists if db.session.scalar( select(Dataset).where(Dataset.name == args.get("name"), Dataset.tenant_id == tenant_id).limit(1) @@ -301,7 +306,7 @@ class ExternalDatasetService: tenant_id: str, dataset_id: str, query: str, - external_retrieval_parameters: dict, + external_retrieval_parameters: dict[str, Any], metadata_condition: MetadataFilteringCondition | None = None, ): external_knowledge_binding = db.session.scalar( @@ -314,7 +319,10 @@ class ExternalDatasetService: external_knowledge_api = db.session.scalar( select(ExternalKnowledgeApis) - .where(ExternalKnowledgeApis.id == external_knowledge_binding.external_knowledge_api_id) + .where( + ExternalKnowledgeApis.id == external_knowledge_binding.external_knowledge_api_id, + ExternalKnowledgeApis.tenant_id == tenant_id, + ) .limit(1) ) if external_knowledge_api is None or external_knowledge_api.settings is None: diff --git a/api/services/feature_service.py b/api/services/feature_service.py index f38e1762d1..df653e0ba7 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -312,7 +312,10 @@ class FeatureService: features.apps.limit = billing_info["apps"]["limit"] if "vector_space" in billing_info: - features.vector_space.size = billing_info["vector_space"]["size"] + # NOTE (hj24): billing API returns vector_space.size as float (e.g. 0.0) + # but LimitationModel.size is int; truncate here for compatibility + features.vector_space.size = int(billing_info["vector_space"]["size"]) + # NOTE END features.vector_space.limit = billing_info["vector_space"]["limit"] if "documents_upload_quota" in billing_info: @@ -333,7 +336,11 @@ class FeatureService: features.model_load_balancing_enabled = billing_info["model_load_balancing_enabled"] if "knowledge_rate_limit" in billing_info: + # NOTE (hj24): + # 1. knowledge_rate_limit size is nullable, currently it's defined but never used, only limit is used. + # 2. So be careful if later we decide to use [size], we cannot assume it is always present. features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"] + # NOTE END if "knowledge_pipeline_publish_enabled" in billing_info: features.knowledge_pipeline.publish_enabled = billing_info["knowledge_pipeline_publish_enabled"] diff --git a/api/services/file_service.py b/api/services/file_service.py index 50a326d813..79a935de4b 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -5,7 +5,7 @@ import uuid from collections.abc import Iterator, Sequence from contextlib import contextmanager, suppress from tempfile import NamedTemporaryFile -from typing import Literal, Union +from typing import Literal from zipfile import ZIP_DEFLATED, ZipFile from graphon.file import helpers as file_helpers @@ -52,7 +52,7 @@ class FileService: filename: str, content: bytes, mimetype: str, - user: Union[Account, EndUser], + user: Account | EndUser, source: Literal["datasets"] | None = None, source_url: str = "", ) -> UploadFile: @@ -132,8 +132,8 @@ class FileService: return file_size <= file_size_limit def get_file_base64(self, file_id: str) -> str: - upload_file = ( - self._session_maker(expire_on_commit=False).query(UploadFile).where(UploadFile.id == file_id).first() + upload_file = self._session_maker(expire_on_commit=False).scalar( + select(UploadFile).where(UploadFile.id == file_id).limit(1) ) if not upload_file: raise NotFound("File not found") @@ -178,7 +178,7 @@ class FileService: Return a short text preview extracted from a document file. """ with self._session_maker(expire_on_commit=False) as session: - upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first() + upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) if not upload_file: raise NotFound("File not found") @@ -200,7 +200,7 @@ class FileService: if not result: raise NotFound("File not found or signature is invalid") with self._session_maker(expire_on_commit=False) as session: - upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first() + upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) if not upload_file: raise NotFound("File not found or signature is invalid") @@ -220,7 +220,7 @@ class FileService: raise NotFound("File not found or signature is invalid") with self._session_maker(expire_on_commit=False) as session: - upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first() + upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) if not upload_file: raise NotFound("File not found or signature is invalid") @@ -231,7 +231,7 @@ class FileService: def get_public_image_preview(self, file_id: str): with self._session_maker(expire_on_commit=False) as session: - upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first() + upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) if not upload_file: raise NotFound("File not found or signature is invalid") @@ -247,7 +247,7 @@ class FileService: def get_file_content(self, file_id: str) -> str: with self._session_maker(expire_on_commit=False) as session: - upload_file: UploadFile | None = session.query(UploadFile).where(UploadFile.id == file_id).first() + upload_file: UploadFile | None = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) if not upload_file: raise NotFound("File not found") diff --git a/api/services/hit_testing_service.py b/api/services/hit_testing_service.py index 7e0100212a..43985e49cd 100644 --- a/api/services/hit_testing_service.py +++ b/api/services/hit_testing_service.py @@ -44,8 +44,8 @@ class HitTestingService: dataset: Dataset, query: str, account: Account, - retrieval_model: dict | None, - external_retrieval_model: dict, + retrieval_model: dict[str, Any] | None, + external_retrieval_model: dict[str, Any], attachment_ids: list | None = None, limit: int = 10, ): @@ -125,8 +125,8 @@ class HitTestingService: dataset: Dataset, query: str, account: Account, - external_retrieval_model: dict | None = None, - metadata_filtering_conditions: dict | None = None, + external_retrieval_model: dict[str, Any] | None = None, + metadata_filtering_conditions: dict[str, Any] | None = None, ): if dataset.provider != "external": return { diff --git a/api/services/model_load_balancing_service.py b/api/services/model_load_balancing_service.py index 3cce83a975..b652e049ce 100644 --- a/api/services/model_load_balancing_service.py +++ b/api/services/model_load_balancing_service.py @@ -1,6 +1,6 @@ import json import logging -from typing import Any, TypedDict, Union +from typing import Any, TypedDict from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.entities.provider_entities import ( @@ -502,7 +502,7 @@ class ModelLoadBalancingService: provider: str, model: str, model_type: str, - credentials: dict, + credentials: dict[str, Any], config_id: str | None = None, ): """ @@ -561,7 +561,7 @@ class ModelLoadBalancingService: provider_configuration: ProviderConfiguration, model_type: ModelType, model: str, - credentials: dict, + credentials: dict[str, Any], load_balancing_model_config: LoadBalancingModelConfig | None = None, model_provider_factory: ModelProviderFactory | None = None, validate: bool = True, @@ -626,7 +626,7 @@ class ModelLoadBalancingService: def _get_credential_schema( self, provider_configuration: ProviderConfiguration - ) -> Union[ModelCredentialSchema, ProviderCredentialSchema]: + ) -> ModelCredentialSchema | ProviderCredentialSchema: """Get form schemas.""" if provider_configuration.provider.model_credential_schema: return provider_configuration.provider.model_credential_schema diff --git a/api/services/oauth_server.py b/api/services/oauth_server.py index b05b43d76e..22648070f0 100644 --- a/api/services/oauth_server.py +++ b/api/services/oauth_server.py @@ -2,7 +2,7 @@ import enum import uuid from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest from extensions.ext_database import db @@ -29,7 +29,7 @@ class OAuthServerService: def get_oauth_provider_app(client_id: str) -> OAuthProviderApp | None: query = select(OAuthProviderApp).where(OAuthProviderApp.client_id == client_id) - with Session(db.engine) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: return session.execute(query).scalar_one_or_none() @staticmethod diff --git a/api/services/operation_service.py b/api/services/operation_service.py index c05e9d555c..903efd26ae 100644 --- a/api/services/operation_service.py +++ b/api/services/operation_service.py @@ -1,8 +1,22 @@ import os +from typing import TypedDict import httpx +class UtmInfo(TypedDict, total=False): + """Expected shape of the utm_info dict passed to record_utm. + + All fields are optional; missing keys default to an empty string. + """ + + utm_source: str + utm_medium: str + utm_campaign: str + utm_content: str + utm_term: str + + class OperationService: base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL") secret_key = os.environ.get("BILLING_API_SECRET_KEY", "BILLING_API_SECRET_KEY") @@ -17,7 +31,7 @@ class OperationService: return response.json() @classmethod - def record_utm(cls, tenant_id: str, utm_info: dict): + def record_utm(cls, tenant_id: str, utm_info: UtmInfo): params = { "tenant_id": tenant_id, "utm_source": utm_info.get("utm_source", ""), diff --git a/api/services/ops_service.py b/api/services/ops_service.py index 0db3d3efec..3ad42faf24 100644 --- a/api/services/ops_service.py +++ b/api/services/ops_service.py @@ -1,3 +1,5 @@ +from typing import Any + from sqlalchemy import select from core.ops.entities.config_entity import BaseTracingConfig @@ -135,7 +137,7 @@ class OpsService: return trace_config_data.to_dict() @classmethod - def create_tracing_app_config(cls, app_id: str, tracing_provider: str, tracing_config: dict): + def create_tracing_app_config(cls, app_id: str, tracing_provider: str, tracing_config: dict[str, Any]): """ Create tracing app config :param app_id: app id @@ -210,7 +212,7 @@ class OpsService: return {"result": "success"} @classmethod - def update_tracing_app_config(cls, app_id: str, tracing_provider: str, tracing_config: dict): + def update_tracing_app_config(cls, app_id: str, tracing_provider: str, tracing_config: dict[str, Any]): """ Update tracing app config :param app_id: app id diff --git a/api/services/plugin/endpoint_service.py b/api/services/plugin/endpoint_service.py index 11b8e0a3d9..1727cd7abd 100644 --- a/api/services/plugin/endpoint_service.py +++ b/api/services/plugin/endpoint_service.py @@ -1,9 +1,13 @@ +from typing import Any + from core.plugin.impl.endpoint import PluginEndpointClient class EndpointService: @classmethod - def create_endpoint(cls, tenant_id: str, user_id: str, plugin_unique_identifier: str, name: str, settings: dict): + def create_endpoint( + cls, tenant_id: str, user_id: str, plugin_unique_identifier: str, name: str, settings: dict[str, Any] + ): return PluginEndpointClient().create_endpoint( tenant_id=tenant_id, user_id=user_id, @@ -32,7 +36,7 @@ class EndpointService: ) @classmethod - def update_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict): + def update_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict[str, Any]): return PluginEndpointClient().update_endpoint( tenant_id=tenant_id, user_id=user_id, diff --git a/api/services/plugin/oauth_service.py b/api/services/plugin/oauth_service.py index 88dec062a0..789b5fa5b7 100644 --- a/api/services/plugin/oauth_service.py +++ b/api/services/plugin/oauth_service.py @@ -1,5 +1,6 @@ import json import uuid +from typing import Any from core.plugin.impl.base import BasePluginClient from extensions.ext_redis import redis_client @@ -16,7 +17,7 @@ class OAuthProxyService(BasePluginClient): tenant_id: str, plugin_id: str, provider: str, - extra_data: dict = {}, + extra_data: dict[str, Any] = {}, credential_id: str | None = None, ): """ diff --git a/api/services/plugin/plugin_auto_upgrade_service.py b/api/services/plugin/plugin_auto_upgrade_service.py index 174bed488d..9bb0ab6ae2 100644 --- a/api/services/plugin/plugin_auto_upgrade_service.py +++ b/api/services/plugin/plugin_auto_upgrade_service.py @@ -1,17 +1,17 @@ -from sqlalchemy.orm import Session +from sqlalchemy import select -from extensions.ext_database import db +from core.db.session_factory import session_factory from models.account import TenantPluginAutoUpgradeStrategy class PluginAutoUpgradeService: @staticmethod def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None: - with Session(db.engine) as session: - return ( - session.query(TenantPluginAutoUpgradeStrategy) + with session_factory.create_session() as session: + return session.scalar( + select(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .first() + .limit(1) ) @staticmethod @@ -23,11 +23,11 @@ class PluginAutoUpgradeService: exclude_plugins: list[str], include_plugins: list[str], ) -> bool: - with Session(db.engine) as session: - exist_strategy = ( - session.query(TenantPluginAutoUpgradeStrategy) + with session_factory.create_session() as session: + exist_strategy = session.scalar( + select(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .first() + .limit(1) ) if not exist_strategy: strategy = TenantPluginAutoUpgradeStrategy( @@ -46,16 +46,15 @@ class PluginAutoUpgradeService: exist_strategy.exclude_plugins = exclude_plugins exist_strategy.include_plugins = include_plugins - session.commit() return True @staticmethod def exclude_plugin(tenant_id: str, plugin_id: str) -> bool: - with Session(db.engine) as session: - exist_strategy = ( - session.query(TenantPluginAutoUpgradeStrategy) + with session_factory.create_session() as session: + exist_strategy = session.scalar( + select(TenantPluginAutoUpgradeStrategy) .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .first() + .limit(1) ) if not exist_strategy: # create for this tenant @@ -83,5 +82,4 @@ class PluginAutoUpgradeService: exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE exist_strategy.exclude_plugins = [plugin_id] - session.commit() return True diff --git a/api/services/plugin/plugin_migration.py b/api/services/plugin/plugin_migration.py index d6f6ee8086..43a726b100 100644 --- a/api/services/plugin/plugin_migration.py +++ b/api/services/plugin/plugin_migration.py @@ -13,6 +13,7 @@ import sqlalchemy as sa import tqdm from flask import Flask, current_app from pydantic import TypeAdapter +from sqlalchemy import func, select from sqlalchemy.orm import Session from core.agent.entities import AgentToolEntity @@ -66,7 +67,7 @@ class PluginMigration: current_time = started_at with Session(db.engine) as session: - total_tenant_count = session.query(Tenant.id).count() + total_tenant_count = session.scalar(select(func.count(Tenant.id))) or 0 click.echo(click.style(f"Total tenant count: {total_tenant_count}", fg="white")) @@ -123,9 +124,12 @@ class PluginMigration: tenant_count = 0 for test_interval in test_intervals: tenant_count = ( - session.query(Tenant.id) - .where(Tenant.created_at.between(current_time, current_time + test_interval)) - .count() + session.scalar( + select(func.count(Tenant.id)).where( + Tenant.created_at.between(current_time, current_time + test_interval) + ) + ) + or 0 ) if tenant_count <= 100: interval = test_interval @@ -147,8 +151,8 @@ class PluginMigration: batch_end = min(current_time + interval, ended_at) - rs = ( - session.query(Tenant.id) + rs = session.execute( + select(Tenant.id) .where(Tenant.created_at.between(current_time, batch_end)) .order_by(Tenant.created_at) ) @@ -235,7 +239,7 @@ class PluginMigration: Extract tool tables. """ with Session(db.engine) as session: - rs = session.query(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant_id).all() + rs = session.scalars(select(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant_id)).all() result = [] for row in rs: result.append(ToolProviderID(row.provider).plugin_id) @@ -249,7 +253,7 @@ class PluginMigration: """ with Session(db.engine) as session: - rs = session.query(Workflow).where(Workflow.tenant_id == tenant_id).all() + rs = session.scalars(select(Workflow).where(Workflow.tenant_id == tenant_id)).all() result = [] for row in rs: graph = row.graph_dict @@ -272,7 +276,7 @@ class PluginMigration: Extract app tables. """ with Session(db.engine) as session: - apps = session.query(App).where(App.tenant_id == tenant_id).all() + apps = session.scalars(select(App).where(App.tenant_id == tenant_id)).all() if not apps: return [] @@ -280,7 +284,7 @@ class PluginMigration: app.app_model_config_id for app in apps if app.is_agent or app.mode == AppMode.AGENT_CHAT ] - rs = session.query(AppModelConfig).where(AppModelConfig.id.in_(agent_app_model_config_ids)).all() + rs = session.scalars(select(AppModelConfig).where(AppModelConfig.id.in_(agent_app_model_config_ids))).all() result = [] for row in rs: agent_config = row.agent_mode_dict diff --git a/api/services/plugin/plugin_permission_service.py b/api/services/plugin/plugin_permission_service.py index 60fa269640..3cca4268d0 100644 --- a/api/services/plugin/plugin_permission_service.py +++ b/api/services/plugin/plugin_permission_service.py @@ -1,14 +1,16 @@ -from sqlalchemy.orm import Session +from sqlalchemy import select -from extensions.ext_database import db +from core.db.session_factory import session_factory from models.account import TenantPluginPermission class PluginPermissionService: @staticmethod def get_permission(tenant_id: str) -> TenantPluginPermission | None: - with Session(db.engine) as session: - return session.query(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).first() + with session_factory.create_session() as session: + return session.scalar( + select(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).limit(1) + ) @staticmethod def change_permission( @@ -16,9 +18,9 @@ class PluginPermissionService: install_permission: TenantPluginPermission.InstallPermission, debug_permission: TenantPluginPermission.DebugPermission, ): - with Session(db.engine) as session: - permission = ( - session.query(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).first() + with session_factory.create_session() as session, session.begin(): + permission = session.scalar( + select(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id).limit(1) ) if not permission: permission = TenantPluginPermission( @@ -30,5 +32,4 @@ class PluginPermissionService: permission.install_permission = install_permission permission.debug_permission = debug_permission - session.commit() return True diff --git a/api/services/rag_pipeline/pipeline_generate_service.py b/api/services/rag_pipeline/pipeline_generate_service.py index 10e89b1dba..56bc785958 100644 --- a/api/services/rag_pipeline/pipeline_generate_service.py +++ b/api/services/rag_pipeline/pipeline_generate_service.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Any, Union +from typing import Any from configs import dify_config from core.app.apps.pipeline.pipeline_generator import PipelineGenerator @@ -17,7 +17,7 @@ class PipelineGenerateService: def generate( cls, pipeline: Pipeline, - user: Union[Account, EndUser], + user: Account | EndUser, args: Mapping[str, Any], invoke_from: InvokeFrom, streaming: bool = True, diff --git a/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py b/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py index 24baeb73b5..aa7456dcd3 100644 --- a/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py @@ -1,6 +1,7 @@ import json from os import path from pathlib import Path +from typing import Any from flask import current_app @@ -13,21 +14,21 @@ class BuiltInPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): Retrieval pipeline template from built-in, the location is constants/pipeline_templates.json """ - builtin_data: dict | None = None + builtin_data: dict[str, Any] | None = None def get_type(self) -> str: return PipelineTemplateType.BUILTIN - def get_pipeline_templates(self, language: str) -> dict: + def get_pipeline_templates(self, language: str) -> dict[str, Any]: result = self.fetch_pipeline_templates_from_builtin(language) return result - def get_pipeline_template_detail(self, template_id: str): + def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: result = self.fetch_pipeline_template_detail_from_builtin(template_id) return result @classmethod - def _get_builtin_data(cls) -> dict: + def _get_builtin_data(cls) -> dict[str, Any]: """ Get builtin data. :return: @@ -43,7 +44,7 @@ class BuiltInPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return cls.builtin_data or {} @classmethod - def fetch_pipeline_templates_from_builtin(cls, language: str) -> dict: + def fetch_pipeline_templates_from_builtin(cls, language: str) -> dict[str, Any]: """ Fetch pipeline templates from builtin. :param language: language @@ -53,7 +54,7 @@ class BuiltInPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return builtin_data.get("pipeline_templates", {}).get(language, {}) @classmethod - def fetch_pipeline_template_detail_from_builtin(cls, template_id: str) -> dict | None: + def fetch_pipeline_template_detail_from_builtin(cls, template_id: str) -> dict[str, Any] | None: """ Fetch pipeline template detail from builtin. :param template_id: Template ID diff --git a/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py b/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py index 2ee871a266..0ffbef8365 100644 --- a/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py @@ -1,3 +1,5 @@ +from typing import Any + import yaml from sqlalchemy import select @@ -13,12 +15,12 @@ class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): Retrieval recommended app from database """ - def get_pipeline_templates(self, language: str) -> dict: + def get_pipeline_templates(self, language: str) -> dict[str, Any]: _, current_tenant_id = current_account_with_tenant() result = self.fetch_pipeline_templates_from_customized(tenant_id=current_tenant_id, language=language) return result - def get_pipeline_template_detail(self, template_id: str): + def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: result = self.fetch_pipeline_template_detail_from_db(template_id) return result @@ -26,7 +28,7 @@ class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return PipelineTemplateType.CUSTOMIZED @classmethod - def fetch_pipeline_templates_from_customized(cls, tenant_id: str, language: str) -> dict: + def fetch_pipeline_templates_from_customized(cls, tenant_id: str, language: str) -> dict[str, Any]: """ Fetch pipeline templates from db. :param tenant_id: tenant id @@ -53,7 +55,7 @@ class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return {"pipeline_templates": recommended_pipelines_results} @classmethod - def fetch_pipeline_template_detail_from_db(cls, template_id: str) -> dict | None: + def fetch_pipeline_template_detail_from_db(cls, template_id: str) -> dict[str, Any] | None: """ Fetch pipeline template detail from db. :param template_id: Template ID diff --git a/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py b/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py index 43b21a7b32..073eed221c 100644 --- a/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py @@ -1,3 +1,5 @@ +from typing import Any + import yaml from sqlalchemy import select @@ -12,11 +14,11 @@ class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): Retrieval pipeline template from database """ - def get_pipeline_templates(self, language: str) -> dict: + def get_pipeline_templates(self, language: str) -> dict[str, Any]: result = self.fetch_pipeline_templates_from_db(language) return result - def get_pipeline_template_detail(self, template_id: str): + def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: result = self.fetch_pipeline_template_detail_from_db(template_id) return result @@ -24,7 +26,7 @@ class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return PipelineTemplateType.DATABASE @classmethod - def fetch_pipeline_templates_from_db(cls, language: str) -> dict: + def fetch_pipeline_templates_from_db(cls, language: str) -> dict[str, Any]: """ Fetch pipeline templates from db. :param language: language @@ -54,7 +56,7 @@ class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return {"pipeline_templates": recommended_pipelines_results} @classmethod - def fetch_pipeline_template_detail_from_db(cls, template_id: str) -> dict | None: + def fetch_pipeline_template_detail_from_db(cls, template_id: str) -> dict[str, Any] | None: """ Fetch pipeline template detail from db. :param pipeline_id: Pipeline ID diff --git a/api/services/rag_pipeline/pipeline_template/pipeline_template_base.py b/api/services/rag_pipeline/pipeline_template/pipeline_template_base.py index 21c30a4986..0ed2a4b8f2 100644 --- a/api/services/rag_pipeline/pipeline_template/pipeline_template_base.py +++ b/api/services/rag_pipeline/pipeline_template/pipeline_template_base.py @@ -1,15 +1,16 @@ from abc import ABC, abstractmethod +from typing import Any class PipelineTemplateRetrievalBase(ABC): """Interface for pipeline template retrieval.""" @abstractmethod - def get_pipeline_templates(self, language: str) -> dict: + def get_pipeline_templates(self, language: str) -> dict[str, Any]: raise NotImplementedError @abstractmethod - def get_pipeline_template_detail(self, template_id: str) -> dict | None: + def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: raise NotImplementedError @abstractmethod diff --git a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py index f996db11dc..d5ef745bec 100644 --- a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py @@ -1,4 +1,5 @@ import logging +from typing import Any import httpx @@ -15,8 +16,8 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): Retrieval recommended app from dify official """ - def get_pipeline_template_detail(self, template_id: str) -> dict | None: - result: dict | None + def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: + result: dict[str, Any] | None try: result = self.fetch_pipeline_template_detail_from_dify_official(template_id) except Exception as e: @@ -24,7 +25,7 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): result = DatabasePipelineTemplateRetrieval.fetch_pipeline_template_detail_from_db(template_id) return result - def get_pipeline_templates(self, language: str) -> dict: + def get_pipeline_templates(self, language: str) -> dict[str, Any]: try: result = self.fetch_pipeline_templates_from_dify_official(language) except Exception as e: @@ -36,7 +37,7 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return PipelineTemplateType.REMOTE @classmethod - def fetch_pipeline_template_detail_from_dify_official(cls, template_id: str) -> dict: + def fetch_pipeline_template_detail_from_dify_official(cls, template_id: str) -> dict[str, Any]: """ Fetch pipeline template detail from dify official. @@ -53,11 +54,11 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): + f" status_code: {response.status_code}," + f" response: {response.text[:1000]}" ) - data: dict = response.json() + data: dict[str, Any] = response.json() return data @classmethod - def fetch_pipeline_templates_from_dify_official(cls, language: str) -> dict: + def fetch_pipeline_templates_from_dify_official(cls, language: str) -> dict[str, Any]: """ Fetch pipeline templates from dify official. :param language: language @@ -69,6 +70,6 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): if response.status_code != 200: raise ValueError(f"fetch pipeline templates failed, status code: {response.status_code}") - result: dict = response.json() + result: dict[str, Any] = response.json() return result diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index 5b3668aebb..5fc5b412b3 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -5,7 +5,7 @@ import threading import time from collections.abc import Callable, Generator, Mapping, Sequence from datetime import UTC, datetime -from typing import Any, Union, cast +from typing import Any, cast from uuid import uuid4 from flask_login import current_user @@ -38,11 +38,7 @@ from core.datasource.online_document.online_document_plugin import OnlineDocumen from core.datasource.online_drive.online_drive_plugin import OnlineDriveDatasourcePlugin from core.datasource.website_crawl.website_crawl_plugin import WebsiteCrawlDatasourcePlugin from core.helper import marketplace -from core.rag.entities.event import ( - DatasourceCompletedEvent, - DatasourceErrorEvent, - DatasourceProcessingEvent, -) +from core.rag.entities import DatasourceCompletedEvent, DatasourceErrorEvent, DatasourceProcessingEvent from core.repositories.factory import DifyCoreRepositoryFactory, OrderConfig from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping @@ -559,7 +555,7 @@ class RagPipelineService: workflow_node_execution.id ) - with Session(bind=db.engine) as session, session.begin(): + with sessionmaker(bind=db.engine).begin() as session: draft_var_saver = DraftVariableSaver( session=session, app_id=pipeline.id, @@ -573,7 +569,6 @@ class RagPipelineService: process_data=workflow_node_execution.process_data, outputs=workflow_node_execution.outputs, ) - session.commit() if isinstance(workflow_node_execution_db_model, WorkflowNodeExecutionModel): enqueue_draft_node_execution_trace( execution=workflow_node_execution_db_model, @@ -1186,7 +1181,7 @@ class RagPipelineService: workflow = db.session.get(Workflow, pipeline.workflow_id) if not workflow: raise ValueError("Workflow not found") - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: dataset = pipeline.retrieve_dataset(session=session) if not dataset: raise ValueError("Dataset not found") @@ -1213,7 +1208,7 @@ class RagPipelineService: from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineDslService - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: rag_pipeline_dsl_service = RagPipelineDslService(session) dsl = rag_pipeline_dsl_service.export_rag_pipeline_dsl(pipeline=pipeline, include_secret=True) if args.get("icon_info") is None: @@ -1329,7 +1324,7 @@ class RagPipelineService: # Convert node_execution to WorkflowNodeExecution after save workflow_node_execution_db_model = repository._to_db_model(workflow_node_execution) # type: ignore - with Session(bind=db.engine) as session, session.begin(): + with sessionmaker(bind=db.engine).begin() as session: draft_var_saver = DraftVariableSaver( session=session, app_id=pipeline.id, @@ -1343,7 +1338,6 @@ class RagPipelineService: process_data=workflow_node_execution.process_data, outputs=workflow_node_execution.outputs, ) - session.commit() enqueue_draft_node_execution_trace( execution=workflow_node_execution_db_model, outputs=workflow_node_execution.outputs, @@ -1393,7 +1387,7 @@ class RagPipelineService: "uninstalled_recommended_plugins": uninstalled_plugin_list, } - def retry_error_document(self, dataset: Dataset, document: Document, user: Union[Account, EndUser]): + def retry_error_document(self, dataset: Dataset, document: Document, user: Account | EndUser): """ Retry error document """ diff --git a/api/services/rag_pipeline/rag_pipeline_dsl_service.py b/api/services/rag_pipeline/rag_pipeline_dsl_service.py index 04156713f4..7dd86f1581 100644 --- a/api/services/rag_pipeline/rag_pipeline_dsl_service.py +++ b/api/services/rag_pipeline/rag_pipeline_dsl_service.py @@ -5,8 +5,7 @@ import logging import uuid from collections.abc import Mapping from datetime import UTC, datetime -from enum import StrEnum -from typing import cast +from typing import Any, cast from urllib.parse import urlparse from uuid import uuid4 @@ -21,7 +20,7 @@ from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeDat from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData from graphon.nodes.tool.entities import ToolNodeData from packaging import version -from pydantic import BaseModel, Field +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session @@ -38,6 +37,7 @@ from models import Account from models.dataset import Dataset, DatasetCollectionBinding, Pipeline from models.enums import CollectionBindingType, DatasetRuntimeMode from models.workflow import Workflow, WorkflowType +from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus from services.entities.knowledge_entities.rag_pipeline_entities import ( IconInfo, KnowledgeConfiguration, @@ -54,18 +54,6 @@ DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB CURRENT_DSL_VERSION = "0.1.0" -class ImportMode(StrEnum): - YAML_CONTENT = "yaml-content" - YAML_URL = "yaml-url" - - -class ImportStatus(StrEnum): - COMPLETED = "completed" - COMPLETED_WITH_WARNINGS = "completed-with-warnings" - PENDING = "pending" - FAILED = "failed" - - class RagPipelineImportInfo(BaseModel): id: str status: ImportStatus @@ -76,10 +64,6 @@ class RagPipelineImportInfo(BaseModel): dataset_id: str | None = None -class CheckDependenciesResult(BaseModel): - leaked_dependencies: list[PluginDependency] = Field(default_factory=list) - - def _check_version_compatibility(imported_version: str) -> ImportStatus: """Determine import status based on version comparison""" try: @@ -299,7 +283,9 @@ class RagPipelineDslService: ): raise ValueError("Chunk structure is not compatible with the published pipeline") if not dataset: - datasets = self._session.query(Dataset).filter_by(tenant_id=account.current_tenant_id).all() + datasets = self._session.scalars( + select(Dataset).where(Dataset.tenant_id == account.current_tenant_id) + ).all() names = [dataset.name for dataset in datasets] generate_name = generate_incremental_name(names, name) dataset = Dataset( @@ -319,8 +305,8 @@ class RagPipelineDslService: chunk_structure=knowledge_configuration.chunk_structure, ) if knowledge_configuration.indexing_technique == IndexTechniqueType.HIGH_QUALITY: - dataset_collection_binding = ( - self._session.query(DatasetCollectionBinding) + dataset_collection_binding = self._session.scalar( + select(DatasetCollectionBinding) .where( DatasetCollectionBinding.provider_name == knowledge_configuration.embedding_model_provider, @@ -328,7 +314,7 @@ class RagPipelineDslService: DatasetCollectionBinding.type == CollectionBindingType.DATASET, ) .order_by(DatasetCollectionBinding.created_at) - .first() + .limit(1) ) if not dataset_collection_binding: @@ -456,8 +442,8 @@ class RagPipelineDslService: dataset.runtime_mode = DatasetRuntimeMode.RAG_PIPELINE dataset.chunk_structure = knowledge_configuration.chunk_structure if knowledge_configuration.indexing_technique == IndexTechniqueType.HIGH_QUALITY: - dataset_collection_binding = ( - self._session.query(DatasetCollectionBinding) + dataset_collection_binding = self._session.scalar( + select(DatasetCollectionBinding) .where( DatasetCollectionBinding.provider_name == knowledge_configuration.embedding_model_provider, @@ -465,7 +451,7 @@ class RagPipelineDslService: DatasetCollectionBinding.type == CollectionBindingType.DATASET, ) .order_by(DatasetCollectionBinding.created_at) - .first() + .limit(1) ) if not dataset_collection_binding: @@ -540,7 +526,7 @@ class RagPipelineDslService: self, *, pipeline: Pipeline | None, - data: dict, + data: dict[str, Any], account: Account, dependencies: list[PluginDependency] | None = None, ) -> Pipeline: @@ -607,14 +593,14 @@ class RagPipelineDslService: IMPORT_INFO_REDIS_EXPIRY, CheckDependenciesPendingData(pipeline_id=pipeline.id, dependencies=dependencies).model_dump_json(), ) - workflow = ( - self._session.query(Workflow) + workflow = self._session.scalar( + select(Workflow) .where( Workflow.tenant_id == pipeline.tenant_id, Workflow.app_id == pipeline.id, Workflow.version == "draft", ) - .first() + .limit(1) ) # create draft workflow if not found @@ -674,21 +660,21 @@ class RagPipelineDslService: return yaml.dump(export_data, allow_unicode=True) # type: ignore - def _append_workflow_export_data(self, *, export_data: dict, pipeline: Pipeline, include_secret: bool) -> None: + def _append_workflow_export_data( + self, *, export_data: dict[str, Any], pipeline: Pipeline, include_secret: bool + ) -> None: """ Append workflow export data :param export_data: export data :param pipeline: Pipeline instance """ - workflow = ( - self._session.query(Workflow) - .where( + workflow = self._session.scalar( + select(Workflow).where( Workflow.tenant_id == pipeline.tenant_id, Workflow.app_id == pipeline.id, Workflow.version == "draft", ) - .first() ) if not workflow: raise ValueError("Missing draft workflow configuration, please check.") @@ -920,15 +906,16 @@ class RagPipelineDslService: ): if rag_pipeline_dataset_create_entity.name: # check if dataset name already exists - if ( - self._session.query(Dataset) - .filter_by(name=rag_pipeline_dataset_create_entity.name, tenant_id=tenant_id) - .first() + if self._session.scalar( + select(Dataset).where( + Dataset.name == rag_pipeline_dataset_create_entity.name, + Dataset.tenant_id == tenant_id, + ) ): raise ValueError(f"Dataset with name {rag_pipeline_dataset_create_entity.name} already exists.") else: # generate a random name as Untitled 1 2 3 ... - datasets = self._session.query(Dataset).filter_by(tenant_id=tenant_id).all() + datasets = self._session.scalars(select(Dataset).where(Dataset.tenant_id == tenant_id)).all() names = [dataset.name for dataset in datasets] rag_pipeline_dataset_create_entity.name = generate_incremental_name( names, diff --git a/api/services/rag_pipeline/rag_pipeline_transform_service.py b/api/services/rag_pipeline/rag_pipeline_transform_service.py index c3b00fe109..f08ec7474b 100644 --- a/api/services/rag_pipeline/rag_pipeline_transform_service.py +++ b/api/services/rag_pipeline/rag_pipeline_transform_service.py @@ -2,6 +2,7 @@ import json import logging from datetime import UTC, datetime from pathlib import Path +from typing import Any from uuid import uuid4 import yaml @@ -154,7 +155,7 @@ class RagPipelineTransformService: raise ValueError("Unsupported doc form") return pipeline_yaml - def _deal_file_extensions(self, node: dict): + def _deal_file_extensions(self, node: dict[str, Any]): file_extensions = node.get("data", {}).get("fileExtensions", []) if not file_extensions: return node @@ -167,7 +168,7 @@ class RagPipelineTransformService: dataset: Dataset, indexing_technique: str | None, retrieval_model: RetrievalSetting | None, - node: dict, + node: dict[str, Any], ): knowledge_configuration_dict = node.get("data", {}) @@ -191,7 +192,7 @@ class RagPipelineTransformService: def _create_pipeline( self, - data: dict, + data: dict[str, Any], ) -> Pipeline: """Create a new app or update an existing one.""" pipeline_data = data.get("rag_pipeline", {}) @@ -258,7 +259,7 @@ class RagPipelineTransformService: db.session.add(pipeline) return pipeline - def _deal_dependencies(self, pipeline_yaml: dict, tenant_id: str): + def _deal_dependencies(self, pipeline_yaml: dict[str, Any], tenant_id: str): installer_manager = PluginInstaller() installed_plugins = installer_manager.list_plugins(tenant_id) diff --git a/api/services/recommend_app/buildin/buildin_retrieval.py b/api/services/recommend_app/buildin/buildin_retrieval.py index 64751d186c..16dc66cd76 100644 --- a/api/services/recommend_app/buildin/buildin_retrieval.py +++ b/api/services/recommend_app/buildin/buildin_retrieval.py @@ -1,6 +1,7 @@ import json from os import path from pathlib import Path +from typing import Any from flask import current_app @@ -13,7 +14,7 @@ class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase): Retrieval recommended app from buildin, the location is constants/recommended_apps.json """ - builtin_data: dict | None = None + builtin_data: dict[str, Any] | None = None def get_type(self) -> str: return RecommendAppType.BUILDIN @@ -53,7 +54,7 @@ class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase): return builtin_data.get("recommended_apps", {}).get(language, {}) @classmethod - def fetch_recommended_app_detail_from_builtin(cls, app_id: str) -> dict | None: + def fetch_recommended_app_detail_from_builtin(cls, app_id: str) -> dict[str, Any] | None: """ Fetch recommended app detail from builtin. :param app_id: App ID diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index 6fb90d356d..1df5fd13b6 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -1,3 +1,5 @@ +from typing import Any, TypedDict + from sqlalchemy import select from constants.languages import languages @@ -8,16 +10,43 @@ from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType +class RecommendedAppItemDict(TypedDict): + id: str + app: App | None + app_id: str + description: Any + copyright: Any + privacy_policy: Any + custom_disclaimer: str + category: str + position: int + is_listed: bool + + +class RecommendedAppsResultDict(TypedDict): + recommended_apps: list[RecommendedAppItemDict] + categories: list[str] + + +class RecommendedAppDetailDict(TypedDict): + id: str + name: str + icon: Any + icon_background: str | None + mode: str + export_data: str + + class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): """ Retrieval recommended app from database """ - def get_recommended_apps_and_categories(self, language: str): + def get_recommended_apps_and_categories(self, language: str) -> RecommendedAppsResultDict: result = self.fetch_recommended_apps_from_db(language) return result - def get_recommend_app_detail(self, app_id: str): + def get_recommend_app_detail(self, app_id: str) -> RecommendedAppDetailDict | None: result = self.fetch_recommended_app_detail_from_db(app_id) return result @@ -25,7 +54,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): return RecommendAppType.DATABASE @classmethod - def fetch_recommended_apps_from_db(cls, language: str): + def fetch_recommended_apps_from_db(cls, language: str) -> RecommendedAppsResultDict: """ Fetch recommended apps from db. :param language: language @@ -41,7 +70,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): ).all() categories = set() - recommended_apps_result = [] + recommended_apps_result: list[RecommendedAppItemDict] = [] for recommended_app in recommended_apps: app = recommended_app.app if not app or not app.is_public: @@ -51,7 +80,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): if not site: continue - recommended_app_result = { + recommended_app_result: RecommendedAppItemDict = { "id": recommended_app.id, "app": recommended_app.app, "app_id": recommended_app.app_id, @@ -67,10 +96,10 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): categories.add(recommended_app.category) - return {"recommended_apps": recommended_apps_result, "categories": sorted(categories)} + return RecommendedAppsResultDict(recommended_apps=recommended_apps_result, categories=sorted(categories)) @classmethod - def fetch_recommended_app_detail_from_db(cls, app_id: str) -> dict | None: + def fetch_recommended_app_detail_from_db(cls, app_id: str) -> RecommendedAppDetailDict | None: """ Fetch recommended app detail from db. :param app_id: App ID @@ -89,11 +118,11 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): if not app_model or not app_model.is_public: return None - return { - "id": app_model.id, - "name": app_model.name, - "icon": app_model.icon, - "icon_background": app_model.icon_background, - "mode": app_model.mode, - "export_data": AppDslService.export_dsl(app_model=app_model), - } + return RecommendedAppDetailDict( + id=app_model.id, + name=app_model.name, + icon=app_model.icon, + icon_background=app_model.icon_background, + mode=app_model.mode, + export_data=AppDslService.export_dsl(app_model=app_model), + ) diff --git a/api/services/recommend_app/remote/remote_retrieval.py b/api/services/recommend_app/remote/remote_retrieval.py index b217c9026a..5818be0480 100644 --- a/api/services/recommend_app/remote/remote_retrieval.py +++ b/api/services/recommend_app/remote/remote_retrieval.py @@ -1,4 +1,5 @@ import logging +from typing import Any import httpx @@ -35,7 +36,7 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): return RecommendAppType.REMOTE @classmethod - def fetch_recommended_app_detail_from_dify_official(cls, app_id: str) -> dict | None: + def fetch_recommended_app_detail_from_dify_official(cls, app_id: str) -> dict[str, Any] | None: """ Fetch recommended app detail from dify official. :param app_id: App ID @@ -46,7 +47,7 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0)) if response.status_code != 200: return None - data: dict = response.json() + data: dict[str, Any] = response.json() return data @classmethod @@ -62,7 +63,7 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): if response.status_code != 200: raise ValueError(f"fetch recommended apps failed, status code: {response.status_code}") - result: dict = response.json() + result: dict[str, Any] = response.json() if "categories" in result: result["categories"] = sorted(result["categories"]) diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index 9819822103..134dd37a3e 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -1,3 +1,5 @@ +from typing import Any + from sqlalchemy import select from configs import dify_config @@ -37,7 +39,7 @@ class RecommendedAppService: return result @classmethod - def get_recommend_app_detail(cls, app_id: str) -> dict | None: + def get_recommend_app_detail(cls, app_id: str) -> dict[str, Any] | None: """ Get recommend app detail. :param app_id: app id @@ -45,7 +47,7 @@ class RecommendedAppService: """ mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)() - result: dict = retrieval_instance.get_recommend_app_detail(app_id) + result: dict[str, Any] = retrieval_instance.get_recommend_app_detail(app_id) if FeatureService.get_system_features().enable_trial_app: app_id = result["id"] trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) diff --git a/api/services/retention/conversation/messages_clean_service.py b/api/services/retention/conversation/messages_clean_service.py index 0e0dbab2d1..1e9f0bf149 100644 --- a/api/services/retention/conversation/messages_clean_service.py +++ b/api/services/retention/conversation/messages_clean_service.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, TypedDict, cast import sqlalchemy as sa from sqlalchemy import delete, select, tuple_ from sqlalchemy.engine import CursorResult -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from extensions.ext_database import db @@ -369,7 +369,7 @@ class MessagesCleanService: batch_deleted_messages = 0 # Step 1: Fetch a batch of messages using cursor - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: fetch_messages_start = time.monotonic() msg_stmt = ( select(Message.id, Message.app_id, Message.created_at) @@ -477,7 +477,7 @@ class MessagesCleanService: # Step 4: Batch delete messages and their relations if not self._dry_run: - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: delete_relations_start = time.monotonic() # Delete related records first self._batch_delete_message_relations(session, message_ids_to_delete) @@ -489,9 +489,7 @@ class MessagesCleanService: delete_result = cast(CursorResult, session.execute(delete_stmt)) messages_deleted = delete_result.rowcount delete_messages_ms = int((time.monotonic() - delete_messages_start) * 1000) - commit_start = time.monotonic() - session.commit() - commit_ms = int((time.monotonic() - commit_start) * 1000) + commit_ms = 0 stats["total_deleted"] += messages_deleted batch_deleted_messages = messages_deleted diff --git a/api/services/summary_index_service.py b/api/services/summary_index_service.py index 8760d60de0..c906e3bca3 100644 --- a/api/services/summary_index_service.py +++ b/api/services/summary_index_service.py @@ -8,6 +8,7 @@ from typing import TypedDict, cast from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.model_runtime.entities.model_entities import ModelType +from sqlalchemy import select from sqlalchemy.orm import Session from core.db.session_factory import session_factory @@ -109,8 +110,13 @@ class SummaryIndexService: """ with session_factory.create_session() as session: # Check if summary record already exists - existing_summary = ( - session.query(DocumentSegmentSummary).filter_by(chunk_id=segment.id, dataset_id=dataset.id).first() + existing_summary = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if existing_summary: @@ -309,8 +315,10 @@ class SummaryIndexService: summary_record_id, segment.id, ) - summary_record_in_session = ( - session.query(DocumentSegmentSummary).filter_by(id=summary_record_id).first() + summary_record_in_session = session.scalar( + select(DocumentSegmentSummary) + .where(DocumentSegmentSummary.id == summary_record_id) + .limit(1) ) if not summary_record_in_session: @@ -323,10 +331,13 @@ class SummaryIndexService: dataset.id, segment.id, ) - summary_record_in_session = ( - session.query(DocumentSegmentSummary) - .filter_by(chunk_id=segment.id, dataset_id=dataset.id) - .first() + summary_record_in_session = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if not summary_record_in_session: @@ -487,8 +498,10 @@ class SummaryIndexService: with session_factory.create_session() as error_session: # Try to find the record by id first # Note: Using assignment only (no type annotation) to avoid redeclaration error - summary_record_in_session = ( - error_session.query(DocumentSegmentSummary).filter_by(id=summary_record_id).first() + summary_record_in_session = error_session.scalar( + select(DocumentSegmentSummary) + .where(DocumentSegmentSummary.id == summary_record_id) + .limit(1) ) if not summary_record_in_session: # Try to find by chunk_id and dataset_id @@ -500,10 +513,13 @@ class SummaryIndexService: dataset.id, segment.id, ) - summary_record_in_session = ( - error_session.query(DocumentSegmentSummary) - .filter_by(chunk_id=segment.id, dataset_id=dataset.id) - .first() + summary_record_in_session = error_session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if summary_record_in_session: @@ -551,14 +567,12 @@ class SummaryIndexService: with session_factory.create_session() as session: # Query existing summary records - existing_summaries = ( - session.query(DocumentSegmentSummary) - .filter( + existing_summaries = session.scalars( + select(DocumentSegmentSummary).where( DocumentSegmentSummary.chunk_id.in_(segment_ids), DocumentSegmentSummary.dataset_id == dataset.id, ) - .all() - ) + ).all() existing_summary_map = {summary.chunk_id: summary for summary in existing_summaries} # Create or update records @@ -603,8 +617,13 @@ class SummaryIndexService: error: Error message """ with session_factory.create_session() as session: - summary_record = ( - session.query(DocumentSegmentSummary).filter_by(chunk_id=segment.id, dataset_id=dataset.id).first() + summary_record = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if summary_record: @@ -639,8 +658,13 @@ class SummaryIndexService: with session_factory.create_session() as session: try: # Get or refresh summary record in this session - summary_record_in_session = ( - session.query(DocumentSegmentSummary).filter_by(chunk_id=segment.id, dataset_id=dataset.id).first() + summary_record_in_session = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if not summary_record_in_session: @@ -710,8 +734,13 @@ class SummaryIndexService: except Exception as e: logger.exception("Failed to generate summary for segment %s", segment.id) # Update summary record with error status - summary_record_in_session = ( - session.query(DocumentSegmentSummary).filter_by(chunk_id=segment.id, dataset_id=dataset.id).first() + summary_record_in_session = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if summary_record_in_session: summary_record_in_session.status = SummaryStatus.ERROR @@ -769,17 +798,17 @@ class SummaryIndexService: with session_factory.create_session() as session: # Query segments (only enabled segments) - query = session.query(DocumentSegment).filter_by( - dataset_id=dataset.id, - document_id=document.id, - status="completed", - enabled=True, # Only generate summaries for enabled segments + stmt = select(DocumentSegment).where( + DocumentSegment.dataset_id == dataset.id, + DocumentSegment.document_id == document.id, + DocumentSegment.status == "completed", + DocumentSegment.enabled.is_(True), # Only generate summaries for enabled segments ) if segment_ids: - query = query.filter(DocumentSegment.id.in_(segment_ids)) + stmt = stmt.where(DocumentSegment.id.in_(segment_ids)) - segments = query.all() + segments = list(session.scalars(stmt).all()) if not segments: logger.info("No segments found for document %s", document.id) @@ -848,15 +877,15 @@ class SummaryIndexService: from libs.datetime_utils import naive_utc_now with session_factory.create_session() as session: - query = session.query(DocumentSegmentSummary).filter_by( - dataset_id=dataset.id, - enabled=True, # Only disable enabled summaries + stmt = select(DocumentSegmentSummary).where( + DocumentSegmentSummary.dataset_id == dataset.id, + DocumentSegmentSummary.enabled.is_(True), # Only disable enabled summaries ) if segment_ids: - query = query.filter(DocumentSegmentSummary.chunk_id.in_(segment_ids)) + stmt = stmt.where(DocumentSegmentSummary.chunk_id.in_(segment_ids)) - summaries = query.all() + summaries = session.scalars(stmt).all() if not summaries: return @@ -911,15 +940,15 @@ class SummaryIndexService: return with session_factory.create_session() as session: - query = session.query(DocumentSegmentSummary).filter_by( - dataset_id=dataset.id, - enabled=False, # Only enable disabled summaries + stmt = select(DocumentSegmentSummary).where( + DocumentSegmentSummary.dataset_id == dataset.id, + DocumentSegmentSummary.enabled.is_(False), # Only enable disabled summaries ) if segment_ids: - query = query.filter(DocumentSegmentSummary.chunk_id.in_(segment_ids)) + stmt = stmt.where(DocumentSegmentSummary.chunk_id.in_(segment_ids)) - summaries = query.all() + summaries = session.scalars(stmt).all() if not summaries: return @@ -935,13 +964,13 @@ class SummaryIndexService: enabled_count = 0 for summary in summaries: # Get the original segment - segment = ( - session.query(DocumentSegment) - .filter_by( - id=summary.chunk_id, - dataset_id=dataset.id, + segment = session.scalar( + select(DocumentSegment) + .where( + DocumentSegment.id == summary.chunk_id, + DocumentSegment.dataset_id == dataset.id, ) - .first() + .limit(1) ) # Summary.enabled stays in sync with chunk.enabled, @@ -988,12 +1017,12 @@ class SummaryIndexService: segment_ids: List of segment IDs to delete summaries for. If None, delete all. """ with session_factory.create_session() as session: - query = session.query(DocumentSegmentSummary).filter_by(dataset_id=dataset.id) + stmt = select(DocumentSegmentSummary).where(DocumentSegmentSummary.dataset_id == dataset.id) if segment_ids: - query = query.filter(DocumentSegmentSummary.chunk_id.in_(segment_ids)) + stmt = stmt.where(DocumentSegmentSummary.chunk_id.in_(segment_ids)) - summaries = query.all() + summaries = session.scalars(stmt).all() if not summaries: return @@ -1046,10 +1075,13 @@ class SummaryIndexService: # Check if summary_content is empty (whitespace-only strings are considered empty) if not summary_content or not summary_content.strip(): # If summary is empty, only delete existing summary vector and record - summary_record = ( - session.query(DocumentSegmentSummary) - .filter_by(chunk_id=segment.id, dataset_id=dataset.id) - .first() + summary_record = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if summary_record: @@ -1077,8 +1109,13 @@ class SummaryIndexService: return None # Find existing summary record - summary_record = ( - session.query(DocumentSegmentSummary).filter_by(chunk_id=segment.id, dataset_id=dataset.id).first() + summary_record = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if summary_record: @@ -1162,8 +1199,13 @@ class SummaryIndexService: except Exception as e: logger.exception("Failed to update summary for segment %s", segment.id) # Update summary record with error status if it exists - summary_record = ( - session.query(DocumentSegmentSummary).filter_by(chunk_id=segment.id, dataset_id=dataset.id).first() + summary_record = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset.id, + ) + .limit(1) ) if summary_record: summary_record.status = SummaryStatus.ERROR @@ -1185,14 +1227,14 @@ class SummaryIndexService: DocumentSegmentSummary instance if found, None otherwise """ with session_factory.create_session() as session: - return ( - session.query(DocumentSegmentSummary) + return session.scalar( + select(DocumentSegmentSummary) .where( DocumentSegmentSummary.chunk_id == segment_id, DocumentSegmentSummary.dataset_id == dataset_id, - DocumentSegmentSummary.enabled == True, # Only return enabled summaries + DocumentSegmentSummary.enabled.is_(True), # Only return enabled summaries ) - .first() + .limit(1) ) @staticmethod @@ -1211,15 +1253,13 @@ class SummaryIndexService: return {} with session_factory.create_session() as session: - summary_records = ( - session.query(DocumentSegmentSummary) - .where( + summary_records = session.scalars( + select(DocumentSegmentSummary).where( DocumentSegmentSummary.chunk_id.in_(segment_ids), DocumentSegmentSummary.dataset_id == dataset_id, - DocumentSegmentSummary.enabled == True, # Only return enabled summaries + DocumentSegmentSummary.enabled.is_(True), # Only return enabled summaries ) - .all() - ) + ).all() return {summary.chunk_id: summary for summary in summary_records} @@ -1239,16 +1279,16 @@ class SummaryIndexService: List of DocumentSegmentSummary instances (only enabled summaries) """ with session_factory.create_session() as session: - query = session.query(DocumentSegmentSummary).filter( + stmt = select(DocumentSegmentSummary).where( DocumentSegmentSummary.document_id == document_id, DocumentSegmentSummary.dataset_id == dataset_id, - DocumentSegmentSummary.enabled == True, # Only return enabled summaries + DocumentSegmentSummary.enabled.is_(True), # Only return enabled summaries ) if segment_ids: - query = query.filter(DocumentSegmentSummary.chunk_id.in_(segment_ids)) + stmt = stmt.where(DocumentSegmentSummary.chunk_id.in_(segment_ids)) - return query.all() + return list(session.scalars(stmt).all()) @staticmethod def get_document_summary_index_status(document_id: str, dataset_id: str, tenant_id: str) -> str | None: @@ -1265,16 +1305,15 @@ class SummaryIndexService: """ # Get all segments for this document (excluding qa_model and re_segment) with session_factory.create_session() as session: - segments = ( - session.query(DocumentSegment.id) - .where( - DocumentSegment.document_id == document_id, - DocumentSegment.status != "re_segment", - DocumentSegment.tenant_id == tenant_id, - ) - .all() + segment_ids = list( + session.scalars( + select(DocumentSegment.id).where( + DocumentSegment.document_id == document_id, + DocumentSegment.status != "re_segment", + DocumentSegment.tenant_id == tenant_id, + ) + ).all() ) - segment_ids = [seg.id for seg in segments] if not segment_ids: return None @@ -1312,15 +1351,13 @@ class SummaryIndexService: # Get all segments for these documents (excluding qa_model and re_segment) with session_factory.create_session() as session: - segments = ( - session.query(DocumentSegment.id, DocumentSegment.document_id) - .where( + segments = session.execute( + select(DocumentSegment.id, DocumentSegment.document_id).where( DocumentSegment.document_id.in_(document_ids), DocumentSegment.status != "re_segment", DocumentSegment.tenant_id == tenant_id, ) - .all() - ) + ).all() # Group segments by document_id document_segments_map: dict[str, list[str]] = {} diff --git a/api/services/tools/api_tools_manage_service.py b/api/services/tools/api_tools_manage_service.py index dfc0c2c63f..3bfa221528 100644 --- a/api/services/tools/api_tools_manage_service.py +++ b/api/services/tools/api_tools_manage_service.py @@ -92,7 +92,7 @@ class ApiToolManageService: @staticmethod def convert_schema_to_tool_bundles( - schema: str, extra_info: dict | None = None + schema: str, extra_info: dict[str, Any] | None = None ) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]: """ convert schema to tool bundles @@ -109,8 +109,8 @@ class ApiToolManageService: user_id: str, tenant_id: str, provider_name: str, - icon: dict, - credentials: dict, + icon: dict[str, Any], + credentials: dict[str, Any], schema_type: ApiProviderSchemaType, schema: str, privacy_policy: str, @@ -244,8 +244,8 @@ class ApiToolManageService: tenant_id: str, provider_name: str, original_provider: str, - icon: dict, - credentials: dict, + icon: dict[str, Any], + credentials: dict[str, Any], _schema_type: ApiProviderSchemaType, schema: str, privacy_policy: str | None, @@ -356,8 +356,8 @@ class ApiToolManageService: tenant_id: str, provider_name: str, tool_name: str, - credentials: dict, - parameters: dict, + credentials: dict[str, Any], + parameters: dict[str, Any], schema_type: ApiProviderSchemaType, schema: str, ): diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index d529d2f065..7bd056b8a0 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -4,8 +4,8 @@ from collections.abc import Mapping from pathlib import Path from typing import Any -from sqlalchemy import exists, select -from sqlalchemy.orm import Session +from sqlalchemy import delete, exists, func, select, update +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from constants import HIDDEN_VALUE, UNKNOWN_VALUE @@ -46,13 +46,16 @@ class BuiltinToolManageService: delete custom oauth client params """ tool_provider = ToolProviderID(provider) - with Session(db.engine) as session: - session.query(ToolOAuthTenantClient).filter_by( - tenant_id=tenant_id, - provider=tool_provider.provider_name, - plugin_id=tool_provider.plugin_id, - ).delete() - session.commit() + with sessionmaker(bind=db.engine).begin() as session: + session.execute( + delete(ToolOAuthTenantClient) + .where( + ToolOAuthTenantClient.tenant_id == tenant_id, + ToolOAuthTenantClient.provider == tool_provider.provider_name, + ToolOAuthTenantClient.plugin_id == tool_provider.plugin_id, + ) + .execution_options(synchronize_session=False) + ) return {"result": "success"} @staticmethod @@ -144,21 +147,21 @@ class BuiltinToolManageService: tenant_id: str, provider: str, credential_id: str, - credentials: dict | None = None, + credentials: dict[str, Any] | None = None, name: str | None = None, ): """ update builtin tool provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # get if the provider exists - db_provider = ( - session.query(BuiltinToolProvider) + db_provider = session.scalar( + select(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.id == credential_id, ) - .first() + .limit(1) ) if db_provider is None: raise ValueError(f"you have not added provider {provider}") @@ -174,7 +177,7 @@ class BuiltinToolManageService: ) original_credentials = encrypter.decrypt(db_provider.credentials) - new_credentials: dict = { + new_credentials: dict[str, Any] = { key: value if value != HIDDEN_VALUE else original_credentials.get(key, UNKNOWN_VALUE) for key, value in credentials.items() } @@ -203,9 +206,7 @@ class BuiltinToolManageService: db_provider.name = name - session.commit() except Exception as e: - session.rollback() raise ValueError(str(e)) return {"result": "success"} @@ -215,14 +216,14 @@ class BuiltinToolManageService: api_type: CredentialType, tenant_id: str, provider: str, - credentials: dict, + credentials: dict[str, Any], expires_at: int = -1, name: str | None = None, ): """ add builtin tool provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: try: lock = f"builtin_tool_provider_create_lock:{tenant_id}_{provider}" with redis_client.lock(lock, timeout=20): @@ -231,7 +232,13 @@ class BuiltinToolManageService: raise ValueError(f"provider {provider} does not need credentials") provider_count = ( - session.query(BuiltinToolProvider).filter_by(tenant_id=tenant_id, provider=provider).count() + session.scalar( + select(func.count(BuiltinToolProvider.id)).where( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.provider == provider, + ) + ) + or 0 ) # check if the provider count is reached the limit @@ -281,9 +288,7 @@ class BuiltinToolManageService: ) session.add(db_provider) - session.commit() except Exception as e: - session.rollback() raise ValueError(str(e)) return {"result": "success"} @@ -309,16 +314,15 @@ class BuiltinToolManageService: def generate_builtin_tool_provider_name( session: Session, tenant_id: str, provider: str, credential_type: CredentialType ) -> str: - db_providers = ( - session.query(BuiltinToolProvider) - .filter_by( - tenant_id=tenant_id, - provider=provider, - credential_type=credential_type, + db_providers = session.scalars( + select(BuiltinToolProvider) + .where( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.provider == provider, + BuiltinToolProvider.credential_type == credential_type, ) .order_by(BuiltinToolProvider.created_at.desc()) - .all() - ) + ).all() return generate_incremental_name( [provider.name for provider in db_providers], f"{credential_type.get_name()}", @@ -379,21 +383,20 @@ class BuiltinToolManageService: """ delete tool provider """ - with Session(db.engine) as session: - db_provider = ( - session.query(BuiltinToolProvider) + with sessionmaker(bind=db.engine).begin() as session: + db_provider = session.scalar( + select(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.id == credential_id, ) - .first() + .limit(1) ) if db_provider is None: raise ValueError(f"you have not added provider {provider}") session.delete(db_provider) - session.commit() # delete cache provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) @@ -409,20 +412,31 @@ class BuiltinToolManageService: """ set default provider """ - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # get provider - target_provider = session.query(BuiltinToolProvider).filter_by(id=id, tenant_id=tenant_id).first() + target_provider = session.scalar( + select(BuiltinToolProvider) + .where(BuiltinToolProvider.id == id, BuiltinToolProvider.tenant_id == tenant_id) + .limit(1) + ) if target_provider is None: raise ValueError("provider not found") # clear default provider - session.query(BuiltinToolProvider).filter_by( - tenant_id=tenant_id, user_id=user_id, provider=provider, is_default=True - ).update({"is_default": False}) + session.execute( + update(BuiltinToolProvider) + .where( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.user_id == user_id, + BuiltinToolProvider.provider == provider, + BuiltinToolProvider.is_default.is_(True), + ) + .values(is_default=False) + .execution_options(synchronize_session=False) + ) # set new default provider target_provider.is_default = True - session.commit() return {"result": "success"} @@ -433,10 +447,13 @@ class BuiltinToolManageService: """ tool_provider = ToolProviderID(provider_name) with Session(db.engine, autoflush=False) as session: - system_client: ToolOAuthSystemClient | None = ( - session.query(ToolOAuthSystemClient) - .filter_by(plugin_id=tool_provider.plugin_id, provider=tool_provider.provider_name) - .first() + system_client = session.scalar( + select(ToolOAuthSystemClient) + .where( + ToolOAuthSystemClient.plugin_id == tool_provider.plugin_id, + ToolOAuthSystemClient.provider == tool_provider.provider_name, + ) + .limit(1) ) return system_client is not None @@ -447,15 +464,15 @@ class BuiltinToolManageService: """ tool_provider = ToolProviderID(provider) with Session(db.engine, autoflush=False) as session: - user_client: ToolOAuthTenantClient | None = ( - session.query(ToolOAuthTenantClient) - .filter_by( - tenant_id=tenant_id, - provider=tool_provider.provider_name, - plugin_id=tool_provider.plugin_id, - enabled=True, + user_client = session.scalar( + select(ToolOAuthTenantClient) + .where( + ToolOAuthTenantClient.tenant_id == tenant_id, + ToolOAuthTenantClient.provider == tool_provider.provider_name, + ToolOAuthTenantClient.plugin_id == tool_provider.plugin_id, + ToolOAuthTenantClient.enabled.is_(True), ) - .first() + .limit(1) ) return user_client is not None and user_client.enabled @@ -472,15 +489,15 @@ class BuiltinToolManageService: cache=NoOpProviderCredentialCache(), ) with Session(db.engine, autoflush=False) as session: - user_client: ToolOAuthTenantClient | None = ( - session.query(ToolOAuthTenantClient) - .filter_by( - tenant_id=tenant_id, - provider=tool_provider.provider_name, - plugin_id=tool_provider.plugin_id, - enabled=True, + user_client = session.scalar( + select(ToolOAuthTenantClient) + .where( + ToolOAuthTenantClient.tenant_id == tenant_id, + ToolOAuthTenantClient.provider == tool_provider.provider_name, + ToolOAuthTenantClient.plugin_id == tool_provider.plugin_id, + ToolOAuthTenantClient.enabled.is_(True), ) - .first() + .limit(1) ) oauth_params: Mapping[str, Any] | None = None if user_client: @@ -494,10 +511,13 @@ class BuiltinToolManageService: if not is_verified: return oauth_params - system_client: ToolOAuthSystemClient | None = ( - session.query(ToolOAuthSystemClient) - .filter_by(plugin_id=tool_provider.plugin_id, provider=tool_provider.provider_name) - .first() + system_client = session.scalar( + select(ToolOAuthSystemClient) + .where( + ToolOAuthSystemClient.plugin_id == tool_provider.plugin_id, + ToolOAuthSystemClient.provider == tool_provider.provider_name, + ) + .limit(1) ) if system_client: try: @@ -589,8 +609,8 @@ class BuiltinToolManageService: provider_name = provider_id_entity.provider_name if provider_id_entity.organization != "langgenius": - provider = ( - session.query(BuiltinToolProvider) + provider = session.scalar( + select(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.provider == full_provider_name, @@ -599,11 +619,11 @@ class BuiltinToolManageService: BuiltinToolProvider.is_default.desc(), # default=True first BuiltinToolProvider.created_at.asc(), # oldest first ) - .first() + .limit(1) ) else: - provider = ( - session.query(BuiltinToolProvider) + provider = session.scalar( + select(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, (BuiltinToolProvider.provider == provider_name) @@ -613,7 +633,7 @@ class BuiltinToolManageService: BuiltinToolProvider.is_default.desc(), # default=True first BuiltinToolProvider.created_at.asc(), # oldest first ) - .first() + .limit(1) ) if provider is None: @@ -623,21 +643,21 @@ class BuiltinToolManageService: return provider except Exception: # it's an old provider without organization - return ( - session.query(BuiltinToolProvider) + return session.scalar( + select(BuiltinToolProvider) .where(BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.provider == provider_name) .order_by( BuiltinToolProvider.is_default.desc(), # default=True first BuiltinToolProvider.created_at.asc(), # oldest first ) - .first() + .limit(1) ) @staticmethod def save_custom_oauth_client_params( tenant_id: str, provider: str, - client_params: dict | None = None, + client_params: dict[str, Any] | None = None, enable_oauth_custom_client: bool | None = None, ): """ @@ -654,15 +674,15 @@ class BuiltinToolManageService: if not isinstance(provider_controller, (BuiltinToolProviderController, PluginToolProviderController)): raise ValueError(f"Provider {provider} is not a builtin or plugin provider") - with Session(db.engine) as session: - custom_client_params = ( - session.query(ToolOAuthTenantClient) - .filter_by( - tenant_id=tenant_id, - plugin_id=tool_provider.plugin_id, - provider=tool_provider.provider_name, + with sessionmaker(bind=db.engine).begin() as session: + custom_client_params = session.scalar( + select(ToolOAuthTenantClient) + .where( + ToolOAuthTenantClient.tenant_id == tenant_id, + ToolOAuthTenantClient.plugin_id == tool_provider.plugin_id, + ToolOAuthTenantClient.provider == tool_provider.provider_name, ) - .first() + .limit(1) ) # if the record does not exist, create a basic record @@ -690,7 +710,6 @@ class BuiltinToolManageService: if enable_oauth_custom_client is not None: custom_client_params.enabled = enable_oauth_custom_client - session.commit() return {"result": "success"} @staticmethod @@ -700,14 +719,14 @@ class BuiltinToolManageService: """ with Session(db.engine) as session: tool_provider = ToolProviderID(provider) - custom_oauth_client_params: ToolOAuthTenantClient | None = ( - session.query(ToolOAuthTenantClient) - .filter_by( - tenant_id=tenant_id, - plugin_id=tool_provider.plugin_id, - provider=tool_provider.provider_name, + custom_oauth_client_params = session.scalar( + select(ToolOAuthTenantClient) + .where( + ToolOAuthTenantClient.tenant_id == tenant_id, + ToolOAuthTenantClient.plugin_id == tool_provider.plugin_id, + ToolOAuthTenantClient.provider == tool_provider.provider_name, ) - .first() + .limit(1) ) if custom_oauth_client_params is None: return {} diff --git a/api/services/tools/mcp_tools_manage_service.py b/api/services/tools/mcp_tools_manage_service.py index 690b06ea7d..89762d6772 100644 --- a/api/services/tools/mcp_tools_manage_service.py +++ b/api/services/tools/mcp_tools_manage_service.py @@ -17,6 +17,7 @@ from core.helper import encrypter from core.helper.provider_cache import NoOpProviderCredentialCache from core.mcp.auth.auth_flow import auth from core.mcp.auth_client import MCPClientWithAuthRetry +from core.mcp.entities import AuthActionType, AuthResult from core.mcp.error import MCPAuthError, MCPError from core.mcp.types import Tool as MCPTool from core.tools.entities.api_entities import ToolProviderApiEntity @@ -496,7 +497,13 @@ class MCPToolManageService: ) as mcp_client: return mcp_client.list_tools() - def execute_auth_actions(self, auth_result: Any) -> dict[str, str]: + _ACTION_TO_OAUTH: dict[AuthActionType, OAuthDataType] = { + AuthActionType.SAVE_CLIENT_INFO: OAuthDataType.CLIENT_INFO, + AuthActionType.SAVE_TOKENS: OAuthDataType.TOKENS, + AuthActionType.SAVE_CODE_VERIFIER: OAuthDataType.CODE_VERIFIER, + } + + def execute_auth_actions(self, auth_result: AuthResult) -> dict[str, str]: """ Execute the actions returned by the auth function. @@ -508,19 +515,13 @@ class MCPToolManageService: Returns: The response from the auth result """ - from core.mcp.entities import AuthAction, AuthActionType - - action: AuthAction for action in auth_result.actions: if action.provider_id is None or action.tenant_id is None: continue - if action.action_type == AuthActionType.SAVE_CLIENT_INFO: - self.save_oauth_data(action.provider_id, action.tenant_id, action.data, OAuthDataType.CLIENT_INFO) - elif action.action_type == AuthActionType.SAVE_TOKENS: - self.save_oauth_data(action.provider_id, action.tenant_id, action.data, OAuthDataType.TOKENS) - elif action.action_type == AuthActionType.SAVE_CODE_VERIFIER: - self.save_oauth_data(action.provider_id, action.tenant_id, action.data, OAuthDataType.CODE_VERIFIER) + oauth_type = self._ACTION_TO_OAUTH.get(action.action_type) + if oauth_type is not None: + self.save_oauth_data(action.provider_id, action.tenant_id, action.data, oauth_type) return auth_result.response diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index b24f001133..47aca9b0af 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -1,6 +1,6 @@ import logging from collections.abc import Mapping -from typing import Any, Union +from typing import Any from pydantic import TypeAdapter, ValidationError from yarl import URL @@ -48,24 +48,30 @@ class ToolTransformService: URL(dify_config.CONSOLE_API_URL or "/") / "console" / "api" / "workspaces" / "current" / "tool-provider" ) - if provider_type == ToolProviderType.BUILT_IN: - return str(url_prefix / "builtin" / provider_name / "icon") - elif provider_type in {ToolProviderType.API, ToolProviderType.WORKFLOW}: - try: - if isinstance(icon, str): - parsed = emoji_icon_adapter.validate_json(icon) - return {"background": parsed["background"], "content": parsed["content"]} - return {"background": icon["background"], "content": icon["content"]} - except (ValueError, ValidationError, KeyError): - return {"background": "#252525", "content": "\ud83d\ude01"} - elif provider_type == ToolProviderType.MCP: - if isinstance(icon, Mapping): - return {"background": icon.get("background", ""), "content": icon.get("content", "")} - return icon - return "" + match provider_type: + case ToolProviderType.BUILT_IN: + return str(url_prefix / "builtin" / provider_name / "icon") + case ToolProviderType.API | ToolProviderType.WORKFLOW: + try: + if isinstance(icon, str): + parsed = emoji_icon_adapter.validate_json(icon) + return {"background": parsed["background"], "content": parsed["content"]} + return {"background": icon["background"], "content": icon["content"]} + except (ValueError, ValidationError, KeyError): + return {"background": "#252525", "content": "\ud83d\ude01"} + case ToolProviderType.MCP: + if isinstance(icon, Mapping): + return {"background": icon.get("background", ""), "content": icon.get("content", "")} + return icon + case ToolProviderType.PLUGIN | ToolProviderType.APP | ToolProviderType.DATASET_RETRIEVAL: + return "" + case _: + return "" @staticmethod - def repack_provider(tenant_id: str, provider: Union[dict, ToolProviderApiEntity, PluginDatasourceProviderEntity]): + def repack_provider( + tenant_id: str, provider: dict[str, Any] | ToolProviderApiEntity | PluginDatasourceProviderEntity + ): """ repack provider @@ -422,7 +428,7 @@ class ToolTransformService: @staticmethod def convert_builtin_provider_to_credential_entity( - provider: BuiltinToolProvider, credentials: dict + provider: BuiltinToolProvider, credentials: dict[str, Any] ) -> ToolProviderCredentialApiEntity: return ToolProviderCredentialApiEntity( id=provider.id, diff --git a/api/services/tools/workflow_tools_manage_service.py b/api/services/tools/workflow_tools_manage_service.py index 8f5144c866..be2572b592 100644 --- a/api/services/tools/workflow_tools_manage_service.py +++ b/api/services/tools/workflow_tools_manage_service.py @@ -1,10 +1,11 @@ import json import logging from datetime import datetime +from typing import Any from graphon.model_runtime.utils.encoders import jsonable_encoder from sqlalchemy import delete, or_, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from core.tools.__base.tool_provider import ToolProviderController from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity @@ -35,39 +36,50 @@ class WorkflowToolManageService: workflow_app_id: str, name: str, label: str, - icon: dict, + icon: dict[str, Any], description: str, parameters: list[WorkflowToolParameterConfiguration], privacy_policy: str = "", labels: list[str] | None = None, ): # check if the name is unique - existing_workflow_tool_provider = db.session.scalar( - select(WorkflowToolProvider) - .where( - WorkflowToolProvider.tenant_id == tenant_id, - # name or app_id - or_(WorkflowToolProvider.name == name, WorkflowToolProvider.app_id == workflow_app_id), + existing_workflow_tool_provider: WorkflowToolProvider | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + # query if the name or app_id exists + existing_workflow_tool_provider = _session.scalar( + select(WorkflowToolProvider) + .where( + WorkflowToolProvider.tenant_id == tenant_id, + # name or app_id + or_(WorkflowToolProvider.name == name, WorkflowToolProvider.app_id == workflow_app_id), + ) + .limit(1) ) - .limit(1) - ) + # if the name or app_id exists raise error if existing_workflow_tool_provider is not None: raise ValueError(f"Tool with name {name} or app_id {workflow_app_id} already exists") - app: App | None = db.session.scalar( - select(App).where(App.id == workflow_app_id, App.tenant_id == tenant_id).limit(1) - ) + # query the app + app: App | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + app = _session.scalar(select(App).where(App.id == workflow_app_id, App.tenant_id == tenant_id).limit(1)) + # if not found raise error if app is None: raise ValueError(f"App {workflow_app_id} not found") + # query the workflow workflow: Workflow | None = app.workflow + + # if not found raise error if workflow is None: raise ValueError(f"Workflow not found for app {workflow_app_id}") + # check if workflow configuration is synced WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict) + # create workflow tool provider workflow_tool_provider = WorkflowToolProvider( tenant_id=tenant_id, user_id=user_id, @@ -84,15 +96,18 @@ class WorkflowToolManageService: try: WorkflowToolProviderController.from_db(workflow_tool_provider) except Exception as e: + logger.warning(e, exc_info=True) raise ValueError(str(e)) - with Session(db.engine, expire_on_commit=False) as session, session.begin(): - session.add(workflow_tool_provider) + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + _session.add(workflow_tool_provider) + # keep the session open to make orm instances in the same session if labels is not None: ToolLabelManager.update_tool_labels( ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels ) + return {"result": "success"} @classmethod @@ -103,7 +118,7 @@ class WorkflowToolManageService: workflow_tool_id: str, name: str, label: str, - icon: dict, + icon: dict[str, Any], description: str, parameters: list[WorkflowToolParameterConfiguration], privacy_policy: str = "", @@ -111,6 +126,7 @@ class WorkflowToolManageService: ): """ Update a workflow tool. + :param user_id: the user id :param tenant_id: the tenant id :param workflow_tool_id: workflow tool id @@ -123,62 +139,82 @@ class WorkflowToolManageService: :param labels: labels :return: the updated tool """ - # check if the name is unique - existing_workflow_tool_provider = db.session.scalar( - select(WorkflowToolProvider) - .where( - WorkflowToolProvider.tenant_id == tenant_id, - WorkflowToolProvider.name == name, - WorkflowToolProvider.id != workflow_tool_id, - ) - .limit(1) - ) + existing_workflow_tool_provider: WorkflowToolProvider | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + # query if the name exists for other tools + existing_workflow_tool_provider = _session.scalar( + select(WorkflowToolProvider) + .where( + WorkflowToolProvider.tenant_id == tenant_id, + WorkflowToolProvider.name == name, + WorkflowToolProvider.id != workflow_tool_id, + ) + .limit(1) + ) + + # if the name exists raise error if existing_workflow_tool_provider is not None: raise ValueError(f"Tool with name {name} already exists") - workflow_tool_provider: WorkflowToolProvider | None = db.session.scalar( - select(WorkflowToolProvider) - .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id) - .limit(1) - ) + # query the workflow tool provider + workflow_tool_provider: WorkflowToolProvider | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + workflow_tool_provider = _session.scalar( + select(WorkflowToolProvider) + .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id) + .limit(1) + ) + # if not found raise error if workflow_tool_provider is None: raise ValueError(f"Tool {workflow_tool_id} not found") - app: App | None = db.session.scalar( - select(App).where(App.id == workflow_tool_provider.app_id, App.tenant_id == tenant_id).limit(1) - ) + # query the app + app: App | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + app = _session.scalar( + select(App).where(App.id == workflow_tool_provider.app_id, App.tenant_id == tenant_id).limit(1) + ) + # if not found raise error if app is None: raise ValueError(f"App {workflow_tool_provider.app_id} not found") + # query the workflow workflow: Workflow | None = app.workflow + + # if not found raise error if workflow is None: raise ValueError(f"Workflow not found for app {workflow_tool_provider.app_id}") + # check if workflow configuration is synced WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict) - workflow_tool_provider.name = name - workflow_tool_provider.label = label - workflow_tool_provider.icon = json.dumps(icon) - workflow_tool_provider.description = description - workflow_tool_provider.parameter_configuration = json.dumps([p.model_dump() for p in parameters]) - workflow_tool_provider.privacy_policy = privacy_policy - workflow_tool_provider.version = workflow.version - workflow_tool_provider.updated_at = datetime.now() + with sessionmaker(db.engine).begin() as _session: + _session.add(workflow_tool_provider) - try: - WorkflowToolProviderController.from_db(workflow_tool_provider) - except Exception as e: - raise ValueError(str(e)) + # update workflow tool provider + workflow_tool_provider.name = name + workflow_tool_provider.label = label + workflow_tool_provider.icon = json.dumps(icon) + workflow_tool_provider.description = description + workflow_tool_provider.parameter_configuration = json.dumps([p.model_dump() for p in parameters]) + workflow_tool_provider.privacy_policy = privacy_policy + workflow_tool_provider.version = workflow.version + workflow_tool_provider.updated_at = datetime.now() - db.session.commit() + try: + WorkflowToolProviderController.from_db(workflow_tool_provider) + except Exception as e: + raise ValueError(str(e)) - if labels is not None: - ToolLabelManager.update_tool_labels( - ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels - ) + if labels is not None: + ToolLabelManager.update_tool_labels( + ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), + labels, + session=_session, + ) return {"result": "success"} @@ -186,28 +222,32 @@ class WorkflowToolManageService: def list_tenant_workflow_tools(cls, user_id: str, tenant_id: str) -> list[ToolProviderApiEntity]: """ List workflow tools. + :param user_id: the user id :param tenant_id: the tenant id :return: the list of tools """ - db_tools = db.session.scalars( - select(WorkflowToolProvider).where(WorkflowToolProvider.tenant_id == tenant_id) - ).all() + + providers: list[WorkflowToolProvider] = [] + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + providers = list( + _session.scalars(select(WorkflowToolProvider).where(WorkflowToolProvider.tenant_id == tenant_id)).all() + ) # Create a mapping from provider_id to app_id - provider_id_to_app_id = {provider.id: provider.app_id for provider in db_tools} + provider_id_to_app_id = {provider.id: provider.app_id for provider in providers} tools: list[WorkflowToolProviderController] = [] - for provider in db_tools: + for provider in providers: try: tools.append(ToolTransformService.workflow_provider_to_controller(provider)) except Exception: # skip deleted tools logger.exception("Failed to load workflow tool provider %s", provider.id) - labels = ToolLabelManager.get_tools_labels([t for t in tools if isinstance(t, ToolProviderController)]) + labels = ToolLabelManager.get_tools_labels([tool for tool in tools if isinstance(tool, ToolProviderController)]) - result = [] + result: list[ToolProviderApiEntity] = [] for tool in tools: workflow_app_id = provider_id_to_app_id.get(tool.provider_id) @@ -232,17 +272,18 @@ class WorkflowToolManageService: def delete_workflow_tool(cls, user_id: str, tenant_id: str, workflow_tool_id: str): """ Delete a workflow tool. + :param user_id: the user id :param tenant_id: the tenant id :param workflow_tool_id: the workflow tool id """ - db.session.execute( - delete(WorkflowToolProvider).where( - WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id - ) - ) - db.session.commit() + with sessionmaker(db.engine).begin() as _session: + _ = _session.execute( + delete(WorkflowToolProvider).where( + WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id + ) + ) return {"result": "success"} @@ -250,47 +291,59 @@ class WorkflowToolManageService: def get_workflow_tool_by_tool_id(cls, user_id: str, tenant_id: str, workflow_tool_id: str): """ Get a workflow tool. + :param user_id: the user id :param tenant_id: the tenant id :param workflow_tool_id: the workflow tool id :return: the tool """ - db_tool: WorkflowToolProvider | None = db.session.scalar( - select(WorkflowToolProvider) - .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id) - .limit(1) - ) - return cls._get_workflow_tool(tenant_id, db_tool) + + tool_provider: WorkflowToolProvider | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + tool_provider = _session.scalar( + select(WorkflowToolProvider) + .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id) + .limit(1) + ) + + return cls._get_workflow_tool(tenant_id, tool_provider) @classmethod def get_workflow_tool_by_app_id(cls, user_id: str, tenant_id: str, workflow_app_id: str): """ Get a workflow tool. + :param user_id: the user id :param tenant_id: the tenant id :param workflow_app_id: the workflow app id :return: the tool """ - db_tool: WorkflowToolProvider | None = db.session.scalar( - select(WorkflowToolProvider) - .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.app_id == workflow_app_id) - .limit(1) - ) - return cls._get_workflow_tool(tenant_id, db_tool) + + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + tool_provider: WorkflowToolProvider | None = _session.scalar( + select(WorkflowToolProvider) + .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.app_id == workflow_app_id) + .limit(1) + ) + + return cls._get_workflow_tool(tenant_id, tool_provider) @classmethod def _get_workflow_tool(cls, tenant_id: str, db_tool: WorkflowToolProvider | None): """ Get a workflow tool. + :db_tool: the database tool :return: the tool """ if db_tool is None: raise ValueError("Tool not found") - workflow_app: App | None = db.session.scalar( - select(App).where(App.id == db_tool.app_id, App.tenant_id == db_tool.tenant_id).limit(1) - ) + workflow_app: App | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + workflow_app = _session.scalar( + select(App).where(App.id == db_tool.app_id, App.tenant_id == db_tool.tenant_id).limit(1) + ) if workflow_app is None: raise ValueError(f"App {db_tool.app_id} not found") @@ -330,28 +383,32 @@ class WorkflowToolManageService: def list_single_workflow_tools(cls, user_id: str, tenant_id: str, workflow_tool_id: str) -> list[ToolApiEntity]: """ List workflow tool provider tools. + :param user_id: the user id :param tenant_id: the tenant id :param workflow_tool_id: the workflow tool id :return: the list of tools """ - db_tool: WorkflowToolProvider | None = db.session.scalar( - select(WorkflowToolProvider) - .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id) - .limit(1) - ) - if db_tool is None: + provider: WorkflowToolProvider | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + provider = _session.scalar( + select(WorkflowToolProvider) + .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id) + .limit(1) + ) + + if provider is None: raise ValueError(f"Tool {workflow_tool_id} not found") - tool = ToolTransformService.workflow_provider_to_controller(db_tool) + tool = ToolTransformService.workflow_provider_to_controller(provider) workflow_tools: list[WorkflowTool] = tool.get_tools(tenant_id) if len(workflow_tools) == 0: raise ValueError(f"Tool {workflow_tool_id} not found") return [ ToolTransformService.convert_tool_entity_to_api_entity( - tool=tool.get_tools(db_tool.tenant_id)[0], + tool=tool.get_tools(provider.tenant_id)[0], labels=ToolLabelManager.get_tool_labels(tool), tenant_id=tenant_id, ) diff --git a/api/services/trigger/app_trigger_service.py b/api/services/trigger/app_trigger_service.py index 6d5a719f63..723d29e947 100644 --- a/api/services/trigger/app_trigger_service.py +++ b/api/services/trigger/app_trigger_service.py @@ -8,7 +8,7 @@ This service centralizes all AppTrigger-related business logic. import logging from sqlalchemy import update -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from extensions.ext_database import db from models.enums import AppTriggerStatus @@ -34,13 +34,12 @@ class AppTriggerService: """ try: - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: session.execute( update(AppTrigger) .where(AppTrigger.tenant_id == tenant_id, AppTrigger.status == AppTriggerStatus.ENABLED) .values(status=AppTriggerStatus.RATE_LIMITED) ) - session.commit() logger.info("Marked all enabled triggers as rate limited for tenant %s", tenant_id) except Exception: logger.exception("Failed to mark all enabled triggers as rate limited for tenant %s", tenant_id) diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 008d8bdb8a..6e14d996ea 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -3,10 +3,10 @@ import logging import time as _time import uuid from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict -from sqlalchemy import desc, func -from sqlalchemy.orm import Session +from sqlalchemy import delete, desc, func, select +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from constants import HIDDEN_VALUE, UNKNOWN_VALUE @@ -42,6 +42,10 @@ from services.plugin.plugin_service import PluginService logger = logging.getLogger(__name__) +class VerifyCredentialsResult(TypedDict): + verified: bool + + class TriggerProviderService: """Service for managing trigger providers and credentials""" @@ -69,27 +73,28 @@ class TriggerProviderService: workflows_in_use_map: dict[str, int] = {} with Session(db.engine, expire_on_commit=False) as session: # Get all subscriptions - subscriptions_db = ( - session.query(TriggerSubscription) - .filter_by(tenant_id=tenant_id, provider_id=str(provider_id)) + subscriptions_db = session.scalars( + select(TriggerSubscription) + .where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.provider_id == str(provider_id), + ) .order_by(desc(TriggerSubscription.created_at)) - .all() - ) + ).all() subscriptions = [subscription.to_api_entity() for subscription in subscriptions_db] if not subscriptions: return [] - usage_counts = ( - session.query( + usage_counts = session.execute( + select( WorkflowPluginTrigger.subscription_id, func.count(func.distinct(WorkflowPluginTrigger.app_id)).label("app_count"), ) - .filter( + .where( WorkflowPluginTrigger.tenant_id == tenant_id, WorkflowPluginTrigger.subscription_id.in_([s.id for s in subscriptions]), ) .group_by(WorkflowPluginTrigger.subscription_id) - .all() - ) + ).all() workflows_in_use_map = {str(row.subscription_id): int(row.app_count) for row in usage_counts} provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) @@ -146,15 +151,19 @@ class TriggerProviderService: """ try: provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # Use distributed lock to prevent race conditions lock_key = f"trigger_provider_create_lock:{tenant_id}_{provider_id}" with redis_client.lock(lock_key, timeout=20): # Check provider count limit provider_count = ( - session.query(TriggerSubscription) - .filter_by(tenant_id=tenant_id, provider_id=str(provider_id)) - .count() + session.scalar( + select(func.count(TriggerSubscription.id)).where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.provider_id == str(provider_id), + ) + ) + or 0 ) if provider_count >= cls.__MAX_TRIGGER_PROVIDER_COUNT__: @@ -164,10 +173,14 @@ class TriggerProviderService: ) # Check if name already exists - existing = ( - session.query(TriggerSubscription) - .filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name) - .first() + existing = session.scalar( + select(TriggerSubscription) + .where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.provider_id == str(provider_id), + TriggerSubscription.name == name, + ) + .limit(1) ) if existing: raise ValueError(f"Credential name '{name}' already exists for this provider") @@ -205,7 +218,6 @@ class TriggerProviderService: subscription.id = subscription_id or str(uuid.uuid4()) session.add(subscription) - session.commit() return { "result": "success", @@ -241,12 +253,17 @@ class TriggerProviderService: :param expires_at: Optional new expiration timestamp :return: Success response with updated subscription info """ - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # Use distributed lock to prevent race conditions on the same subscription lock_key = f"trigger_subscription_update_lock:{tenant_id}_{subscription_id}" with redis_client.lock(lock_key, timeout=20): - subscription: TriggerSubscription | None = ( - session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + subscription = session.scalar( + select(TriggerSubscription) + .where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.id == subscription_id, + ) + .limit(1) ) if not subscription: raise ValueError(f"Trigger subscription {subscription_id} not found") @@ -256,10 +273,14 @@ class TriggerProviderService: # Check for name uniqueness if name is being updated if name is not None and name != subscription.name: - existing = ( - session.query(TriggerSubscription) - .filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name) - .first() + existing = session.scalar( + select(TriggerSubscription) + .where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.provider_id == str(provider_id), + TriggerSubscription.name == name, + ) + .limit(1) ) if existing: raise ValueError(f"Subscription name '{name}' already exists for this provider") @@ -302,8 +323,6 @@ class TriggerProviderService: if expires_at is not None: subscription.expires_at = expires_at - session.commit() - # Clear subscription cache delete_cache_for_subscription( tenant_id=tenant_id, @@ -319,11 +338,18 @@ class TriggerProviderService: with Session(db.engine, expire_on_commit=False) as session: subscription: TriggerSubscription | None = None if subscription_id: - subscription = ( - session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + subscription = session.scalar( + select(TriggerSubscription) + .where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.id == subscription_id, + ) + .limit(1) ) else: - subscription = session.query(TriggerSubscription).filter_by(tenant_id=tenant_id).first() + subscription = session.scalar( + select(TriggerSubscription).where(TriggerSubscription.tenant_id == tenant_id).limit(1) + ) if subscription: provider_controller = TriggerManager.get_trigger_provider( tenant_id, TriggerProviderID(subscription.provider_id) @@ -352,8 +378,13 @@ class TriggerProviderService: :param subscription_id: Subscription instance ID :return: Success response """ - subscription: TriggerSubscription | None = ( - session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + subscription = session.scalar( + select(TriggerSubscription) + .where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.id == subscription_id, + ) + .limit(1) ) if not subscription: raise ValueError(f"Trigger provider subscription {subscription_id} not found") @@ -404,8 +435,15 @@ class TriggerProviderService: :param subscription_id: Subscription instance ID :return: New token info """ - with Session(db.engine) as session: - subscription = session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + with sessionmaker(bind=db.engine).begin() as session: + subscription = session.scalar( + select(TriggerSubscription) + .where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.id == subscription_id, + ) + .limit(1) + ) if not subscription: raise ValueError(f"Trigger provider subscription {subscription_id} not found") @@ -448,7 +486,6 @@ class TriggerProviderService: # Update credentials subscription.credentials = dict(encrypter.encrypt(dict(refreshed_credentials.credentials))) subscription.credential_expires_at = refreshed_credentials.expires_at - session.commit() # Clear cache cache.delete() @@ -478,9 +515,14 @@ class TriggerProviderService: """ now_ts: int = int(now if now is not None else _time.time()) - with Session(db.engine) as session: - subscription: TriggerSubscription | None = ( - session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + with sessionmaker(bind=db.engine).begin() as session: + subscription = session.scalar( + select(TriggerSubscription) + .where( + TriggerSubscription.tenant_id == tenant_id, + TriggerSubscription.id == subscription_id, + ) + .limit(1) ) if subscription is None: raise ValueError(f"Trigger provider subscription {subscription_id} not found") @@ -531,7 +573,6 @@ class TriggerProviderService: # Persist refreshed properties and expires_at subscription.properties = dict(properties_encrypter.encrypt(dict(refreshed.properties))) subscription.expires_at = int(refreshed.expires_at) - session.commit() properties_cache.delete() logger.info( @@ -557,15 +598,15 @@ class TriggerProviderService: tenant_id=tenant_id, provider_id=provider_id ) with Session(db.engine, expire_on_commit=False) as session: - tenant_client: TriggerOAuthTenantClient | None = ( - session.query(TriggerOAuthTenantClient) - .filter_by( - tenant_id=tenant_id, - provider=provider_id.provider_name, - plugin_id=provider_id.plugin_id, - enabled=True, + tenant_client = session.scalar( + select(TriggerOAuthTenantClient) + .where( + TriggerOAuthTenantClient.tenant_id == tenant_id, + TriggerOAuthTenantClient.provider == provider_id.provider_name, + TriggerOAuthTenantClient.plugin_id == provider_id.plugin_id, + TriggerOAuthTenantClient.enabled.is_(True), ) - .first() + .limit(1) ) oauth_params: Mapping[str, Any] | None = None @@ -583,10 +624,13 @@ class TriggerProviderService: return None # Check for system-level OAuth client - system_client: TriggerOAuthSystemClient | None = ( - session.query(TriggerOAuthSystemClient) - .filter_by(plugin_id=provider_id.plugin_id, provider=provider_id.provider_name) - .first() + system_client = session.scalar( + select(TriggerOAuthSystemClient) + .where( + TriggerOAuthSystemClient.plugin_id == provider_id.plugin_id, + TriggerOAuthSystemClient.provider == provider_id.provider_name, + ) + .limit(1) ) if system_client: @@ -607,10 +651,13 @@ class TriggerProviderService: if not is_verified: return False with Session(db.engine, expire_on_commit=False) as session: - system_client: TriggerOAuthSystemClient | None = ( - session.query(TriggerOAuthSystemClient) - .filter_by(plugin_id=provider_id.plugin_id, provider=provider_id.provider_name) - .first() + system_client = session.scalar( + select(TriggerOAuthSystemClient) + .where( + TriggerOAuthSystemClient.plugin_id == provider_id.plugin_id, + TriggerOAuthSystemClient.provider == provider_id.provider_name, + ) + .limit(1) ) return system_client is not None @@ -639,16 +686,16 @@ class TriggerProviderService: tenant_id=tenant_id, provider_id=provider_id ) - with Session(db.engine) as session: + with sessionmaker(bind=db.engine).begin() as session: # Find existing custom client params - custom_client = ( - session.query(TriggerOAuthTenantClient) - .filter_by( - tenant_id=tenant_id, - plugin_id=provider_id.plugin_id, - provider=provider_id.provider_name, + custom_client = session.scalar( + select(TriggerOAuthTenantClient) + .where( + TriggerOAuthTenantClient.tenant_id == tenant_id, + TriggerOAuthTenantClient.plugin_id == provider_id.plugin_id, + TriggerOAuthTenantClient.provider == provider_id.provider_name, ) - .first() + .limit(1) ) # Create new record if doesn't exist @@ -683,8 +730,6 @@ class TriggerProviderService: if enabled is not None: custom_client.enabled = enabled - session.commit() - return {"result": "success"} @classmethod @@ -697,14 +742,14 @@ class TriggerProviderService: :return: Masked OAuth client parameters """ with Session(db.engine) as session: - custom_client = ( - session.query(TriggerOAuthTenantClient) - .filter_by( - tenant_id=tenant_id, - plugin_id=provider_id.plugin_id, - provider=provider_id.provider_name, + custom_client = session.scalar( + select(TriggerOAuthTenantClient) + .where( + TriggerOAuthTenantClient.tenant_id == tenant_id, + TriggerOAuthTenantClient.plugin_id == provider_id.plugin_id, + TriggerOAuthTenantClient.provider == provider_id.provider_name, ) - .first() + .limit(1) ) if custom_client is None: @@ -733,13 +778,16 @@ class TriggerProviderService: :param provider_id: Provider identifier :return: Success response """ - with Session(db.engine) as session: - session.query(TriggerOAuthTenantClient).filter_by( - tenant_id=tenant_id, - provider=provider_id.provider_name, - plugin_id=provider_id.plugin_id, - ).delete() - session.commit() + with sessionmaker(bind=db.engine).begin() as session: + session.execute( + delete(TriggerOAuthTenantClient) + .where( + TriggerOAuthTenantClient.tenant_id == tenant_id, + TriggerOAuthTenantClient.provider == provider_id.provider_name, + TriggerOAuthTenantClient.plugin_id == provider_id.plugin_id, + ) + .execution_options(synchronize_session=False) + ) return {"result": "success"} @@ -753,15 +801,15 @@ class TriggerProviderService: :return: True if enabled, False otherwise """ with Session(db.engine, expire_on_commit=False) as session: - custom_client = ( - session.query(TriggerOAuthTenantClient) - .filter_by( - tenant_id=tenant_id, - plugin_id=provider_id.plugin_id, - provider=provider_id.provider_name, - enabled=True, + custom_client = session.scalar( + select(TriggerOAuthTenantClient) + .where( + TriggerOAuthTenantClient.tenant_id == tenant_id, + TriggerOAuthTenantClient.plugin_id == provider_id.plugin_id, + TriggerOAuthTenantClient.provider == provider_id.provider_name, + TriggerOAuthTenantClient.enabled.is_(True), ) - .first() + .limit(1) ) return custom_client is not None @@ -771,7 +819,9 @@ class TriggerProviderService: Get a trigger subscription by the endpoint ID. """ with Session(db.engine, expire_on_commit=False) as session: - subscription = session.query(TriggerSubscription).filter_by(endpoint_id=endpoint_id).first() + subscription = session.scalar( + select(TriggerSubscription).where(TriggerSubscription.endpoint_id == endpoint_id).limit(1) + ) if not subscription: return None provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( @@ -800,7 +850,7 @@ class TriggerProviderService: provider_id: TriggerProviderID, subscription_id: str, credentials: dict[str, Any], - ) -> dict[str, Any]: + ) -> VerifyCredentialsResult: """ Verify credentials for an existing subscription without updating it. diff --git a/api/services/trigger/trigger_service.py b/api/services/trigger/trigger_service.py index d72c041609..5a5d13b96d 100644 --- a/api/services/trigger/trigger_service.py +++ b/api/services/trigger/trigger_service.py @@ -8,7 +8,7 @@ from flask import Request, Response from graphon.entities.graph_config import NodeConfigDict from pydantic import BaseModel from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.entities.request import TriggerDispatchResponse, TriggerInvokeEventResponse @@ -215,7 +215,7 @@ class TriggerService: not_found_in_cache.append(node_info) continue - with Session(db.engine) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: try: # lock the concurrent plugin trigger creation redis_client.lock(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:apps:{app.id}:lock", timeout=10) @@ -260,7 +260,6 @@ class TriggerService: cache.model_dump_json(), ex=60 * 60, ) - session.commit() # Update existing records if subscription_id changed for node_info in nodes_in_graph: @@ -290,14 +289,12 @@ class TriggerService: cache.model_dump_json(), ex=60 * 60, ) - session.commit() # delete the nodes not found in the graph for node_id in nodes_id_in_db: if node_id not in nodes_id_in_graph: session.delete(nodes_id_in_db[node_id]) redis_client.delete(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{app.id}:{node_id}") - session.commit() except Exception: logger.exception("Failed to sync plugin trigger relationships for app %s", app.id) raise diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index f72c69a33e..bb767a6759 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -12,7 +12,7 @@ from graphon.file import FileTransferMethod from graphon.variables.types import ArrayValidation, SegmentType from pydantic import BaseModel from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from werkzeug.datastructures import FileStorage from werkzeug.exceptions import RequestEntityTooLarge @@ -104,32 +104,32 @@ class WebhookService: """ with Session(db.engine) as session: # Get webhook trigger - webhook_trigger = ( - session.query(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.webhook_id == webhook_id).first() + webhook_trigger = session.scalar( + select(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.webhook_id == webhook_id).limit(1) ) if not webhook_trigger: raise ValueError(f"Webhook not found: {webhook_id}") if is_debug: - workflow = ( - session.query(Workflow) - .filter( + workflow = session.scalar( + select(Workflow) + .where( Workflow.app_id == webhook_trigger.app_id, Workflow.version == Workflow.VERSION_DRAFT, ) .order_by(Workflow.created_at.desc()) - .first() + .limit(1) ) else: # Check if the corresponding AppTrigger exists - app_trigger = ( - session.query(AppTrigger) - .filter( + app_trigger = session.scalar( + select(AppTrigger) + .where( AppTrigger.app_id == webhook_trigger.app_id, AppTrigger.node_id == webhook_trigger.node_id, AppTrigger.trigger_type == AppTriggerType.TRIGGER_WEBHOOK, ) - .first() + .limit(1) ) if not app_trigger: @@ -146,14 +146,14 @@ class WebhookService: raise ValueError(f"Webhook trigger is disabled for webhook {webhook_id}") # Get workflow - workflow = ( - session.query(Workflow) - .filter( + workflow = session.scalar( + select(Workflow) + .where( Workflow.app_id == webhook_trigger.app_id, Workflow.version != Workflow.VERSION_DRAFT, ) .order_by(Workflow.created_at.desc()) - .first() + .limit(1) ) if not workflow: raise ValueError(f"Workflow not found for app {webhook_trigger.app_id}") @@ -597,21 +597,38 @@ class WebhookService: Raises: ValueError: If the value cannot be converted to the specified type """ - if param_type == SegmentType.STRING: - return value - elif param_type == SegmentType.NUMBER: - if not cls._can_convert_to_number(value): - raise ValueError(f"Cannot convert '{value}' to number") - numeric_value = float(value) - return int(numeric_value) if numeric_value.is_integer() else numeric_value - elif param_type == SegmentType.BOOLEAN: - lower_value = value.lower() - bool_map = {"true": True, "false": False, "1": True, "0": False, "yes": True, "no": False} - if lower_value not in bool_map: - raise ValueError(f"Cannot convert '{value}' to boolean") - return bool_map[lower_value] - else: - raise ValueError(f"Unsupported type '{param_type}' for form data parameter '{param_name}'") + match param_type: + case SegmentType.STRING: + return value + case SegmentType.NUMBER: + if not cls._can_convert_to_number(value): + raise ValueError(f"Cannot convert '{value}' to number") + numeric_value = float(value) + return int(numeric_value) if numeric_value.is_integer() else numeric_value + case SegmentType.BOOLEAN: + lower_value = value.lower() + bool_map = {"true": True, "false": False, "1": True, "0": False, "yes": True, "no": False} + if lower_value not in bool_map: + raise ValueError(f"Cannot convert '{value}' to boolean") + return bool_map[lower_value] + case ( + SegmentType.OBJECT + | SegmentType.FILE + | SegmentType.ARRAY_ANY + | SegmentType.ARRAY_STRING + | SegmentType.ARRAY_NUMBER + | SegmentType.ARRAY_OBJECT + | SegmentType.ARRAY_FILE + | SegmentType.ARRAY_BOOLEAN + | SegmentType.SECRET + | SegmentType.INTEGER + | SegmentType.FLOAT + | SegmentType.NONE + | SegmentType.GROUP + ): + raise ValueError(f"Unsupported type '{param_type}' for form data parameter '{param_name}'") + case _: + raise ValueError(f"Unsupported type '{param_type}' for form data parameter '{param_name}'") @classmethod def _validate_json_value(cls, param_name: str, value: Any, param_type: SegmentType | str) -> Any: @@ -912,7 +929,7 @@ class WebhookService: logger.warning("Failed to acquire lock for webhook sync, app %s", app.id) raise RuntimeError("Failed to acquire lock for webhook trigger synchronization") - with Session(db.engine) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: # fetch the non-cached nodes from DB all_records = session.scalars( select(WorkflowWebhookTrigger).where( @@ -941,14 +958,12 @@ class WebhookService: redis_client.set( f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:{app.id}:{node_id}", cache.model_dump_json(), ex=60 * 60 ) - session.commit() # delete the nodes not found in the graph for node_id in nodes_id_in_db: if node_id not in nodes_id_in_graph: session.delete(nodes_id_in_db[node_id]) redis_client.delete(f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:{app.id}:{node_id}") - session.commit() except Exception: logger.exception("Failed to sync webhook relationships for app %s", app.id) raise diff --git a/api/services/website_service.py b/api/services/website_service.py index 2471c2cee8..ea584088bb 100644 --- a/api/services/website_service.py +++ b/api/services/website_service.py @@ -91,7 +91,7 @@ class WebsiteCrawlApiRequest: return CrawlRequest(url=self.url, provider=self.provider, options=options) @classmethod - def from_args(cls, args: dict) -> WebsiteCrawlApiRequest: + def from_args(cls, args: dict[str, Any]) -> WebsiteCrawlApiRequest: """Create from Flask-RESTful parsed arguments.""" provider = args.get("provider") url = args.get("url") @@ -115,7 +115,7 @@ class WebsiteCrawlStatusApiRequest: job_id: str @classmethod - def from_args(cls, args: dict, job_id: str) -> WebsiteCrawlStatusApiRequest: + def from_args(cls, args: dict[str, Any], job_id: str) -> WebsiteCrawlStatusApiRequest: """Create from Flask-RESTful parsed arguments.""" provider = args.get("provider") if not provider: @@ -163,7 +163,7 @@ class WebsiteService: raise ValueError("Invalid provider") @classmethod - def _get_decrypted_api_key(cls, tenant_id: str, config: dict) -> str: + def _get_decrypted_api_key(cls, tenant_id: str, config: dict[str, Any]) -> str: """Decrypt and return the API key from config.""" api_key = config.get("api_key") if not api_key: @@ -171,7 +171,7 @@ class WebsiteService: return encrypter.decrypt_token(tenant_id=tenant_id, token=api_key) @classmethod - def document_create_args_validate(cls, args: dict): + def document_create_args_validate(cls, args: dict[str, Any]): """Validate arguments for document creation.""" try: WebsiteCrawlApiRequest.from_args(args) @@ -195,7 +195,7 @@ class WebsiteService: raise ValueError("Invalid provider") @classmethod - def _crawl_with_firecrawl(cls, request: CrawlRequest, api_key: str, config: dict) -> dict[str, Any]: + def _crawl_with_firecrawl(cls, request: CrawlRequest, api_key: str, config: dict[str, Any]) -> dict[str, Any]: firecrawl_app = FirecrawlApp(api_key=api_key, base_url=config.get("base_url")) params: dict[str, Any] @@ -225,7 +225,7 @@ class WebsiteService: return {"status": "active", "job_id": job_id} @classmethod - def _crawl_with_watercrawl(cls, request: CrawlRequest, api_key: str, config: dict) -> dict[str, Any]: + def _crawl_with_watercrawl(cls, request: CrawlRequest, api_key: str, config: dict[str, Any]) -> dict[str, Any]: # Convert CrawlOptions back to dict format for WaterCrawlProvider options = { "limit": request.options.limit, @@ -290,7 +290,7 @@ class WebsiteService: raise ValueError("Invalid provider") @classmethod - def _get_firecrawl_status(cls, job_id: str, api_key: str, config: dict) -> CrawlStatusDict: + def _get_firecrawl_status(cls, job_id: str, api_key: str, config: dict[str, Any]) -> CrawlStatusDict: firecrawl_app = FirecrawlApp(api_key=api_key, base_url=config.get("base_url")) result: CrawlStatusResponse = firecrawl_app.check_crawl_status(job_id) crawl_status_data: CrawlStatusDict = { @@ -364,7 +364,9 @@ class WebsiteService: raise ValueError("Invalid provider") @classmethod - def _get_firecrawl_url_data(cls, job_id: str, url: str, api_key: str, config: dict) -> dict[str, Any] | None: + def _get_firecrawl_url_data( + cls, job_id: str, url: str, api_key: str, config: dict[str, Any] + ) -> dict[str, Any] | None: crawl_data: list[FirecrawlDocumentData] | None = None file_key = "website_files/" + job_id + ".txt" if storage.exists(file_key): @@ -438,7 +440,7 @@ class WebsiteService: raise ValueError("Invalid provider") @classmethod - def _scrape_with_firecrawl(cls, request: ScrapeRequest, api_key: str, config: dict) -> dict[str, Any]: + def _scrape_with_firecrawl(cls, request: ScrapeRequest, api_key: str, config: dict[str, Any]) -> dict[str, Any]: firecrawl_app = FirecrawlApp(api_key=api_key, base_url=config.get("base_url")) params = {"onlyMainContent": request.only_main_content} return dict(firecrawl_app.scrape_url(url=request.url, params=params)) diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 9ed60bf86b..2cc6e21574 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -3,8 +3,9 @@ import json import logging from collections.abc import Mapping, Sequence from concurrent.futures import ThreadPoolExecutor +from datetime import datetime from enum import StrEnum -from typing import Any, ClassVar +from typing import Any, ClassVar, NotRequired, TypedDict from graphon.enums import NodeType from graphon.file import File @@ -19,7 +20,7 @@ from graphon.variables.segments import ( ) from graphon.variables.types import SegmentType from graphon.variables.utils import dumps_with_segments -from sqlalchemy import Engine, orm, select +from sqlalchemy import Engine, delete, orm, select from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session, sessionmaker @@ -222,11 +223,10 @@ class WorkflowDraftVariableService: ) def get_variable(self, variable_id: str) -> WorkflowDraftVariable | None: - return ( - self._session.query(WorkflowDraftVariable) + return self._session.scalar( + select(WorkflowDraftVariable) .options(orm.selectinload(WorkflowDraftVariable.variable_file)) .where(WorkflowDraftVariable.id == variable_id) - .first() ) def get_draft_variables_by_selectors( @@ -254,20 +254,21 @@ class WorkflowDraftVariableService: # Alternatively, a `SELECT` statement could be constructed for each selector and # combined using `UNION` to fetch all rows. # Benchmarking indicates that both approaches yield comparable performance. - query = ( - self._session.query(WorkflowDraftVariable) - .options( - orm.selectinload(WorkflowDraftVariable.variable_file).selectinload( - WorkflowDraftVariableFile.upload_file + return list( + self._session.scalars( + select(WorkflowDraftVariable) + .options( + orm.selectinload(WorkflowDraftVariable.variable_file).selectinload( + WorkflowDraftVariableFile.upload_file + ) + ) + .where( + WorkflowDraftVariable.app_id == app_id, + WorkflowDraftVariable.user_id == user_id, + or_(*ors), ) ) - .where( - WorkflowDraftVariable.app_id == app_id, - WorkflowDraftVariable.user_id == user_id, - or_(*ors), - ) ) - return query.all() def list_variables_without_values( self, app_id: str, page: int, limit: int, user_id: str @@ -277,18 +278,21 @@ class WorkflowDraftVariableService: WorkflowDraftVariable.user_id == user_id, ] total = None - query = self._session.query(WorkflowDraftVariable).where(*criteria) + base_stmt = select(WorkflowDraftVariable).where(*criteria) if page == 1: - total = query.count() - variables = ( - # Do not load the `value` field - query.options( - orm.defer(WorkflowDraftVariable.value, raiseload=True), + from sqlalchemy import func as sa_func + + total = self._session.scalar(select(sa_func.count()).select_from(base_stmt.subquery())) + variables = list( + self._session.scalars( + # Do not load the `value` field + base_stmt.options( + orm.defer(WorkflowDraftVariable.value, raiseload=True), + ) + .order_by(WorkflowDraftVariable.created_at.desc()) + .limit(limit) + .offset((page - 1) * limit) ) - .order_by(WorkflowDraftVariable.created_at.desc()) - .limit(limit) - .offset((page - 1) * limit) - .all() ) return WorkflowDraftVariableList(variables=variables, total=total) @@ -299,11 +303,13 @@ class WorkflowDraftVariableService: WorkflowDraftVariable.node_id == node_id, WorkflowDraftVariable.user_id == user_id, ] - query = self._session.query(WorkflowDraftVariable).where(*criteria) - variables = ( - query.options(orm.selectinload(WorkflowDraftVariable.variable_file)) - .order_by(WorkflowDraftVariable.created_at.desc()) - .all() + variables = list( + self._session.scalars( + select(WorkflowDraftVariable) + .options(orm.selectinload(WorkflowDraftVariable.variable_file)) + .where(*criteria) + .order_by(WorkflowDraftVariable.created_at.desc()) + ) ) return WorkflowDraftVariableList(variables=variables) @@ -326,8 +332,8 @@ class WorkflowDraftVariableService: return self._get_variable(app_id, node_id, name, user_id=user_id) def _get_variable(self, app_id: str, node_id: str, name: str, user_id: str) -> WorkflowDraftVariable | None: - return ( - self._session.query(WorkflowDraftVariable) + return self._session.scalar( + select(WorkflowDraftVariable) .options(orm.selectinload(WorkflowDraftVariable.variable_file)) .where( WorkflowDraftVariable.app_id == app_id, @@ -335,7 +341,6 @@ class WorkflowDraftVariableService: WorkflowDraftVariable.name == name, WorkflowDraftVariable.user_id == user_id, ) - .first() ) def update_variable( @@ -488,20 +493,20 @@ class WorkflowDraftVariableService: self._session.delete(variable) def delete_user_workflow_variables(self, app_id: str, user_id: str): - ( - self._session.query(WorkflowDraftVariable) + self._session.execute( + delete(WorkflowDraftVariable) .where( WorkflowDraftVariable.app_id == app_id, WorkflowDraftVariable.user_id == user_id, ) - .delete(synchronize_session=False) + .execution_options(synchronize_session=False) ) def delete_app_workflow_variables(self, app_id: str): - ( - self._session.query(WorkflowDraftVariable) + self._session.execute( + delete(WorkflowDraftVariable) .where(WorkflowDraftVariable.app_id == app_id) - .delete(synchronize_session=False) + .execution_options(synchronize_session=False) ) def delete_workflow_draft_variable_file(self, deletions: list[DraftVarFileDeletion]): @@ -540,14 +545,14 @@ class WorkflowDraftVariableService: return self._delete_node_variables(app_id, node_id, user_id=user_id) def _delete_node_variables(self, app_id: str, node_id: str, user_id: str): - ( - self._session.query(WorkflowDraftVariable) + self._session.execute( + delete(WorkflowDraftVariable) .where( WorkflowDraftVariable.app_id == app_id, WorkflowDraftVariable.node_id == node_id, WorkflowDraftVariable.user_id == user_id, ) - .delete(synchronize_session=False) + .execution_options(synchronize_session=False) ) def _get_conversation_id_from_draft_variable(self, app_id: str, user_id: str) -> str | None: @@ -588,13 +593,11 @@ class WorkflowDraftVariableService: conv_id = self._get_conversation_id_from_draft_variable(workflow.app_id, account_id) if conv_id is not None: - conversation = ( - self._session.query(Conversation) - .where( + conversation = self._session.scalar( + select(Conversation).where( Conversation.id == conv_id, Conversation.app_id == workflow.app_id, ) - .first() ) # Only return the conversation ID if it exists and is valid (has a correspond conversation record in DB). if conversation is not None: @@ -723,8 +726,27 @@ def _batch_upsert_draft_variable( session.execute(stmt) -def _model_to_insertion_dict(model: WorkflowDraftVariable) -> dict[str, Any]: - d: dict[str, Any] = { +class _InsertionDict(TypedDict): + id: str + app_id: str + user_id: str | None + last_edited_at: datetime | None + node_id: str + name: str + selector: str + value_type: SegmentType + value: str + node_execution_id: str | None + file_id: str | None + visible: NotRequired[bool] + editable: NotRequired[bool] + created_at: NotRequired[datetime] + updated_at: NotRequired[datetime] + description: NotRequired[str] + + +def _model_to_insertion_dict(model: WorkflowDraftVariable) -> _InsertionDict: + d: _InsertionDict = { "id": model.id, "app_id": model.app_id, "user_id": model.user_id, @@ -1075,9 +1097,8 @@ class DraftVariableSaver: ) engine = bind = self._session.get_bind() assert isinstance(engine, Engine) - with Session(bind=engine, expire_on_commit=False) as session: + with sessionmaker(bind=engine, expire_on_commit=False).begin() as session: session.add(variable_file) - session.commit() return truncation_result.result, variable_file diff --git a/api/services/workflow_run_service.py b/api/services/workflow_run_service.py index b903d8df5f..29b9e72a00 100644 --- a/api/services/workflow_run_service.py +++ b/api/services/workflow_run_service.py @@ -1,5 +1,6 @@ import threading from collections.abc import Sequence +from typing import TypedDict from sqlalchemy import Engine from sqlalchemy.orm import sessionmaker @@ -19,6 +20,14 @@ from repositories.api_workflow_run_repository import APIWorkflowRunRepository from repositories.factory import DifyAPIRepositoryFactory +class WorkflowRunListArgs(TypedDict, total=False): + """Expected shape of the args dict passed to workflow run pagination methods.""" + + limit: int + last_id: str + status: str + + class WorkflowRunService: _session_factory: sessionmaker _workflow_run_repo: APIWorkflowRunRepository @@ -37,7 +46,10 @@ class WorkflowRunService: self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._session_factory) def get_paginate_advanced_chat_workflow_runs( - self, app_model: App, args: dict, triggered_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.DEBUGGING + self, + app_model: App, + args: WorkflowRunListArgs, + triggered_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.DEBUGGING, ) -> InfiniteScrollPagination: """ Get advanced chat app workflow run list @@ -73,7 +85,10 @@ class WorkflowRunService: return pagination def get_paginate_workflow_runs( - self, app_model: App, args: dict, triggered_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.DEBUGGING + self, + app_model: App, + args: WorkflowRunListArgs, + triggered_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.DEBUGGING, ) -> InfiniteScrollPagination: """ Get workflow run list diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 5301491df7..839b9e3319 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -5,7 +5,7 @@ import uuid from collections.abc import Callable, Generator, Mapping, Sequence from typing import Any, cast -from graphon.entities import GraphInitParams, WorkflowNodeExecution +from graphon.entities import WorkflowNodeExecution from graphon.entities.graph_config import NodeConfigDict from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import ( @@ -48,7 +48,12 @@ from core.workflow.human_input_compat import ( normalize_human_input_node_data_for_graph, parse_human_input_delivery_methods, ) -from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping, is_start_node_type +from core.workflow.node_factory import ( + LATEST_VERSION, + DifyGraphInitContext, + get_node_type_classes_mapping, + is_start_node_type, +) from core.workflow.node_runtime import DifyHumanInputNodeRuntime, apply_dify_debug_email_recipient from core.workflow.system_variables import build_bootstrap_variables, build_system_variables, default_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool @@ -834,10 +839,10 @@ class WorkflowService: if workflow_node_execution is None: raise ValueError(f"WorkflowNodeExecution with id {node_execution.id} not found after saving") - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: outputs = workflow_node_execution.load_full_outputs(session, storage) - with Session(bind=db.engine) as session, session.begin(): + with sessionmaker(bind=db.engine).begin() as session: draft_var_saver = DraftVariableSaver( session=session, app_id=app_model.id, @@ -848,7 +853,6 @@ class WorkflowService: user=account, ) draft_var_saver.save(process_data=node_execution.process_data, outputs=outputs) - session.commit() enqueue_draft_node_execution_trace( execution=workflow_node_execution, @@ -977,7 +981,7 @@ class WorkflowService: enclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config) enclosing_node_id = enclosing_node_type_and_id[1] if enclosing_node_type_and_id else None - with Session(bind=db.engine) as session, session.begin(): + with sessionmaker(bind=db.engine).begin() as session: draft_var_saver = DraftVariableSaver( session=session, app_id=app_model.id, @@ -988,7 +992,6 @@ class WorkflowService: enclosing_node_id=enclosing_node_id, ) draft_var_saver.save(outputs=outputs, process_data={}) - session.commit() return outputs @@ -1134,18 +1137,20 @@ class WorkflowService: node_config: NodeConfigDict, variable_pool: VariablePool, ) -> HumanInputNode: - graph_init_params = GraphInitParams( + run_context = build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + user_id=account.id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + graph_init_context = DifyGraphInitContext( workflow_id=workflow.id, graph_config=workflow.graph_dict, - run_context=build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=workflow.app_id, - user_id=account.id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ), + run_context=run_context, call_depth=0, ) + graph_init_params = graph_init_context.to_graph_init_params() graph_runtime_state = GraphRuntimeState( variable_pool=variable_pool, start_at=time.perf_counter(), @@ -1155,7 +1160,7 @@ class WorkflowService: config=node_config, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, - runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), + runtime=DifyHumanInputNodeRuntime(run_context), ) return node @@ -1507,14 +1512,12 @@ class WorkflowService: # Don't use workflow.tool_published as it's not accurate for specific workflow versions # Check if there's a tool provider using this specific workflow version - tool_provider = ( - session.query(WorkflowToolProvider) - .where( + tool_provider = session.scalar( + select(WorkflowToolProvider).where( WorkflowToolProvider.tenant_id == workflow.tenant_id, WorkflowToolProvider.app_id == workflow.app_id, WorkflowToolProvider.version == workflow.version, ) - .first() ) if tool_provider: diff --git a/api/tasks/add_document_to_index_task.py b/api/tasks/add_document_to_index_task.py index ae55c9ee03..c9d4673c0a 100644 --- a/api/tasks/add_document_to_index_task.py +++ b/api/tasks/add_document_to_index_task.py @@ -3,6 +3,7 @@ import time import click from celery import shared_task +from sqlalchemy import delete, select, update from core.db.session_factory import session_factory from core.rag.index_processor.constant.doc_type import DocType @@ -30,7 +31,9 @@ def add_document_to_index_task(dataset_document_id: str): start_at = time.perf_counter() with session_factory.create_session() as session: - dataset_document = session.query(DatasetDocument).where(DatasetDocument.id == dataset_document_id).first() + dataset_document = session.scalar( + select(DatasetDocument).where(DatasetDocument.id == dataset_document_id).limit(1) + ) if not dataset_document: logger.info(click.style(f"Document not found: {dataset_document_id}", fg="red")) return @@ -45,15 +48,14 @@ def add_document_to_index_task(dataset_document_id: str): if not dataset: raise Exception(f"Document {dataset_document.id} dataset {dataset_document.dataset_id} doesn't exist.") - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .where( DocumentSegment.document_id == dataset_document.id, DocumentSegment.status == SegmentStatus.COMPLETED, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() documents = [] multimodal_documents = [] @@ -104,18 +106,15 @@ def add_document_to_index_task(dataset_document_id: str): index_processor.load(dataset, documents, multimodal_documents=multimodal_documents) # delete auto disable log - session.query(DatasetAutoDisableLog).where( - DatasetAutoDisableLog.document_id == dataset_document.id - ).delete() + session.execute( + delete(DatasetAutoDisableLog).where(DatasetAutoDisableLog.document_id == dataset_document.id) + ) # update segment to enable - session.query(DocumentSegment).where(DocumentSegment.document_id == dataset_document.id).update( - { - DocumentSegment.enabled: True, - DocumentSegment.disabled_at: None, - DocumentSegment.disabled_by: None, - DocumentSegment.updated_at: naive_utc_now(), - } + session.execute( + update(DocumentSegment) + .where(DocumentSegment.document_id == dataset_document.id) + .values(enabled=True, disabled_at=None, disabled_by=None, updated_at=naive_utc_now()) ) session.commit() diff --git a/api/tasks/async_workflow_tasks.py b/api/tasks/async_workflow_tasks.py index 0a73c91279..9ff34c7c48 100644 --- a/api/tasks/async_workflow_tasks.py +++ b/api/tasks/async_workflow_tasks.py @@ -7,15 +7,16 @@ with appropriate retry policies and error handling. import logging from datetime import UTC, datetime -from typing import Any +from typing import Any, NotRequired from celery import shared_task from graphon.runtime import GraphRuntimeState from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker +from typing_extensions import TypedDict from configs import dify_config -from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator +from core.app.apps.workflow.app_generator import WorkflowAppGenerator from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, WorkflowResumptionContext from core.app.layers.timeslice_layer import TimeSliceLayer @@ -42,6 +43,13 @@ from tasks.workflow_cfs_scheduler.entities import AsyncWorkflowQueue, AsyncWorkf logger = logging.getLogger(__name__) +class WorkflowGeneratorArgsDict(TypedDict): + inputs: dict[str, Any] + files: list[Any] + _skip_prepare_user_inputs: bool + workflow_id: NotRequired[str] + + @shared_task(queue=AsyncWorkflowQueue.PROFESSIONAL_QUEUE) def execute_workflow_professional(task_data_dict: dict[str, Any]): """Execute workflow for professional tier with highest priority""" @@ -90,15 +98,13 @@ def execute_workflow_sandbox(task_data_dict: dict[str, Any]): ) -def _build_generator_args(trigger_data: TriggerData) -> dict[str, Any]: +def _build_generator_args(trigger_data: TriggerData) -> WorkflowGeneratorArgsDict: """Build args passed into WorkflowAppGenerator.generate for Celery executions.""" - - args: dict[str, Any] = { + return { "inputs": dict(trigger_data.inputs), "files": list(trigger_data.files), - SKIP_PREPARE_USER_INPUTS_KEY: True, + "_skip_prepare_user_inputs": True, } - return args def _execute_workflow_common( @@ -156,7 +162,12 @@ def _execute_workflow_common( state_owner_user_id=workflow.created_by, ) - # Execute the workflow with the trigger type + # NOTE (hj24) + # Release the transaction before the blocking generate() call, + # otherwise the connection stays "idle in transaction" for hours. + session.commit() + # NOTE END + generator.generate( app_model=app_model, workflow=workflow, diff --git a/api/tasks/batch_clean_document_task.py b/api/tasks/batch_clean_document_task.py index 66aafc30b9..56c371fcc1 100644 --- a/api/tasks/batch_clean_document_task.py +++ b/api/tasks/batch_clean_document_task.py @@ -1,9 +1,11 @@ import logging import time +from typing import cast import click from celery import shared_task from sqlalchemy import delete, select +from sqlalchemy.engine import CursorResult from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory @@ -92,14 +94,16 @@ def batch_clean_document_task(document_ids: list[str], dataset_id: str, doc_form # ============ Step 3: Delete metadata binding (separate short transaction) ============ try: with session_factory.create_session() as session: - deleted_count = int( - session.query(DatasetMetadataBinding) - .where( - DatasetMetadataBinding.dataset_id == dataset_id, - DatasetMetadataBinding.document_id.in_(document_ids), - ) - .delete(synchronize_session=False) + result = cast( + CursorResult, + session.execute( + delete(DatasetMetadataBinding).where( + DatasetMetadataBinding.dataset_id == dataset_id, + DatasetMetadataBinding.document_id.in_(document_ids), + ) + ), ) + deleted_count = result.rowcount session.commit() logger.debug("Deleted %d metadata bindings for dataset_id: %s", deleted_count, dataset_id) except Exception: diff --git a/api/tasks/batch_create_segment_to_index_task.py b/api/tasks/batch_create_segment_to_index_task.py index 77feea47a2..4db551c73c 100644 --- a/api/tasks/batch_create_segment_to_index_task.py +++ b/api/tasks/batch_create_segment_to_index_task.py @@ -3,6 +3,7 @@ import tempfile import time import uuid from pathlib import Path +from typing import Any import click import pandas as pd @@ -51,8 +52,8 @@ def batch_create_segment_to_index_task( # Initialize variables with default values upload_file_key: str | None = None - dataset_config: dict | None = None - document_config: dict | None = None + dataset_config: dict[str, Any] | None = None + document_config: dict[str, Any] | None = None with session_factory.create_session() as session: try: diff --git a/api/tasks/clean_dataset_task.py b/api/tasks/clean_dataset_task.py index 0d51a743ad..377d0e5cc7 100644 --- a/api/tasks/clean_dataset_task.py +++ b/api/tasks/clean_dataset_task.py @@ -112,7 +112,9 @@ def clean_dataset_task( segment_ids = [segment.id for segment in segments] for segment in segments: image_upload_file_ids = get_image_upload_file_ids(segment.content) - image_files = session.query(UploadFile).where(UploadFile.id.in_(image_upload_file_ids)).all() + image_files = session.scalars( + select(UploadFile).where(UploadFile.id.in_(image_upload_file_ids)) + ).all() for image_file in image_files: if image_file is None: continue @@ -150,20 +152,22 @@ def clean_dataset_task( ) session.execute(binding_delete_stmt) - session.query(DatasetProcessRule).where(DatasetProcessRule.dataset_id == dataset_id).delete() - session.query(DatasetQuery).where(DatasetQuery.dataset_id == dataset_id).delete() - session.query(AppDatasetJoin).where(AppDatasetJoin.dataset_id == dataset_id).delete() + session.execute(delete(DatasetProcessRule).where(DatasetProcessRule.dataset_id == dataset_id)) + session.execute(delete(DatasetQuery).where(DatasetQuery.dataset_id == dataset_id)) + session.execute(delete(AppDatasetJoin).where(AppDatasetJoin.dataset_id == dataset_id)) # delete dataset metadata - session.query(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset_id).delete() - session.query(DatasetMetadataBinding).where(DatasetMetadataBinding.dataset_id == dataset_id).delete() + session.execute(delete(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset_id)) + session.execute(delete(DatasetMetadataBinding).where(DatasetMetadataBinding.dataset_id == dataset_id)) # delete pipeline and workflow if pipeline_id: - session.query(Pipeline).where(Pipeline.id == pipeline_id).delete() - session.query(Workflow).where( - Workflow.tenant_id == tenant_id, - Workflow.app_id == pipeline_id, - Workflow.type == WorkflowType.RAG_PIPELINE, - ).delete() + session.execute(delete(Pipeline).where(Pipeline.id == pipeline_id)) + session.execute( + delete(Workflow).where( + Workflow.tenant_id == tenant_id, + Workflow.app_id == pipeline_id, + Workflow.type == WorkflowType.RAG_PIPELINE, + ) + ) # delete files if documents: file_ids = [] @@ -174,7 +178,7 @@ def clean_dataset_task( if data_source_info and "upload_file_id" in data_source_info: file_id = data_source_info["upload_file_id"] file_ids.append(file_id) - files = session.query(UploadFile).where(UploadFile.id.in_(file_ids)).all() + files = session.scalars(select(UploadFile).where(UploadFile.id.in_(file_ids))).all() for file in files: storage.delete(file.key) diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py index a017e9114b..a657cd553a 100644 --- a/api/tasks/clean_document_task.py +++ b/api/tasks/clean_document_task.py @@ -32,7 +32,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i with session_factory.create_session() as session: try: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise Exception("Document has no dataset") @@ -63,7 +63,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i if index_node_ids: index_processor = IndexProcessorFactory(doc_form).init_index_processor() with session_factory.create_session() as session: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if dataset: index_processor.clean( dataset, index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True @@ -94,7 +94,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i with session_factory.create_session() as session, session.begin(): if file_id: - file = session.query(UploadFile).where(UploadFile.id == file_id).first() + file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) if file: try: storage.delete(file.key) @@ -124,10 +124,12 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i with session_factory.create_session() as session, session.begin(): # delete dataset metadata binding - session.query(DatasetMetadataBinding).where( - DatasetMetadataBinding.dataset_id == dataset_id, - DatasetMetadataBinding.document_id == document_id, - ).delete() + session.execute( + delete(DatasetMetadataBinding).where( + DatasetMetadataBinding.dataset_id == dataset_id, + DatasetMetadataBinding.document_id == document_id, + ) + ) end_at = time.perf_counter() logger.info( diff --git a/api/tasks/deal_dataset_index_update_task.py b/api/tasks/deal_dataset_index_update_task.py index fa844a8647..c9b5121a08 100644 --- a/api/tasks/deal_dataset_index_update_task.py +++ b/api/tasks/deal_dataset_index_update_task.py @@ -3,6 +3,7 @@ import time import click from celery import shared_task # type: ignore +from sqlalchemy import select, update from core.db.session_factory import session_factory from core.rag.index_processor.constant.doc_type import DocType @@ -26,43 +27,42 @@ def deal_dataset_index_update_task(dataset_id: str, action: str): with session_factory.create_session() as session: try: - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise Exception("Dataset not found") index_type = dataset.doc_form or IndexStructureType.PARAGRAPH_INDEX index_processor = IndexProcessorFactory(index_type).init_index_processor() if action == "upgrade": - dataset_documents = ( - session.query(DatasetDocument) - .where( + dataset_documents = session.scalars( + select(DatasetDocument).where( DatasetDocument.dataset_id == dataset_id, DatasetDocument.indexing_status == "completed", DatasetDocument.enabled == True, DatasetDocument.archived == False, ) - .all() - ) + ).all() if dataset_documents: dataset_documents_ids = [doc.id for doc in dataset_documents] - session.query(DatasetDocument).where(DatasetDocument.id.in_(dataset_documents_ids)).update( - {"indexing_status": "indexing"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id.in_(dataset_documents_ids)) + .values(indexing_status="indexing") ) session.commit() for dataset_document in dataset_documents: try: # add from vector index - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .where( DocumentSegment.document_id == dataset_document.id, DocumentSegment.enabled == True, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() if segments: documents = [] for segment in segments: @@ -81,32 +81,36 @@ def deal_dataset_index_update_task(dataset_id: str, action: str): # clean keywords index_processor.clean(dataset, None, with_keywords=True, delete_child_chunks=False) index_processor.load(dataset, documents, with_keywords=False) - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "completed"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="completed") ) session.commit() except Exception as e: - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "error", "error": str(e)}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="error", error=str(e)) ) session.commit() elif action == "update": - dataset_documents = ( - session.query(DatasetDocument) - .where( + dataset_documents = session.scalars( + select(DatasetDocument).where( DatasetDocument.dataset_id == dataset_id, DatasetDocument.indexing_status == "completed", DatasetDocument.enabled == True, DatasetDocument.archived == False, ) - .all() - ) + ).all() # add new index if dataset_documents: # update document status dataset_documents_ids = [doc.id for doc in dataset_documents] - session.query(DatasetDocument).where(DatasetDocument.id.in_(dataset_documents_ids)).update( - {"indexing_status": "indexing"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id.in_(dataset_documents_ids)) + .values(indexing_status="indexing") ) session.commit() @@ -116,15 +120,14 @@ def deal_dataset_index_update_task(dataset_id: str, action: str): for dataset_document in dataset_documents: # update from vector index try: - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .where( DocumentSegment.document_id == dataset_document.id, DocumentSegment.enabled == True, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() if segments: documents = [] multimodal_documents = [] @@ -173,13 +176,17 @@ def deal_dataset_index_update_task(dataset_id: str, action: str): index_processor.load( dataset, documents, multimodal_documents=multimodal_documents, with_keywords=False ) - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "completed"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="completed") ) session.commit() except Exception as e: - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "error", "error": str(e)}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="error", error=str(e)) ) session.commit() else: diff --git a/api/tasks/deal_dataset_vector_index_task.py b/api/tasks/deal_dataset_vector_index_task.py index 0047e04a17..36605359dc 100644 --- a/api/tasks/deal_dataset_vector_index_task.py +++ b/api/tasks/deal_dataset_vector_index_task.py @@ -3,7 +3,7 @@ import time import click from celery import shared_task -from sqlalchemy import select +from sqlalchemy import select, update from core.db.session_factory import session_factory from core.rag.index_processor.constant.doc_type import DocType @@ -29,7 +29,7 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): with session_factory.create_session() as session: try: - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise Exception("Dataset not found") @@ -49,23 +49,24 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): if dataset_documents: dataset_documents_ids = [doc.id for doc in dataset_documents] - session.query(DatasetDocument).where(DatasetDocument.id.in_(dataset_documents_ids)).update( - {"indexing_status": "indexing"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id.in_(dataset_documents_ids)) + .values(indexing_status="indexing") ) session.commit() for dataset_document in dataset_documents: try: # add from vector index - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .where( DocumentSegment.document_id == dataset_document.id, DocumentSegment.enabled == True, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() if segments: documents = [] for segment in segments: @@ -82,13 +83,17 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): documents.append(document) # save vector index index_processor.load(dataset, documents, with_keywords=False) - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "completed"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="completed") ) session.commit() except Exception as e: - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "error", "error": str(e)}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="error", error=str(e)) ) session.commit() elif action == "update": @@ -104,8 +109,10 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): if dataset_documents: # update document status dataset_documents_ids = [doc.id for doc in dataset_documents] - session.query(DatasetDocument).where(DatasetDocument.id.in_(dataset_documents_ids)).update( - {"indexing_status": "indexing"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id.in_(dataset_documents_ids)) + .values(indexing_status="indexing") ) session.commit() @@ -115,15 +122,14 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): for dataset_document in dataset_documents: # update from vector index try: - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .where( DocumentSegment.document_id == dataset_document.id, DocumentSegment.enabled == True, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() if segments: documents = [] multimodal_documents = [] @@ -172,13 +178,17 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): index_processor.load( dataset, documents, multimodal_documents=multimodal_documents, with_keywords=False ) - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "completed"}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="completed") ) session.commit() except Exception as e: - session.query(DatasetDocument).where(DatasetDocument.id == dataset_document.id).update( - {"indexing_status": "error", "error": str(e)}, synchronize_session=False + session.execute( + update(DatasetDocument) + .where(DatasetDocument.id == dataset_document.id) + .values(indexing_status="error", error=str(e)) ) session.commit() else: diff --git a/api/tasks/delete_conversation_task.py b/api/tasks/delete_conversation_task.py index 9664b8ac73..0b392f6096 100644 --- a/api/tasks/delete_conversation_task.py +++ b/api/tasks/delete_conversation_task.py @@ -3,6 +3,7 @@ import time import click from celery import shared_task +from sqlalchemy import delete from core.db.session_factory import session_factory from models import ConversationVariable @@ -29,29 +30,21 @@ def delete_conversation_related_data(conversation_id: str): with session_factory.create_session() as session: try: - session.query(MessageAnnotation).where(MessageAnnotation.conversation_id == conversation_id).delete( - synchronize_session=False + session.execute(delete(MessageAnnotation).where(MessageAnnotation.conversation_id == conversation_id)) + + session.execute(delete(MessageFeedback).where(MessageFeedback.conversation_id == conversation_id)) + + session.execute( + delete(ToolConversationVariables).where(ToolConversationVariables.conversation_id == conversation_id) ) - session.query(MessageFeedback).where(MessageFeedback.conversation_id == conversation_id).delete( - synchronize_session=False - ) + session.execute(delete(ToolFile).where(ToolFile.conversation_id == conversation_id)) - session.query(ToolConversationVariables).where( - ToolConversationVariables.conversation_id == conversation_id - ).delete(synchronize_session=False) + session.execute(delete(ConversationVariable).where(ConversationVariable.conversation_id == conversation_id)) - session.query(ToolFile).where(ToolFile.conversation_id == conversation_id).delete(synchronize_session=False) + session.execute(delete(Message).where(Message.conversation_id == conversation_id)) - session.query(ConversationVariable).where(ConversationVariable.conversation_id == conversation_id).delete( - synchronize_session=False - ) - - session.query(Message).where(Message.conversation_id == conversation_id).delete(synchronize_session=False) - - session.query(PinnedConversation).where(PinnedConversation.conversation_id == conversation_id).delete( - synchronize_session=False - ) + session.execute(delete(PinnedConversation).where(PinnedConversation.conversation_id == conversation_id)) session.commit() diff --git a/api/tasks/delete_segment_from_index_task.py b/api/tasks/delete_segment_from_index_task.py index a6a2dcebc8..306a23aeda 100644 --- a/api/tasks/delete_segment_from_index_task.py +++ b/api/tasks/delete_segment_from_index_task.py @@ -3,7 +3,7 @@ import time import click from celery import shared_task -from sqlalchemy import delete +from sqlalchemy import delete, select from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory @@ -29,12 +29,12 @@ def delete_segment_from_index_task( start_at = time.perf_counter() with session_factory.create_session() as session: try: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: logging.warning("Dataset %s not found, skipping index cleanup", dataset_id) return - dataset_document = session.query(Document).where(Document.id == document_id).first() + dataset_document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if not dataset_document: return @@ -60,11 +60,9 @@ def delete_segment_from_index_task( ) if dataset.is_multimodal: # delete segment attachment binding - segment_attachment_bindings = ( - session.query(SegmentAttachmentBinding) - .where(SegmentAttachmentBinding.segment_id.in_(segment_ids)) - .all() - ) + segment_attachment_bindings = session.scalars( + select(SegmentAttachmentBinding).where(SegmentAttachmentBinding.segment_id.in_(segment_ids)) + ).all() if segment_attachment_bindings: attachment_ids = [binding.attachment_id for binding in segment_attachment_bindings] index_processor.clean(dataset=dataset, node_ids=attachment_ids, with_keywords=False) @@ -77,7 +75,7 @@ def delete_segment_from_index_task( session.execute(segment_attachment_bind_delete_stmt) # delete upload file - session.query(UploadFile).where(UploadFile.id.in_(attachment_ids)).delete(synchronize_session=False) + session.execute(delete(UploadFile).where(UploadFile.id.in_(attachment_ids))) session.commit() end_at = time.perf_counter() diff --git a/api/tasks/disable_segments_from_index_task.py b/api/tasks/disable_segments_from_index_task.py index 3cc267e821..86e96ea3f0 100644 --- a/api/tasks/disable_segments_from_index_task.py +++ b/api/tasks/disable_segments_from_index_task.py @@ -3,7 +3,7 @@ import time import click from celery import shared_task -from sqlalchemy import select +from sqlalchemy import select, update from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory @@ -27,12 +27,12 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen start_at = time.perf_counter() with session_factory.create_session() as session: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: logger.info(click.style(f"Dataset {dataset_id} not found, pass.", fg="cyan")) return - dataset_document = session.query(DatasetDocument).where(DatasetDocument.id == document_id).first() + dataset_document = session.scalar(select(DatasetDocument).where(DatasetDocument.id == document_id).limit(1)) if not dataset_document: logger.info(click.style(f"Document {document_id} not found, pass.", fg="cyan")) @@ -58,11 +58,9 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen index_node_ids = [segment.index_node_id for segment in segments] if dataset.is_multimodal: segment_ids = [segment.id for segment in segments] - segment_attachment_bindings = ( - session.query(SegmentAttachmentBinding) - .where(SegmentAttachmentBinding.segment_id.in_(segment_ids)) - .all() - ) + segment_attachment_bindings = session.scalars( + select(SegmentAttachmentBinding).where(SegmentAttachmentBinding.segment_id.in_(segment_ids)) + ).all() if segment_attachment_bindings: attachment_ids = [binding.attachment_id for binding in segment_attachment_bindings] index_node_ids.extend(attachment_ids) @@ -87,16 +85,14 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen logger.info(click.style(f"Segments removed from index latency: {end_at - start_at}", fg="green")) except Exception: # update segment error msg - session.query(DocumentSegment).where( - DocumentSegment.id.in_(segment_ids), - DocumentSegment.dataset_id == dataset_id, - DocumentSegment.document_id == document_id, - ).update( - { - "disabled_at": None, - "disabled_by": None, - "enabled": True, - } + session.execute( + update(DocumentSegment) + .where( + DocumentSegment.id.in_(segment_ids), + DocumentSegment.dataset_id == dataset_id, + DocumentSegment.document_id == document_id, + ) + .values(disabled_at=None, disabled_by=None, enabled=True) ) session.commit() finally: diff --git a/api/tasks/document_indexing_sync_task.py b/api/tasks/document_indexing_sync_task.py index f99e90062f..90c80be3a1 100644 --- a/api/tasks/document_indexing_sync_task.py +++ b/api/tasks/document_indexing_sync_task.py @@ -32,7 +32,9 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): tenant_id = None with session_factory.create_session() as session, session.begin(): - document = session.query(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).first() + document = session.scalar( + select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1) + ) if not document: logger.info(click.style(f"Document not found: {document_id}", fg="red")) @@ -42,7 +44,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): logger.info(click.style(f"Document {document_id} is already being processed, skipping", fg="yellow")) return - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: raise Exception("Dataset not found") @@ -87,7 +89,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): ) with session_factory.create_session() as session, session.begin(): - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if document: document.indexing_status = IndexingStatus.ERROR document.error = "Datasource credential not found. Please reconnect your Notion workspace." @@ -112,7 +114,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): try: index_processor = IndexProcessorFactory(index_type).init_index_processor() with session_factory.create_session() as session: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if dataset: index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True) logger.info(click.style(f"Cleaned vector index for document {document_id}", fg="green")) @@ -120,7 +122,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): logger.exception("Failed to clean vector index for document %s", document_id) with session_factory.create_session() as session, session.begin(): - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if not document: logger.warning(click.style(f"Document {document_id} not found during sync", fg="yellow")) return @@ -140,7 +142,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): try: indexing_runner = IndexingRunner() with session_factory.create_session() as session: - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if document: indexing_runner.run([document]) end_at = time.perf_counter() @@ -150,7 +152,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): except Exception as e: logger.exception("document_indexing_sync_task failed for document_id: %s", document_id) with session_factory.create_session() as session, session.begin(): - document = session.query(Document).filter_by(id=document_id).first() + document = session.scalar(select(Document).where(Document.id == document_id).limit(1)) if document: document.indexing_status = IndexingStatus.ERROR document.error = str(e) diff --git a/api/tasks/document_indexing_task.py b/api/tasks/document_indexing_task.py index 23a80fa106..31dad7937c 100644 --- a/api/tasks/document_indexing_task.py +++ b/api/tasks/document_indexing_task.py @@ -5,6 +5,7 @@ from typing import Any, Protocol import click from celery import current_app, shared_task +from sqlalchemy import select from configs import dify_config from core.db.session_factory import session_factory @@ -53,11 +54,10 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]): Usage: _document_indexing(dataset_id, document_ids) """ - documents = [] start_at = time.perf_counter() with session_factory.create_session() as session: - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: logger.info(click.style(f"Dataset is not found: {dataset_id}", fg="yellow")) return @@ -79,8 +79,8 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]): ) except Exception as e: for document_id in document_ids: - document = ( - session.query(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).first() + document = session.scalar( + select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1) ) if document: document.indexing_status = IndexingStatus.ERROR @@ -92,8 +92,10 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]): # Phase 1: Update status to parsing (short transaction) with session_factory.create_session() as session, session.begin(): - documents = ( - session.query(Document).where(Document.id.in_(document_ids), Document.dataset_id == dataset_id).all() + documents: list[Document] = list( + session.scalars( + select(Document).where(Document.id.in_(document_ids), Document.dataset_id == dataset_id) + ).all() ) for document in documents: @@ -122,7 +124,7 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]): # Trigger summary index generation for completed documents if enabled # Only generate for high_quality indexing technique and when summary_index_setting is enabled # Re-query dataset to get latest summary_index_setting (in case it was updated) - dataset = session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: logger.warning("Dataset %s not found after indexing", dataset_id) return @@ -134,10 +136,10 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]): session.expire_all() # Check each document's indexing status and trigger summary generation if completed - documents = ( - session.query(Document) - .where(Document.id.in_(document_ids), Document.dataset_id == dataset_id) - .all() + documents = list( + session.scalars( + select(Document).where(Document.id.in_(document_ids), Document.dataset_id == dataset_id) + ).all() ) for document in documents: diff --git a/api/tasks/regenerate_summary_index_task.py b/api/tasks/regenerate_summary_index_task.py index 6f490ab7ea..e794195c92 100644 --- a/api/tasks/regenerate_summary_index_task.py +++ b/api/tasks/regenerate_summary_index_task.py @@ -47,7 +47,7 @@ def regenerate_summary_index_task( try: with session_factory.create_session() as session: - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) if not dataset: logger.error(click.style(f"Dataset not found: {dataset_id}", fg="red")) return @@ -84,8 +84,8 @@ def regenerate_summary_index_task( # For embedding_model change: directly query all segments with existing summaries # Don't require document indexing_status == "completed" # Include summaries with status "completed" or "error" (if they have content) - segments_with_summaries = ( - session.query(DocumentSegment, DocumentSegmentSummary) + segments_with_summaries = session.execute( + select(DocumentSegment, DocumentSegmentSummary) .join( DocumentSegmentSummary, DocumentSegment.id == DocumentSegmentSummary.chunk_id, @@ -110,8 +110,7 @@ def regenerate_summary_index_task( DatasetDocument.doc_form != IndexStructureType.QA_INDEX, # Skip qa_model documents ) .order_by(DocumentSegment.document_id.asc(), DocumentSegment.position.asc()) - .all() - ) + ).all() if not segments_with_summaries: logger.info( @@ -215,8 +214,8 @@ def regenerate_summary_index_task( try: # Get all segments with existing summaries - segments = ( - session.query(DocumentSegment) + segments = session.scalars( + select(DocumentSegment) .join( DocumentSegmentSummary, DocumentSegment.id == DocumentSegmentSummary.chunk_id, @@ -229,8 +228,7 @@ def regenerate_summary_index_task( DocumentSegmentSummary.dataset_id == dataset_id, ) .order_by(DocumentSegment.position.asc()) - .all() - ) + ).all() if not segments: continue @@ -245,13 +243,13 @@ def regenerate_summary_index_task( summary_record = None try: # Get existing summary record - summary_record = ( - session.query(DocumentSegmentSummary) - .filter_by( - chunk_id=segment.id, - dataset_id=dataset_id, + summary_record = session.scalar( + select(DocumentSegmentSummary) + .where( + DocumentSegmentSummary.chunk_id == segment.id, + DocumentSegmentSummary.dataset_id == dataset_id, ) - .first() + .limit(1) ) if not summary_record: diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index b1840662ff..5f1f0952af 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -6,7 +6,7 @@ from typing import Any, cast import click import sqlalchemy as sa from celery import shared_task -from sqlalchemy import delete +from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import sessionmaker @@ -99,7 +99,11 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): def _delete_app_model_configs(tenant_id: str, app_id: str): def del_model_config(session, model_config_id: str): - session.query(AppModelConfig).where(AppModelConfig.id == model_config_id).delete(synchronize_session=False) + session.execute( + delete(AppModelConfig) + .where(AppModelConfig.id == model_config_id) + .execution_options(synchronize_session=False) + ) _delete_records( """select id from app_model_configs where app_id=:app_id limit 1000""", @@ -111,7 +115,7 @@ def _delete_app_model_configs(tenant_id: str, app_id: str): def _delete_app_site(tenant_id: str, app_id: str): def del_site(session, site_id: str): - session.query(Site).where(Site.id == site_id).delete(synchronize_session=False) + session.execute(delete(Site).where(Site.id == site_id).execution_options(synchronize_session=False)) _delete_records( """select id from sites where app_id=:app_id limit 1000""", @@ -123,7 +127,9 @@ def _delete_app_site(tenant_id: str, app_id: str): def _delete_app_mcp_servers(tenant_id: str, app_id: str): def del_mcp_server(session, mcp_server_id: str): - session.query(AppMCPServer).where(AppMCPServer.id == mcp_server_id).delete(synchronize_session=False) + session.execute( + delete(AppMCPServer).where(AppMCPServer.id == mcp_server_id).execution_options(synchronize_session=False) + ) _delete_records( """select id from app_mcp_servers where app_id=:app_id limit 1000""", @@ -136,12 +142,14 @@ def _delete_app_mcp_servers(tenant_id: str, app_id: str): def _delete_app_api_tokens(tenant_id: str, app_id: str): def del_api_token(session, api_token_id: str): # Fetch token details for cache invalidation - token_obj = session.query(ApiToken).where(ApiToken.id == api_token_id).first() + token_obj = session.scalar(select(ApiToken).where(ApiToken.id == api_token_id).limit(1)) if token_obj: # Invalidate cache before deletion ApiTokenCache.delete(token_obj.token, token_obj.type) - session.query(ApiToken).where(ApiToken.id == api_token_id).delete(synchronize_session=False) + session.execute( + delete(ApiToken).where(ApiToken.id == api_token_id).execution_options(synchronize_session=False) + ) _delete_records( """select id from api_tokens where app_id=:app_id limit 1000""", @@ -153,7 +161,9 @@ def _delete_app_api_tokens(tenant_id: str, app_id: str): def _delete_installed_apps(tenant_id: str, app_id: str): def del_installed_app(session, installed_app_id: str): - session.query(InstalledApp).where(InstalledApp.id == installed_app_id).delete(synchronize_session=False) + session.execute( + delete(InstalledApp).where(InstalledApp.id == installed_app_id).execution_options(synchronize_session=False) + ) _delete_records( """select id from installed_apps where tenant_id=:tenant_id and app_id=:app_id limit 1000""", @@ -165,7 +175,11 @@ def _delete_installed_apps(tenant_id: str, app_id: str): def _delete_recommended_apps(tenant_id: str, app_id: str): def del_recommended_app(session, recommended_app_id: str): - session.query(RecommendedApp).where(RecommendedApp.id == recommended_app_id).delete(synchronize_session=False) + session.execute( + delete(RecommendedApp) + .where(RecommendedApp.id == recommended_app_id) + .execution_options(synchronize_session=False) + ) _delete_records( """select id from recommended_apps where app_id=:app_id limit 1000""", @@ -177,8 +191,10 @@ def _delete_recommended_apps(tenant_id: str, app_id: str): def _delete_app_annotation_data(tenant_id: str, app_id: str): def del_annotation_hit_history(session, annotation_hit_history_id: str): - session.query(AppAnnotationHitHistory).where(AppAnnotationHitHistory.id == annotation_hit_history_id).delete( - synchronize_session=False + session.execute( + delete(AppAnnotationHitHistory) + .where(AppAnnotationHitHistory.id == annotation_hit_history_id) + .execution_options(synchronize_session=False) ) _delete_records( @@ -189,8 +205,10 @@ def _delete_app_annotation_data(tenant_id: str, app_id: str): ) def del_annotation_setting(session, annotation_setting_id: str): - session.query(AppAnnotationSetting).where(AppAnnotationSetting.id == annotation_setting_id).delete( - synchronize_session=False + session.execute( + delete(AppAnnotationSetting) + .where(AppAnnotationSetting.id == annotation_setting_id) + .execution_options(synchronize_session=False) ) _delete_records( @@ -203,7 +221,11 @@ def _delete_app_annotation_data(tenant_id: str, app_id: str): def _delete_app_dataset_joins(tenant_id: str, app_id: str): def del_dataset_join(session, dataset_join_id: str): - session.query(AppDatasetJoin).where(AppDatasetJoin.id == dataset_join_id).delete(synchronize_session=False) + session.execute( + delete(AppDatasetJoin) + .where(AppDatasetJoin.id == dataset_join_id) + .execution_options(synchronize_session=False) + ) _delete_records( """select id from app_dataset_joins where app_id=:app_id limit 1000""", @@ -215,7 +237,7 @@ def _delete_app_dataset_joins(tenant_id: str, app_id: str): def _delete_app_workflows(tenant_id: str, app_id: str): def del_workflow(session, workflow_id: str): - session.query(Workflow).where(Workflow.id == workflow_id).delete(synchronize_session=False) + session.execute(delete(Workflow).where(Workflow.id == workflow_id).execution_options(synchronize_session=False)) _delete_records( """select id from workflows where tenant_id=:tenant_id and app_id=:app_id limit 1000""", @@ -255,7 +277,11 @@ def _delete_app_workflow_node_executions(tenant_id: str, app_id: str): def _delete_app_workflow_app_logs(tenant_id: str, app_id: str): def del_workflow_app_log(session, workflow_app_log_id: str): - session.query(WorkflowAppLog).where(WorkflowAppLog.id == workflow_app_log_id).delete(synchronize_session=False) + session.execute( + delete(WorkflowAppLog) + .where(WorkflowAppLog.id == workflow_app_log_id) + .execution_options(synchronize_session=False) + ) _delete_records( """select id from workflow_app_logs where tenant_id=:tenant_id and app_id=:app_id limit 1000""", @@ -267,8 +293,10 @@ def _delete_app_workflow_app_logs(tenant_id: str, app_id: str): def _delete_app_workflow_archive_logs(tenant_id: str, app_id: str): def del_workflow_archive_log(session, workflow_archive_log_id: str): - session.query(WorkflowArchiveLog).where(WorkflowArchiveLog.id == workflow_archive_log_id).delete( - synchronize_session=False + session.execute( + delete(WorkflowArchiveLog) + .where(WorkflowArchiveLog.id == workflow_archive_log_id) + .execution_options(synchronize_session=False) ) _delete_records( @@ -306,10 +334,14 @@ def _delete_archived_workflow_run_files(tenant_id: str, app_id: str): def _delete_app_conversations(tenant_id: str, app_id: str): def del_conversation(session, conversation_id: str): - session.query(PinnedConversation).where(PinnedConversation.conversation_id == conversation_id).delete( - synchronize_session=False + session.execute( + delete(PinnedConversation) + .where(PinnedConversation.conversation_id == conversation_id) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(Conversation).where(Conversation.id == conversation_id).execution_options(synchronize_session=False) ) - session.query(Conversation).where(Conversation.id == conversation_id).delete(synchronize_session=False) _delete_records( """select id from conversations where app_id=:app_id limit 1000""", @@ -329,17 +361,35 @@ def _delete_conversation_variables(*, app_id: str): def _delete_app_messages(tenant_id: str, app_id: str): def del_message(session, message_id: str): - session.query(MessageFeedback).where(MessageFeedback.message_id == message_id).delete(synchronize_session=False) - session.query(MessageAnnotation).where(MessageAnnotation.message_id == message_id).delete( - synchronize_session=False + session.execute( + delete(MessageFeedback) + .where(MessageFeedback.message_id == message_id) + .execution_options(synchronize_session=False) ) - session.query(MessageChain).where(MessageChain.message_id == message_id).delete(synchronize_session=False) - session.query(MessageAgentThought).where(MessageAgentThought.message_id == message_id).delete( - synchronize_session=False + session.execute( + delete(MessageAnnotation) + .where(MessageAnnotation.message_id == message_id) + .execution_options(synchronize_session=False) ) - session.query(MessageFile).where(MessageFile.message_id == message_id).delete(synchronize_session=False) - session.query(SavedMessage).where(SavedMessage.message_id == message_id).delete(synchronize_session=False) - session.query(Message).where(Message.id == message_id).delete() + session.execute( + delete(MessageChain) + .where(MessageChain.message_id == message_id) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(MessageAgentThought) + .where(MessageAgentThought.message_id == message_id) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(MessageFile).where(MessageFile.message_id == message_id).execution_options(synchronize_session=False) + ) + session.execute( + delete(SavedMessage) + .where(SavedMessage.message_id == message_id) + .execution_options(synchronize_session=False) + ) + session.execute(delete(Message).where(Message.id == message_id).execution_options(synchronize_session=False)) _delete_records( """select id from messages where app_id=:app_id limit 1000""", @@ -351,8 +401,10 @@ def _delete_app_messages(tenant_id: str, app_id: str): def _delete_workflow_tool_providers(tenant_id: str, app_id: str): def del_tool_provider(session, tool_provider_id: str): - session.query(WorkflowToolProvider).where(WorkflowToolProvider.id == tool_provider_id).delete( - synchronize_session=False + session.execute( + delete(WorkflowToolProvider) + .where(WorkflowToolProvider.id == tool_provider_id) + .execution_options(synchronize_session=False) ) _delete_records( @@ -365,7 +417,9 @@ def _delete_workflow_tool_providers(tenant_id: str, app_id: str): def _delete_app_tag_bindings(tenant_id: str, app_id: str): def del_tag_binding(session, tag_binding_id: str): - session.query(TagBinding).where(TagBinding.id == tag_binding_id).delete(synchronize_session=False) + session.execute( + delete(TagBinding).where(TagBinding.id == tag_binding_id).execution_options(synchronize_session=False) + ) _delete_records( """select id from tag_bindings where tenant_id=:tenant_id and target_id=:app_id limit 1000""", @@ -377,7 +431,7 @@ def _delete_app_tag_bindings(tenant_id: str, app_id: str): def _delete_end_users(tenant_id: str, app_id: str): def del_end_user(session, end_user_id: str): - session.query(EndUser).where(EndUser.id == end_user_id).delete(synchronize_session=False) + session.execute(delete(EndUser).where(EndUser.id == end_user_id).execution_options(synchronize_session=False)) _delete_records( """select id from end_users where tenant_id=:tenant_id and app_id=:app_id limit 1000""", @@ -389,7 +443,11 @@ def _delete_end_users(tenant_id: str, app_id: str): def _delete_trace_app_configs(tenant_id: str, app_id: str): def del_trace_app_config(session, trace_app_config_id: str): - session.query(TraceAppConfig).where(TraceAppConfig.id == trace_app_config_id).delete(synchronize_session=False) + session.execute( + delete(TraceAppConfig) + .where(TraceAppConfig.id == trace_app_config_id) + .execution_options(synchronize_session=False) + ) _delete_records( """select id from trace_app_config where app_id=:app_id limit 1000""", @@ -545,7 +603,9 @@ def _delete_draft_variable_offload_data(session, file_ids: list[str]) -> int: def _delete_app_triggers(tenant_id: str, app_id: str): def del_app_trigger(session, trigger_id: str): - session.query(AppTrigger).where(AppTrigger.id == trigger_id).delete(synchronize_session=False) + session.execute( + delete(AppTrigger).where(AppTrigger.id == trigger_id).execution_options(synchronize_session=False) + ) _delete_records( """select id from app_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", @@ -557,8 +617,10 @@ def _delete_app_triggers(tenant_id: str, app_id: str): def _delete_workflow_plugin_triggers(tenant_id: str, app_id: str): def del_plugin_trigger(session, trigger_id: str): - session.query(WorkflowPluginTrigger).where(WorkflowPluginTrigger.id == trigger_id).delete( - synchronize_session=False + session.execute( + delete(WorkflowPluginTrigger) + .where(WorkflowPluginTrigger.id == trigger_id) + .execution_options(synchronize_session=False) ) _delete_records( @@ -571,8 +633,10 @@ def _delete_workflow_plugin_triggers(tenant_id: str, app_id: str): def _delete_workflow_webhook_triggers(tenant_id: str, app_id: str): def del_webhook_trigger(session, trigger_id: str): - session.query(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.id == trigger_id).delete( - synchronize_session=False + session.execute( + delete(WorkflowWebhookTrigger) + .where(WorkflowWebhookTrigger.id == trigger_id) + .execution_options(synchronize_session=False) ) _delete_records( @@ -585,7 +649,11 @@ def _delete_workflow_webhook_triggers(tenant_id: str, app_id: str): def _delete_workflow_schedule_plans(tenant_id: str, app_id: str): def del_schedule_plan(session, plan_id: str): - session.query(WorkflowSchedulePlan).where(WorkflowSchedulePlan.id == plan_id).delete(synchronize_session=False) + session.execute( + delete(WorkflowSchedulePlan) + .where(WorkflowSchedulePlan.id == plan_id) + .execution_options(synchronize_session=False) + ) _delete_records( """select id from workflow_schedule_plans where tenant_id=:tenant_id and app_id=:app_id limit 1000""", @@ -597,7 +665,11 @@ def _delete_workflow_schedule_plans(tenant_id: str, app_id: str): def _delete_workflow_trigger_logs(tenant_id: str, app_id: str): def del_trigger_log(session, log_id: str): - session.query(WorkflowTriggerLog).where(WorkflowTriggerLog.id == log_id).delete(synchronize_session=False) + session.execute( + delete(WorkflowTriggerLog) + .where(WorkflowTriggerLog.id == log_id) + .execution_options(synchronize_session=False) + ) _delete_records( """select id from workflow_trigger_logs where tenant_id=:tenant_id and app_id=:app_id limit 1000""", @@ -607,7 +679,7 @@ def _delete_workflow_trigger_logs(tenant_id: str, app_id: str): ) -def _delete_records(query_sql: str, params: dict, delete_func: Callable, name: str) -> None: +def _delete_records(query_sql: str, params: dict[str, Any], delete_func: Callable, name: str) -> None: while True: with session_factory.create_session() as session: rs = session.execute(sa.text(query_sql), params) diff --git a/api/tasks/workflow_execution_tasks.py b/api/tasks/workflow_execution_tasks.py index 0c7f74c180..b4f975f4da 100644 --- a/api/tasks/workflow_execution_tasks.py +++ b/api/tasks/workflow_execution_tasks.py @@ -7,6 +7,7 @@ improving performance by offloading storage operations to background workers. import json import logging +from typing import Any from celery import shared_task from graphon.entities import WorkflowExecution @@ -23,7 +24,7 @@ logger = logging.getLogger(__name__) @shared_task(queue="workflow_storage", bind=True, max_retries=3, default_retry_delay=60) def save_workflow_execution_task( self, - execution_data: dict, + execution_data: dict[str, Any], tenant_id: str, app_id: str, triggered_from: str, diff --git a/api/tasks/workflow_node_execution_tasks.py b/api/tasks/workflow_node_execution_tasks.py index f25ebe3bae..128cdd72e1 100644 --- a/api/tasks/workflow_node_execution_tasks.py +++ b/api/tasks/workflow_node_execution_tasks.py @@ -7,6 +7,7 @@ improving performance by offloading storage operations to background workers. import json import logging +from typing import Any from celery import shared_task from graphon.entities.workflow_node_execution import ( @@ -25,7 +26,7 @@ logger = logging.getLogger(__name__) @shared_task(queue="workflow_storage", bind=True, max_retries=3, default_retry_delay=60) def save_workflow_node_execution_task( self, - execution_data: dict, + execution_data: dict[str, Any], tenant_id: str, app_id: str, triggered_from: str, diff --git a/api/tests/__init__.py b/api/tests/__init__.py index e69de29bb2..ced6188ce8 100644 --- a/api/tests/__init__.py +++ b/api/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite root package (enables ``import tests.integration_tests...`` with ``pythonpath = .``).""" diff --git a/api/tests/integration_tests/__init__.py b/api/tests/integration_tests/__init__.py index e69de29bb2..c66cd71b7e 100644 --- a/api/tests/integration_tests/__init__.py +++ b/api/tests/integration_tests/__init__.py @@ -0,0 +1 @@ +"""Integration tests package.""" diff --git a/api/tests/integration_tests/conftest.py b/api/tests/integration_tests/conftest.py index 44adadeaa5..b2e8dda443 100644 --- a/api/tests/integration_tests/conftest.py +++ b/api/tests/integration_tests/conftest.py @@ -8,6 +8,7 @@ from collections.abc import Generator import pytest from flask import Flask from flask.testing import FlaskClient +from sqlalchemy import delete, select from sqlalchemy.orm import Session from app_factory import create_app @@ -83,15 +84,15 @@ def setup_account(request) -> Generator[Account, None, None]: with _CACHED_APP.test_request_context(): with Session(bind=db.engine, expire_on_commit=False) as session: - account = session.query(Account).filter_by(email=email).one() + account = session.scalars(select(Account).filter_by(email=email)).one() yield account with _CACHED_APP.test_request_context(): - db.session.query(DifySetup).delete() - db.session.query(TenantAccountJoin).delete() - db.session.query(Account).delete() - db.session.query(Tenant).delete() + db.session.execute(delete(DifySetup)) + db.session.execute(delete(TenantAccountJoin)) + db.session.execute(delete(Account)) + db.session.execute(delete(Tenant)) db.session.commit() diff --git a/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py b/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py index 951a5ab4b4..0a19debc39 100644 --- a/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py +++ b/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py @@ -1,5 +1,5 @@ import pytest -from sqlalchemy import delete +from sqlalchemy import delete, func, select from core.db.session_factory import session_factory from models import Tenant @@ -61,7 +61,11 @@ class TestPluginPermissionLifecycle: assert perm.debug_permission == TenantPluginPermission.DebugPermission.ADMINS with session_factory.create_session() as session: - count = session.query(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant).count() + count = session.scalar( + select(func.count()) + .select_from(TenantPluginPermission) + .where(TenantPluginPermission.tenant_id == tenant) + ) assert count == 1 diff --git a/api/tests/integration_tests/services/retention/test_messages_clean_service.py b/api/tests/integration_tests/services/retention/test_messages_clean_service.py index 348bb0af4a..352960bcc2 100644 --- a/api/tests/integration_tests/services/retention/test_messages_clean_service.py +++ b/api/tests/integration_tests/services/retention/test_messages_clean_service.py @@ -3,7 +3,7 @@ import math import uuid import pytest -from sqlalchemy import delete +from sqlalchemy import delete, func, select from core.db.session_factory import session_factory from models import Tenant @@ -210,7 +210,7 @@ class TestMessagesCleanServiceIntegration: assert stats["total_deleted"] == 0 with session_factory.create_session() as session: - remaining = session.query(Message).where(Message.id.in_(all_ids)).count() + remaining = session.scalar(select(func.count()).select_from(Message).where(Message.id.in_(all_ids))) assert remaining == len(all_ids) def test_billing_disabled_deletes_all_in_range(self, seed_messages): @@ -231,7 +231,7 @@ class TestMessagesCleanServiceIntegration: assert stats["total_deleted"] == len(all_ids) with session_factory.create_session() as session: - remaining = session.query(Message).where(Message.id.in_(all_ids)).count() + remaining = session.scalar(select(func.count()).select_from(Message).where(Message.id.in_(all_ids))) assert remaining == 0 def test_start_from_filters_correctly(self, seed_messages): @@ -254,7 +254,7 @@ class TestMessagesCleanServiceIntegration: with session_factory.create_session() as session: all_ids = list(msg_ids.values()) - remaining_ids = {r[0] for r in session.query(Message.id).where(Message.id.in_(all_ids)).all()} + remaining_ids = set(session.scalars(select(Message.id).where(Message.id.in_(all_ids))).all()) assert msg_ids["old"] not in remaining_ids assert msg_ids["very_old"] in remaining_ids @@ -282,7 +282,7 @@ class TestMessagesCleanServiceIntegration: assert stats["batches"] >= expected_batches with session_factory.create_session() as session: - remaining = session.query(Message).where(Message.id.in_(msg_ids)).count() + remaining = session.scalar(select(func.count()).select_from(Message).where(Message.id.in_(msg_ids))) assert remaining == 0 def test_no_messages_in_range_returns_empty_stats(self, seed_messages): @@ -319,9 +319,17 @@ class TestMessagesCleanServiceIntegration: assert stats["total_deleted"] == 1 with session_factory.create_session() as session: - assert session.query(Message).where(Message.id == msg_id).count() == 0 - assert session.query(MessageFeedback).where(MessageFeedback.id == fb_id).count() == 0 - assert session.query(MessageAnnotation).where(MessageAnnotation.id == ann_id).count() == 0 + assert session.scalar(select(func.count()).select_from(Message).where(Message.id == msg_id)) == 0 + assert ( + session.scalar(select(func.count()).select_from(MessageFeedback).where(MessageFeedback.id == fb_id)) + == 0 + ) + assert ( + session.scalar( + select(func.count()).select_from(MessageAnnotation).where(MessageAnnotation.id == ann_id) + ) + == 0 + ) def test_factory_from_time_range_validation(self): with pytest.raises(ValueError, match="start_from"): diff --git a/api/tests/integration_tests/services/test_workflow_draft_variable_service.py b/api/tests/integration_tests/services/test_workflow_draft_variable_service.py index 5c6636f31e..c7bb90f019 100644 --- a/api/tests/integration_tests/services/test_workflow_draft_variable_service.py +++ b/api/tests/integration_tests/services/test_workflow_draft_variable_service.py @@ -7,7 +7,7 @@ from graphon.nodes import BuiltinNodeTypes from graphon.variables.segments import StringSegment from graphon.variables.types import SegmentType from graphon.variables.variables import StringVariable -from sqlalchemy import delete +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID @@ -38,21 +38,25 @@ class TestWorkflowDraftVariableService(unittest.TestCase): def setUp(self): self._test_app_id = str(uuid.uuid4()) + self._test_user_id = str(uuid.uuid4()) self._session: Session = db.session() sys_var = WorkflowDraftVariable.new_sys_variable( app_id=self._test_app_id, + user_id=self._test_user_id, name="sys_var", value=build_segment("sys_value"), node_execution_id=self._node_exec_id, ) conv_var = WorkflowDraftVariable.new_conversation_variable( app_id=self._test_app_id, + user_id=self._test_user_id, name="conv_var", value=build_segment("conv_value"), ) node2_vars = [ WorkflowDraftVariable.new_node_variable( app_id=self._test_app_id, + user_id=self._test_user_id, node_id=self._node2_id, name="int_var", value=build_segment(1), @@ -61,6 +65,7 @@ class TestWorkflowDraftVariableService(unittest.TestCase): ), WorkflowDraftVariable.new_node_variable( app_id=self._test_app_id, + user_id=self._test_user_id, node_id=self._node2_id, name="str_var", value=build_segment("str_value"), @@ -70,6 +75,7 @@ class TestWorkflowDraftVariableService(unittest.TestCase): ] node1_var = WorkflowDraftVariable.new_node_variable( app_id=self._test_app_id, + user_id=self._test_user_id, node_id=self._node1_id, name="str_var", value=build_segment("str_value"), @@ -141,24 +147,27 @@ class TestWorkflowDraftVariableService(unittest.TestCase): def test_delete_node_variables(self): srv = self._get_test_srv() srv.delete_node_variables(self._test_app_id, self._node2_id, user_id=self._test_user_id) - node2_var_count = ( - self._session.query(WorkflowDraftVariable) + node2_var_count = self._session.scalar( + select(func.count()) + .select_from(WorkflowDraftVariable) .where( WorkflowDraftVariable.app_id == self._test_app_id, WorkflowDraftVariable.node_id == self._node2_id, + WorkflowDraftVariable.user_id == self._test_user_id, ) - .count() ) assert node2_var_count == 0 def test_delete_variable(self): srv = self._get_test_srv() - node_1_var = ( - self._session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.id == self._node1_str_var_id).one() - ) + node_1_var = self._session.scalars( + select(WorkflowDraftVariable).where(WorkflowDraftVariable.id == self._node1_str_var_id) + ).one() srv.delete_variable(node_1_var) exists = bool( - self._session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.id == self._node1_str_var_id).first() + self._session.scalars( + select(WorkflowDraftVariable).where(WorkflowDraftVariable.id == self._node1_str_var_id) + ).first() ) assert exists is False @@ -248,9 +257,7 @@ class TestDraftVariableLoader(unittest.TestCase): def tearDown(self): with Session(bind=db.engine, expire_on_commit=False) as session: - session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.app_id == self._test_app_id).delete( - synchronize_session=False - ) + session.execute(delete(WorkflowDraftVariable).where(WorkflowDraftVariable.app_id == self._test_app_id)) session.commit() def test_variable_loader_with_empty_selector(self): @@ -431,9 +438,11 @@ class TestDraftVariableLoader(unittest.TestCase): # Clean up with Session(bind=db.engine) as session: # Query and delete by ID to ensure they're tracked in this session - session.query(WorkflowDraftVariable).filter_by(id=offloaded_var.id).delete() - session.query(WorkflowDraftVariableFile).filter_by(id=variable_file.id).delete() - session.query(UploadFile).filter_by(id=upload_file.id).delete() + session.execute(delete(WorkflowDraftVariable).where(WorkflowDraftVariable.id == offloaded_var.id)) + session.execute( + delete(WorkflowDraftVariableFile).where(WorkflowDraftVariableFile.id == variable_file.id) + ) + session.execute(delete(UploadFile).where(UploadFile.id == upload_file.id)) session.commit() # Clean up storage try: @@ -534,9 +543,11 @@ class TestDraftVariableLoader(unittest.TestCase): # Clean up with Session(bind=db.engine) as session: # Query and delete by ID to ensure they're tracked in this session - session.query(WorkflowDraftVariable).filter_by(id=offloaded_var.id).delete() - session.query(WorkflowDraftVariableFile).filter_by(id=variable_file.id).delete() - session.query(UploadFile).filter_by(id=upload_file.id).delete() + session.execute(delete(WorkflowDraftVariable).where(WorkflowDraftVariable.id == offloaded_var.id)) + session.execute( + delete(WorkflowDraftVariableFile).where(WorkflowDraftVariableFile.id == variable_file.id) + ) + session.execute(delete(UploadFile).where(UploadFile.id == upload_file.id)) session.commit() # Clean up storage try: diff --git a/api/tests/integration_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/integration_tests/tasks/test_remove_app_and_related_data_task.py index 38dc8bbb28..3dfedd811d 100644 --- a/api/tests/integration_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/integration_tests/tasks/test_remove_app_and_related_data_task.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest from graphon.variables.segments import StringSegment -from sqlalchemy import delete +from sqlalchemy import delete, func, select from core.db.session_factory import session_factory from extensions.storage.storage_type import StorageType @@ -108,8 +108,12 @@ class TestDeleteDraftVariablesIntegration: app2_id = data["app2"].id with session_factory.create_session() as session: - app1_vars_before = session.query(WorkflowDraftVariable).filter_by(app_id=app1_id).count() - app2_vars_before = session.query(WorkflowDraftVariable).filter_by(app_id=app2_id).count() + app1_vars_before = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app1_id) + ) + app2_vars_before = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app2_id) + ) assert app1_vars_before == 5 assert app2_vars_before == 5 @@ -117,8 +121,12 @@ class TestDeleteDraftVariablesIntegration: assert deleted_count == 5 with session_factory.create_session() as session: - app1_vars_after = session.query(WorkflowDraftVariable).filter_by(app_id=app1_id).count() - app2_vars_after = session.query(WorkflowDraftVariable).filter_by(app_id=app2_id).count() + app1_vars_after = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app1_id) + ) + app2_vars_after = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app2_id) + ) assert app1_vars_after == 0 assert app2_vars_after == 5 @@ -130,7 +138,9 @@ class TestDeleteDraftVariablesIntegration: assert deleted_count == 5 with session_factory.create_session() as session: - remaining_vars = session.query(WorkflowDraftVariable).filter_by(app_id=app1_id).count() + remaining_vars = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app1_id) + ) assert remaining_vars == 0 def test_delete_draft_variables_batch_nonexistent_app(self, setup_test_data): @@ -143,14 +153,18 @@ class TestDeleteDraftVariablesIntegration: app1_id = data["app1"].id with session_factory.create_session() as session: - vars_before = session.query(WorkflowDraftVariable).filter_by(app_id=app1_id).count() + vars_before = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app1_id) + ) assert vars_before == 5 deleted_count = _delete_draft_variables(app1_id) assert deleted_count == 5 with session_factory.create_session() as session: - vars_after = session.query(WorkflowDraftVariable).filter_by(app_id=app1_id).count() + vars_after = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app1_id) + ) assert vars_after == 0 def test_batch_deletion_handles_large_dataset(self, app_and_tenant): @@ -175,7 +189,9 @@ class TestDeleteDraftVariablesIntegration: deleted_count = delete_draft_variables_batch(app.id, batch_size=8) assert deleted_count == 25 with session_factory.create_session() as session: - remaining = session.query(WorkflowDraftVariable).filter_by(app_id=app.id).count() + remaining = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app.id) + ) assert remaining == 0 finally: with session_factory.create_session() as session: @@ -307,13 +323,17 @@ class TestDeleteDraftVariablesWithOffloadIntegration: mock_storage.delete.return_value = None with session_factory.create_session() as session: - draft_vars_before = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() - var_files_before = ( - session.query(WorkflowDraftVariableFile) - .where(WorkflowDraftVariableFile.id.in_(variable_file_ids)) - .count() + draft_vars_before = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) + var_files_before = session.scalar( + select(func.count()) + .select_from(WorkflowDraftVariableFile) + .where(WorkflowDraftVariableFile.id.in_(variable_file_ids)) + ) + upload_files_before = session.scalar( + select(func.count()).select_from(UploadFile).where(UploadFile.id.in_(upload_file_ids)) ) - upload_files_before = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count() assert draft_vars_before == 3 assert var_files_before == 2 assert upload_files_before == 2 @@ -322,16 +342,20 @@ class TestDeleteDraftVariablesWithOffloadIntegration: assert deleted_count == 3 with session_factory.create_session() as session: - draft_vars_after = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() + draft_vars_after = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) assert draft_vars_after == 0 with session_factory.create_session() as session: - var_files_after = ( - session.query(WorkflowDraftVariableFile) + var_files_after = session.scalar( + select(func.count()) + .select_from(WorkflowDraftVariableFile) .where(WorkflowDraftVariableFile.id.in_(variable_file_ids)) - .count() ) - upload_files_after = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count() + upload_files_after = session.scalar( + select(func.count()).select_from(UploadFile).where(UploadFile.id.in_(upload_file_ids)) + ) assert var_files_after == 0 assert upload_files_after == 0 @@ -352,16 +376,20 @@ class TestDeleteDraftVariablesWithOffloadIntegration: assert deleted_count == 3 with session_factory.create_session() as session: - draft_vars_after = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() + draft_vars_after = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) assert draft_vars_after == 0 with session_factory.create_session() as session: - var_files_after = ( - session.query(WorkflowDraftVariableFile) + var_files_after = session.scalar( + select(func.count()) + .select_from(WorkflowDraftVariableFile) .where(WorkflowDraftVariableFile.id.in_(variable_file_ids)) - .count() ) - upload_files_after = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count() + upload_files_after = session.scalar( + select(func.count()).select_from(UploadFile).where(UploadFile.id.in_(upload_file_ids)) + ) assert var_files_after == 0 assert upload_files_after == 0 @@ -579,7 +607,9 @@ class TestDeleteDraftVariablesSessionCommit: # Verify all data was deleted (proves transaction was committed) with session_factory.create_session() as session: - remaining_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() + remaining_count = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) assert deleted_count == 10 assert remaining_count == 0 @@ -592,7 +622,9 @@ class TestDeleteDraftVariablesSessionCommit: # Verify initial state with session_factory.create_session() as session: - initial_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() + initial_count = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) assert initial_count == 10 # Perform deletion with small batch size to force multiple commits @@ -602,13 +634,17 @@ class TestDeleteDraftVariablesSessionCommit: # Verify all data is deleted in a new session (proves commits worked) with session_factory.create_session() as session: - final_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() + final_count = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) assert final_count == 0 # Verify specific IDs are deleted with session_factory.create_session() as session: - remaining_vars = ( - session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.id.in_(variable_ids)).count() + remaining_vars = session.scalar( + select(func.count()) + .select_from(WorkflowDraftVariable) + .where(WorkflowDraftVariable.id.in_(variable_ids)) ) assert remaining_vars == 0 @@ -626,7 +662,9 @@ class TestDeleteDraftVariablesSessionCommit: app_id = data["app"].id with session_factory.create_session() as session: - initial_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() + initial_count = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) assert initial_count == 10 # Delete all in a single batch @@ -635,7 +673,9 @@ class TestDeleteDraftVariablesSessionCommit: # Verify data is persisted with session_factory.create_session() as session: - final_count = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() + final_count = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) assert final_count == 0 def test_invalid_batch_size_raises_error(self, setup_commit_test_data): @@ -659,13 +699,17 @@ class TestDeleteDraftVariablesSessionCommit: # Verify initial state with session_factory.create_session() as session: - draft_vars_before = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() - var_files_before = ( - session.query(WorkflowDraftVariableFile) - .where(WorkflowDraftVariableFile.id.in_([vf.id for vf in data["variable_files"]])) - .count() + draft_vars_before = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) + var_files_before = session.scalar( + select(func.count()) + .select_from(WorkflowDraftVariableFile) + .where(WorkflowDraftVariableFile.id.in_([vf.id for vf in data["variable_files"]])) + ) + upload_files_before = session.scalar( + select(func.count()).select_from(UploadFile).where(UploadFile.id.in_(upload_file_ids)) ) - upload_files_before = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count() assert draft_vars_before == 3 assert var_files_before == 2 assert upload_files_before == 2 @@ -676,13 +720,17 @@ class TestDeleteDraftVariablesSessionCommit: # Verify all data is persisted (deleted) in new session with session_factory.create_session() as session: - draft_vars_after = session.query(WorkflowDraftVariable).filter_by(app_id=app_id).count() - var_files_after = ( - session.query(WorkflowDraftVariableFile) - .where(WorkflowDraftVariableFile.id.in_([vf.id for vf in data["variable_files"]])) - .count() + draft_vars_after = session.scalar( + select(func.count()).select_from(WorkflowDraftVariable).filter_by(app_id=app_id) + ) + var_files_after = session.scalar( + select(func.count()) + .select_from(WorkflowDraftVariableFile) + .where(WorkflowDraftVariableFile.id.in_([vf.id for vf in data["variable_files"]])) + ) + upload_files_after = session.scalar( + select(func.count()).select_from(UploadFile).where(UploadFile.id.in_(upload_file_ids)) ) - upload_files_after = session.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).count() assert draft_vars_after == 0 assert var_files_after == 0 assert upload_files_after == 0 diff --git a/api/tests/integration_tests/vdb/analyticdb/__init__.py b/api/tests/integration_tests/vdb/analyticdb/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/baidu/__init__.py b/api/tests/integration_tests/vdb/baidu/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/chroma/__init__.py b/api/tests/integration_tests/vdb/chroma/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/couchbase/__init__.py b/api/tests/integration_tests/vdb/couchbase/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/elasticsearch/__init__.py b/api/tests/integration_tests/vdb/elasticsearch/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/hologres/__init__.py b/api/tests/integration_tests/vdb/hologres/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/huawei/__init__.py b/api/tests/integration_tests/vdb/huawei/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/iris/__init__.py b/api/tests/integration_tests/vdb/iris/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/lindorm/__init__.py b/api/tests/integration_tests/vdb/lindorm/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/matrixone/__init__.py b/api/tests/integration_tests/vdb/matrixone/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/milvus/__init__.py b/api/tests/integration_tests/vdb/milvus/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/myscale/__init__.py b/api/tests/integration_tests/vdb/myscale/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/oceanbase/__init__.py b/api/tests/integration_tests/vdb/oceanbase/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/opengauss/__init__.py b/api/tests/integration_tests/vdb/opengauss/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/opensearch/__init__.py b/api/tests/integration_tests/vdb/opensearch/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/opensearch/test_opensearch.py b/api/tests/integration_tests/vdb/opensearch/test_opensearch.py deleted file mode 100644 index 81ebb1d2f7..0000000000 --- a/api/tests/integration_tests/vdb/opensearch/test_opensearch.py +++ /dev/null @@ -1,235 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest - -from core.rag.datasource.vdb.field import Field -from core.rag.datasource.vdb.opensearch.opensearch_vector import OpenSearchConfig, OpenSearchVector -from core.rag.models.document import Document -from extensions import ext_redis - - -def get_example_text() -> str: - return "This is a sample text for testing purposes." - - -@pytest.fixture(scope="module") -def setup_mock_redis(): - ext_redis.redis_client.get = MagicMock(return_value=None) - ext_redis.redis_client.set = MagicMock(return_value=None) - - mock_redis_lock = MagicMock() - mock_redis_lock.__enter__ = MagicMock() - mock_redis_lock.__exit__ = MagicMock() - ext_redis.redis_client.lock = MagicMock(return_value=mock_redis_lock) - - -class TestOpenSearchConfig: - def test_to_opensearch_params(self): - config = OpenSearchConfig( - host="localhost", - port=9200, - secure=True, - user="admin", - password="password", - ) - - params = config.to_opensearch_params() - - assert params["hosts"] == [{"host": "localhost", "port": 9200}] - assert params["use_ssl"] is True - assert params["verify_certs"] is True - assert params["connection_class"].__name__ == "Urllib3HttpConnection" - assert params["http_auth"] == ("admin", "password") - - @patch("boto3.Session", autospec=True) - @patch("core.rag.datasource.vdb.opensearch.opensearch_vector.Urllib3AWSV4SignerAuth", autospec=True) - def test_to_opensearch_params_with_aws_managed_iam( - self, mock_aws_signer_auth: MagicMock, mock_boto_session: MagicMock - ): - mock_credentials = MagicMock() - mock_boto_session.return_value.get_credentials.return_value = mock_credentials - - mock_auth_instance = mock_aws_signer_auth.return_value - aws_region = "ap-southeast-2" - aws_service = "aoss" - host = f"aoss-endpoint.{aws_region}.aoss.amazonaws.com" - port = 9201 - - config = OpenSearchConfig( - host=host, - port=port, - secure=True, - auth_method="aws_managed_iam", - aws_region=aws_region, - aws_service=aws_service, - ) - - params = config.to_opensearch_params() - - assert params["hosts"] == [{"host": host, "port": port}] - assert params["use_ssl"] is True - assert params["verify_certs"] is True - assert params["connection_class"].__name__ == "Urllib3HttpConnection" - assert params["http_auth"] is mock_auth_instance - - mock_aws_signer_auth.assert_called_once_with( - credentials=mock_credentials, region=aws_region, service=aws_service - ) - assert mock_boto_session.return_value.get_credentials.called - - -class TestOpenSearchVector: - def setup_method(self): - self.collection_name = "test_collection" - self.example_doc_id = "example_doc_id" - self.vector = OpenSearchVector( - collection_name=self.collection_name, - config=OpenSearchConfig(host="localhost", port=9200, secure=False, user="admin", password="password"), - ) - self.vector._client = MagicMock() - - @pytest.mark.parametrize( - ("search_response", "expected_length", "expected_doc_id"), - [ - ( - { - "hits": { - "total": {"value": 1}, - "hits": [ - { - "_source": { - "page_content": get_example_text(), - "metadata": {"document_id": "example_doc_id"}, - } - } - ], - } - }, - 1, - "example_doc_id", - ), - ({"hits": {"total": {"value": 0}, "hits": []}}, 0, None), - ], - ) - def test_search_by_full_text(self, search_response, expected_length, expected_doc_id): - self.vector._client.search.return_value = search_response - - hits_by_full_text = self.vector.search_by_full_text(query=get_example_text()) - assert len(hits_by_full_text) == expected_length - if expected_length > 0: - assert hits_by_full_text[0].metadata["document_id"] == expected_doc_id - - def test_search_by_vector(self): - vector = [0.1] * 128 - mock_response = { - "hits": { - "total": {"value": 1}, - "hits": [ - { - "_source": { - Field.CONTENT_KEY: get_example_text(), - Field.METADATA_KEY: {"document_id": self.example_doc_id}, - }, - "_score": 1.0, - } - ], - } - } - self.vector._client.search.return_value = mock_response - - hits_by_vector = self.vector.search_by_vector(query_vector=vector) - - print("Hits by vector:", hits_by_vector) - print("Expected document ID:", self.example_doc_id) - print("Actual document ID:", hits_by_vector[0].metadata["document_id"] if hits_by_vector else "No hits") - - assert len(hits_by_vector) > 0, f"Expected at least one hit, got {len(hits_by_vector)}" - assert hits_by_vector[0].metadata["document_id"] == self.example_doc_id, ( - f"Expected document ID {self.example_doc_id}, got {hits_by_vector[0].metadata['document_id']}" - ) - - def test_get_ids_by_metadata_field(self): - mock_response = {"hits": {"total": {"value": 1}, "hits": [{"_id": "mock_id"}]}} - self.vector._client.search.return_value = mock_response - - doc = Document(page_content="Test content", metadata={"document_id": self.example_doc_id}) - embedding = [0.1] * 128 - - with patch("opensearchpy.helpers.bulk", autospec=True) as mock_bulk: - mock_bulk.return_value = ([], []) - self.vector.add_texts([doc], [embedding]) - - ids = self.vector.get_ids_by_metadata_field(key="document_id", value=self.example_doc_id) - assert len(ids) == 1 - assert ids[0] == "mock_id" - - def test_add_texts(self): - self.vector._client.index.return_value = {"result": "created"} - - doc = Document(page_content="Test content", metadata={"document_id": self.example_doc_id}) - embedding = [0.1] * 128 - - with patch("opensearchpy.helpers.bulk", autospec=True) as mock_bulk: - mock_bulk.return_value = ([], []) - self.vector.add_texts([doc], [embedding]) - - mock_response = {"hits": {"total": {"value": 1}, "hits": [{"_id": "mock_id"}]}} - self.vector._client.search.return_value = mock_response - - ids = self.vector.get_ids_by_metadata_field(key="document_id", value=self.example_doc_id) - assert len(ids) == 1 - assert ids[0] == "mock_id" - - def test_delete_nonexistent_index(self): - """Test deleting a non-existent index.""" - # Create a vector instance with a non-existent collection name - self.vector._client.indices.exists.return_value = False - - # Should not raise an exception - self.vector.delete() - - # Verify that exists was called but delete was not - self.vector._client.indices.exists.assert_called_once_with(index=self.collection_name.lower()) - self.vector._client.indices.delete.assert_not_called() - - def test_delete_existing_index(self): - """Test deleting an existing index.""" - self.vector._client.indices.exists.return_value = True - - self.vector.delete() - - # Verify both exists and delete were called - self.vector._client.indices.exists.assert_called_once_with(index=self.collection_name.lower()) - self.vector._client.indices.delete.assert_called_once_with(index=self.collection_name.lower()) - - -@pytest.mark.usefixtures("setup_mock_redis") -class TestOpenSearchVectorWithRedis: - def setup_method(self): - self.tester = TestOpenSearchVector() - - def test_search_by_full_text(self): - self.tester.setup_method() - search_response = { - "hits": { - "total": {"value": 1}, - "hits": [ - {"_source": {"page_content": get_example_text(), "metadata": {"document_id": "example_doc_id"}}} - ], - } - } - expected_length = 1 - expected_doc_id = "example_doc_id" - self.tester.test_search_by_full_text(search_response, expected_length, expected_doc_id) - - def test_get_ids_by_metadata_field(self): - self.tester.setup_method() - self.tester.test_get_ids_by_metadata_field() - - def test_add_texts(self): - self.tester.setup_method() - self.tester.test_add_texts() - - def test_search_by_vector(self): - self.tester.setup_method() - self.tester.test_search_by_vector() diff --git a/api/tests/integration_tests/vdb/oracle/__init__.py b/api/tests/integration_tests/vdb/oracle/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/pgvecto_rs/__init__.py b/api/tests/integration_tests/vdb/pgvecto_rs/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/pgvector/__init__.py b/api/tests/integration_tests/vdb/pgvector/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/pyvastbase/__init__.py b/api/tests/integration_tests/vdb/pyvastbase/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/qdrant/__init__.py b/api/tests/integration_tests/vdb/qdrant/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/tablestore/__init__.py b/api/tests/integration_tests/vdb/tablestore/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/tcvectordb/__init__.py b/api/tests/integration_tests/vdb/tcvectordb/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/tidb_vector/__init__.py b/api/tests/integration_tests/vdb/tidb_vector/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/upstash/__init__.py b/api/tests/integration_tests/vdb/upstash/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/vikingdb/__init__.py b/api/tests/integration_tests/vdb/vikingdb/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/integration_tests/vdb/weaviate/__init__.py b/api/tests/integration_tests/vdb/weaviate/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py index c3a861c3e1..54e0496dbd 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py @@ -313,6 +313,21 @@ class TestSiteEndpoints: method = _unwrap(api.post) site = MagicMock() + site.app_id = "app-1" + site.code = "test-code" + site.title = "My Site" + site.icon = None + site.icon_background = None + site.description = "Test site" + site.default_language = "en-US" + site.customize_domain = None + site.copyright = None + site.privacy_policy = None + site.custom_disclaimer = "" + site.customize_token_strategy = "not_allow" + site.prompt_public = False + site.show_workflow_steps = True + site.use_icon_as_answer_icon = False monkeypatch.setattr( site_module.db, "session", @@ -328,13 +343,29 @@ class TestSiteEndpoints: with app.test_request_context("/", json={"title": "My Site"}): result = method(app_model=SimpleNamespace(id="app-1")) - assert result is site + assert isinstance(result, dict) + assert result["title"] == "My Site" def test_app_site_access_token_reset(self, app, monkeypatch): api = site_module.AppSiteAccessTokenReset() method = _unwrap(api.post) site = MagicMock() + site.app_id = "app-1" + site.code = "old-code" + site.title = "My Site" + site.icon = None + site.icon_background = None + site.description = None + site.default_language = "en-US" + site.customize_domain = None + site.copyright = None + site.privacy_policy = None + site.custom_disclaimer = "" + site.customize_token_strategy = "not_allow" + site.prompt_public = False + site.show_workflow_steps = True + site.use_icon_as_answer_icon = False monkeypatch.setattr( site_module.db, "session", @@ -351,7 +382,8 @@ class TestSiteEndpoints: with app.test_request_context("/"): result = method(app_model=SimpleNamespace(id="app-1")) - assert result is site + assert isinstance(result, dict) + assert result["access_token"] == "code" class TestWorkflowEndpoints: diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_message.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_message.py index 6b51ec98bc..eff6dd789d 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_message.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_message.py @@ -148,14 +148,18 @@ def test_chat_message_list_success( account.id, created_at_offset_seconds=1, ) + # Capture IDs before the HTTP request detaches ORM instances from the session + app_id = app.id + conversation_id = conversation.id + second_id = second.id with patch( "controllers.console.app.message.attach_message_extra_contents", side_effect=_attach_message_extra_contents, ): response = test_client_with_containers.get( - f"/console/api/apps/{app.id}/chat-messages", - query_string={"conversation_id": conversation.id, "limit": 1}, + f"/console/api/apps/{app_id}/chat-messages", + query_string={"conversation_id": conversation_id, "limit": 1}, headers=authenticate_console_client(test_client_with_containers, account), ) @@ -165,7 +169,7 @@ def test_chat_message_list_success( assert payload["limit"] == 1 assert payload["has_more"] is True assert len(payload["data"]) == 1 - assert payload["data"][0]["id"] == second.id + assert payload["data"][0]["id"] == second_id def test_message_feedback_not_found( diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py index 879c337319..320da85b60 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py @@ -158,7 +158,10 @@ def test_get_account_by_email_with_case_fallback_falls_back_to_lowercase(): second_result.scalar_one_or_none.return_value = expected_account mock_session.execute.side_effect = [first_result, second_result] - result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com", session=mock_session) + with patch("services.account_service.session_factory") as mock_factory: + mock_factory.create_session.return_value.__enter__ = MagicMock(return_value=mock_session) + mock_factory.create_session.return_value.__exit__ = MagicMock(return_value=False) + result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com") assert result is expected_account assert mock_session.execute.call_count == 2 diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py index 7b7393dade..d2703ed5cc 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py @@ -113,12 +113,14 @@ class TestForgotPasswordCheckApi: class TestForgotPasswordResetApi: @patch("controllers.console.auth.forgot_password.ForgotPasswordResetApi._update_existing_account") @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") + @patch("controllers.console.auth.forgot_password.db") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") def test_reset_fetches_account_with_original_email( self, mock_get_reset_data, mock_revoke_token, + mock_db, mock_get_account, mock_update_account, app, @@ -126,6 +128,7 @@ class TestForgotPasswordResetApi: mock_get_reset_data.return_value = {"phase": "reset", "email": "User@Example.com"} mock_account = MagicMock() mock_get_account.return_value = mock_account + mock_db.session.merge.return_value = mock_account wraps_features = SimpleNamespace(enable_email_password_login=True) with ( @@ -161,7 +164,10 @@ def test_get_account_by_email_with_case_fallback_falls_back_to_lowercase(): second_result.scalar_one_or_none.return_value = expected_account mock_session.execute.side_effect = [first_result, second_result] - result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=mock_session) + with patch("services.account_service.session_factory") as mock_factory: + mock_factory.create_session.return_value.__enter__ = MagicMock(return_value=mock_session) + mock_factory.create_session.return_value.__exit__ = MagicMock(return_value=False) + result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com") assert result is expected_account assert mock_session.execute.call_count == 2 diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py index a2f1328579..1eabb45422 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py @@ -437,7 +437,10 @@ class TestAccountGeneration: second_result.scalar_one_or_none.return_value = expected_account mock_session.execute.side_effect = [first_result, second_result] - result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com", session=mock_session) + with patch("services.account_service.session_factory") as mock_factory: + mock_factory.create_session.return_value.__enter__ = MagicMock(return_value=mock_session) + mock_factory.create_session.return_value.__exit__ = MagicMock(return_value=False) + result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com") assert result is expected_account assert mock_session.execute.call_count == 2 diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py index 8f9db287e3..50249bcd74 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py @@ -335,10 +335,12 @@ class TestForgotPasswordResetApi: @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") + @patch("controllers.console.auth.forgot_password.db") @patch("controllers.console.auth.forgot_password.TenantService.get_join_tenants") def test_reset_password_success( self, mock_get_tenants, + mock_db, mock_get_account, mock_revoke_token, mock_get_data, @@ -356,6 +358,7 @@ class TestForgotPasswordResetApi: # Arrange mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"} mock_get_account.return_value = mock_account + mock_db.session.merge.return_value = mock_account mock_get_tenants.return_value = [MagicMock()] # Act diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py index 04ad143103..f14b2c0ae5 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py @@ -37,10 +37,8 @@ class TestForgotPasswordSendEmailApi: @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") @patch("controllers.web.forgot_password.AccountService.is_email_send_ip_limit", return_value=False) @patch("controllers.web.forgot_password.extract_remote_ip", return_value="127.0.0.1") - @patch("controllers.web.forgot_password.sessionmaker") def test_should_normalize_email_before_sending( self, - mock_session_cls, mock_extract_ip, mock_rate_limit, mock_get_account, @@ -50,19 +48,16 @@ class TestForgotPasswordSendEmailApi: mock_account = MagicMock() mock_get_account.return_value = mock_account mock_send_mail.return_value = "token-123" - mock_session = MagicMock() - mock_session_cls.return_value.begin.return_value.__enter__.return_value = mock_session - with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")): - with app.test_request_context( - "/web/forgot-password", - method="POST", - json={"email": "User@Example.com", "language": "zh-Hans"}, - ): - response = ForgotPasswordSendEmailApi().post() + with app.test_request_context( + "/web/forgot-password", + method="POST", + json={"email": "User@Example.com", "language": "zh-Hans"}, + ): + response = ForgotPasswordSendEmailApi().post() assert response == {"result": "success", "data": "token-123"} - mock_get_account.assert_called_once_with("User@Example.com", session=mock_session) + mock_get_account.assert_called_once_with("User@Example.com") mock_send_mail.assert_called_once_with(account=mock_account, email="user@example.com", language="zh-Hans") mock_extract_ip.assert_called_once() mock_rate_limit.assert_called_once_with("127.0.0.1") @@ -153,14 +148,14 @@ class TestForgotPasswordResetApi: @patch("controllers.web.forgot_password.ForgotPasswordResetApi._update_existing_account") @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.web.forgot_password.sessionmaker") + @patch("controllers.web.forgot_password.db") @patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token") @patch("controllers.web.forgot_password.AccountService.get_reset_password_data") def test_should_fetch_account_with_fallback( self, mock_get_reset_data, mock_revoke_token, - mock_session_cls, + mock_db, mock_get_account, mock_update_account, app, @@ -168,29 +163,27 @@ class TestForgotPasswordResetApi: mock_get_reset_data.return_value = {"phase": "reset", "email": "User@Example.com", "code": "1234"} mock_account = MagicMock() mock_get_account.return_value = mock_account - mock_session = MagicMock() - mock_session_cls.return_value.begin.return_value.__enter__.return_value = mock_session + mock_db.session.merge.return_value = mock_account - with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")): - with app.test_request_context( - "/web/forgot-password/resets", - method="POST", - json={ - "token": "token-123", - "new_password": "ValidPass123!", - "password_confirm": "ValidPass123!", - }, - ): - response = ForgotPasswordResetApi().post() + with app.test_request_context( + "/web/forgot-password/resets", + method="POST", + json={ + "token": "token-123", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + }, + ): + response = ForgotPasswordResetApi().post() assert response == {"result": "success"} - mock_get_account.assert_called_once_with("User@Example.com", session=mock_session) + mock_get_account.assert_called_once_with("User@Example.com") mock_update_account.assert_called_once() mock_revoke_token.assert_called_once_with("token-123") @patch("controllers.web.forgot_password.hash_password", return_value=b"hashed-value") @patch("controllers.web.forgot_password.secrets.token_bytes", return_value=b"0123456789abcdef") - @patch("controllers.web.forgot_password.sessionmaker") + @patch("controllers.web.forgot_password.db") @patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token") @patch("controllers.web.forgot_password.AccountService.get_reset_password_data") @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") @@ -199,7 +192,7 @@ class TestForgotPasswordResetApi: mock_get_account, mock_get_reset_data, mock_revoke_token, - mock_session_cls, + mock_db, mock_token_bytes, mock_hash_password, app, @@ -207,20 +200,18 @@ class TestForgotPasswordResetApi: mock_get_reset_data.return_value = {"phase": "reset", "email": "user@example.com"} account = MagicMock() mock_get_account.return_value = account - mock_session = MagicMock() - mock_session_cls.return_value.begin.return_value.__enter__.return_value = mock_session + mock_db.session.merge.return_value = account - with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")): - with app.test_request_context( - "/web/forgot-password/resets", - method="POST", - json={ - "token": "reset-token", - "new_password": "StrongPass123!", - "password_confirm": "StrongPass123!", - }, - ): - response = ForgotPasswordResetApi().post() + with app.test_request_context( + "/web/forgot-password/resets", + method="POST", + json={ + "token": "reset-token", + "new_password": "StrongPass123!", + "password_confirm": "StrongPass123!", + }, + ): + response = ForgotPasswordResetApi().post() assert response == {"result": "success"} mock_get_reset_data.assert_called_once_with("reset-token") diff --git a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py index 2b4c1b59ab..c9ee67863d 100644 --- a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py +++ b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py @@ -557,11 +557,9 @@ class TestPauseStatePersistenceLayerTestContainers: self.session.refresh(self.test_workflow_run) assert self.test_workflow_run.status == WorkflowExecutionStatus.RUNNING - pause_states = ( - self.session.query(WorkflowPauseModel) - .filter(WorkflowPauseModel.workflow_run_id == self.test_workflow_run_id) - .all() - ) + pause_states = self.session.scalars( + select(WorkflowPauseModel).where(WorkflowPauseModel.workflow_run_id == self.test_workflow_run_id) + ).all() assert len(pause_states) == 0 def test_layer_requires_initialization(self, db_session_with_containers): diff --git a/api/tests/test_containers_integration_tests/models/test_account.py b/api/tests/test_containers_integration_tests/models/test_account.py index 078dc0e8de..6fd6716cbb 100644 --- a/api/tests/test_containers_integration_tests/models/test_account.py +++ b/api/tests/test_containers_integration_tests/models/test_account.py @@ -1,79 +1,202 @@ -# import secrets +""" +Integration tests for Account and Tenant model methods that interact with the database. -# import pytest -# from sqlalchemy import select -# from sqlalchemy.orm import Session -# from sqlalchemy.orm.exc import DetachedInstanceError +Migrated from unit_tests/models/test_account_models.py, replacing +@patch("models.account.db") mock patches with real PostgreSQL operations. -# from libs.datetime_utils import naive_utc_now -# from models.account import Account, Tenant, TenantAccountJoin +Covers: +- Account.current_tenant setter (sets _current_tenant and role from TenantAccountJoin) +- Account.set_tenant_id (resolves tenant + role from real join row) +- Account.get_by_openid (AccountIntegrate lookup then Account fetch) +- Tenant.get_accounts (returns accounts linked via TenantAccountJoin) +""" + +from collections.abc import Generator +from uuid import uuid4 + +import pytest +from sqlalchemy import delete +from sqlalchemy.orm import Session + +from models.account import Account, AccountIntegrate, Tenant, TenantAccountJoin, TenantAccountRole -# @pytest.fixture -# def session(db_session_with_containers): -# with Session(db_session_with_containers.get_bind()) as session: -# yield session +def _cleanup_tracked_rows(db_session: Session, tracked: list) -> None: + """Delete rows tracked during the test so committed state does not leak into the DB. + + Rolls back any pending (uncommitted) session state first, then issues DELETE + statements by primary key for each tracked entity (in reverse creation order) + and commits. This cleans up rows created via either flush() or commit(). + """ + db_session.rollback() + for entity in reversed(tracked): + db_session.execute(delete(type(entity)).where(type(entity).id == entity.id)) + db_session.commit() -# @pytest.fixture -# def account(session): -# account = Account( -# name="test account", -# email=f"test_{secrets.token_hex(8)}@example.com", -# ) -# session.add(account) -# session.commit() -# return account +def _build_tenant() -> Tenant: + return Tenant(name=f"Tenant {uuid4()}") -# @pytest.fixture -# def tenant(session): -# tenant = Tenant(name="test tenant") -# session.add(tenant) -# session.commit() -# return tenant +def _build_account(email_prefix: str = "account") -> Account: + return Account( + name=f"Account {uuid4()}", + email=f"{email_prefix}_{uuid4()}@example.com", + password="hashed-password", + password_salt="salt", + interface_language="en-US", + timezone="UTC", + ) -# @pytest.fixture -# def tenant_account_join(session, account, tenant): -# tenant_join = TenantAccountJoin(account_id=account.id, tenant_id=tenant.id) -# session.add(tenant_join) -# session.commit() -# yield tenant_join -# session.delete(tenant_join) -# session.commit() +class _DBTrackingTestBase: + """Base class providing a tracker list and shared row factories for account/tenant tests.""" + + _tracked: list + + @pytest.fixture(autouse=True) + def _setup_cleanup(self, db_session_with_containers: Session) -> Generator[None, None, None]: + self._tracked = [] + yield + _cleanup_tracked_rows(db_session_with_containers, self._tracked) + + def _create_tenant(self, db_session: Session) -> Tenant: + tenant = _build_tenant() + db_session.add(tenant) + db_session.flush() + self._tracked.append(tenant) + return tenant + + def _create_account(self, db_session: Session, email_prefix: str = "account") -> Account: + account = _build_account(email_prefix) + db_session.add(account) + db_session.flush() + self._tracked.append(account) + return account + + def _create_join( + self, db_session: Session, tenant_id: str, account_id: str, role: TenantAccountRole, current: bool = True + ) -> TenantAccountJoin: + join = TenantAccountJoin(tenant_id=tenant_id, account_id=account_id, role=role, current=current) + db_session.add(join) + db_session.flush() + self._tracked.append(join) + return join -# class TestAccountTenant: -# def test_set_current_tenant_should_reload_tenant( -# self, -# db_session_with_containers, -# account, -# tenant, -# tenant_account_join, -# ): -# with Session(db_session_with_containers.get_bind(), expire_on_commit=True) as session: -# scoped_tenant = session.scalars(select(Tenant).where(Tenant.id == tenant.id)).one() -# account.current_tenant = scoped_tenant -# scoped_tenant.created_at = naive_utc_now() -# # session.commit() +class TestAccountCurrentTenantSetter(_DBTrackingTestBase): + """Integration tests for Account.current_tenant property setter.""" -# # Ensure the tenant used in assignment is detached. -# with pytest.raises(DetachedInstanceError): -# _ = scoped_tenant.name + def test_current_tenant_property_returns_cached_tenant(self, db_session_with_containers: Session) -> None: + """current_tenant getter returns the in-memory _current_tenant without DB access.""" + account = self._create_account(db_session_with_containers) + tenant = self._create_tenant(db_session_with_containers) + account._current_tenant = tenant -# assert account._current_tenant.id == tenant.id -# assert account._current_tenant.id == tenant.id + assert account.current_tenant is tenant -# def test_set_tenant_id_should_load_tenant_as_not_expire( -# self, -# flask_app_with_containers, -# account, -# tenant, -# tenant_account_join, -# ): -# with flask_app_with_containers.test_request_context(): -# account.set_tenant_id(tenant.id) + def test_current_tenant_setter_sets_tenant_and_role_when_join_exists( + self, db_session_with_containers: Session + ) -> None: + """Setting current_tenant loads the join row and assigns role when relationship exists.""" + tenant = self._create_tenant(db_session_with_containers) + account = self._create_account(db_session_with_containers) + self._create_join(db_session_with_containers, tenant.id, account.id, TenantAccountRole.OWNER) + db_session_with_containers.commit() -# assert account._current_tenant.id == tenant.id -# assert account._current_tenant.id == tenant.id + account.current_tenant = tenant + + assert account._current_tenant is not None + assert account._current_tenant.id == tenant.id + assert account.role == TenantAccountRole.OWNER + + def test_current_tenant_setter_sets_none_when_no_join_exists(self, db_session_with_containers: Session) -> None: + """Setting current_tenant results in _current_tenant=None when no join row exists.""" + tenant = self._create_tenant(db_session_with_containers) + account = self._create_account(db_session_with_containers) + db_session_with_containers.commit() + + account.current_tenant = tenant + + assert account._current_tenant is None + + +class TestAccountSetTenantId(_DBTrackingTestBase): + """Integration tests for Account.set_tenant_id method.""" + + def test_set_tenant_id_sets_tenant_and_role_when_relationship_exists( + self, db_session_with_containers: Session + ) -> None: + """set_tenant_id loads the tenant and assigns role when a join row exists.""" + tenant = self._create_tenant(db_session_with_containers) + account = self._create_account(db_session_with_containers) + self._create_join(db_session_with_containers, tenant.id, account.id, TenantAccountRole.ADMIN) + db_session_with_containers.commit() + + account.set_tenant_id(tenant.id) + + assert account._current_tenant is not None + assert account._current_tenant.id == tenant.id + assert account.role == TenantAccountRole.ADMIN + + def test_set_tenant_id_does_not_set_tenant_when_no_relationship_exists( + self, db_session_with_containers: Session + ) -> None: + """set_tenant_id does nothing when no join row matches the tenant.""" + tenant = self._create_tenant(db_session_with_containers) + account = self._create_account(db_session_with_containers) + db_session_with_containers.commit() + + account.set_tenant_id(tenant.id) + + assert account._current_tenant is None + + +class TestAccountGetByOpenId(_DBTrackingTestBase): + """Integration tests for Account.get_by_openid class method.""" + + def test_get_by_openid_returns_account_when_integrate_exists(self, db_session_with_containers: Session) -> None: + """get_by_openid returns the Account when a matching AccountIntegrate row exists.""" + account = self._create_account(db_session_with_containers, email_prefix="openid") + provider = "google" + open_id = f"google_{uuid4()}" + + integrate = AccountIntegrate( + account_id=account.id, + provider=provider, + open_id=open_id, + encrypted_token="token", + ) + db_session_with_containers.add(integrate) + db_session_with_containers.flush() + self._tracked.append(integrate) + + result = Account.get_by_openid(provider, open_id) + + assert result is not None + assert result.id == account.id + + def test_get_by_openid_returns_none_when_no_integrate_exists(self, db_session_with_containers: Session) -> None: + """get_by_openid returns None when no AccountIntegrate row matches.""" + result = Account.get_by_openid("github", f"github_{uuid4()}") + + assert result is None + + +class TestTenantGetAccounts(_DBTrackingTestBase): + """Integration tests for Tenant.get_accounts method.""" + + def test_get_accounts_returns_linked_accounts(self, db_session_with_containers: Session) -> None: + """get_accounts returns all accounts linked to the tenant via TenantAccountJoin.""" + tenant = self._create_tenant(db_session_with_containers) + account1 = self._create_account(db_session_with_containers, email_prefix="tenant_member") + account2 = self._create_account(db_session_with_containers, email_prefix="tenant_member") + self._create_join(db_session_with_containers, tenant.id, account1.id, TenantAccountRole.OWNER, current=False) + self._create_join(db_session_with_containers, tenant.id, account2.id, TenantAccountRole.NORMAL, current=False) + + accounts = tenant.get_accounts() + + assert len(accounts) == 2 + account_ids = {a.id for a in accounts} + assert account1.id in account_ids + assert account2.id in account_ids diff --git a/api/tests/test_containers_integration_tests/models/test_conversation_message_inputs.py b/api/tests/test_containers_integration_tests/models/test_conversation_message_inputs.py new file mode 100644 index 0000000000..e922c19a5a --- /dev/null +++ b/api/tests/test_containers_integration_tests/models/test_conversation_message_inputs.py @@ -0,0 +1,149 @@ +""" +Integration tests for Conversation.inputs and Message.inputs tenant resolution. + +Migrated from unit_tests/models/test_model.py, replacing db.session.scalar monkeypatching +with a real App in PostgreSQL so the _resolve_app_tenant_id lookup executes against the DB. +""" + +from collections.abc import Generator +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from graphon.file import FILE_MODEL_IDENTITY, FileTransferMethod +from sqlalchemy.orm import Session + +from core.workflow.file_reference import build_file_reference +from models.model import App, AppMode, Conversation, Message + + +def _build_local_file_mapping(record_id: str, *, tenant_id: str | None = None) -> dict: + mapping: dict = { + "dify_model_identity": FILE_MODEL_IDENTITY, + "transfer_method": FileTransferMethod.LOCAL_FILE, + "reference": build_file_reference(record_id=record_id), + "type": "document", + "filename": "example.txt", + "extension": ".txt", + "mime_type": "text/plain", + "size": 1, + } + if tenant_id is not None: + mapping["tenant_id"] = tenant_id + return mapping + + +class TestConversationMessageInputsTenantResolution: + """Integration tests for Conversation/Message.inputs tenant resolution via real DB lookup.""" + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + """Automatically rollback session changes after each test.""" + yield + db_session_with_containers.rollback() + + def _create_app(self, db_session: Session) -> App: + tenant_id = str(uuid4()) + app = App( + tenant_id=tenant_id, + name=f"App {uuid4()}", + mode=AppMode.CHAT, + enable_site=False, + enable_api=True, + is_demo=False, + is_public=False, + is_universal=False, + created_by=str(uuid4()), + updated_by=str(uuid4()), + ) + db_session.add(app) + db_session.flush() + return app + + @pytest.mark.parametrize("owner_cls", [Conversation, Message]) + def test_inputs_resolves_tenant_via_db_for_local_file( + self, + db_session_with_containers: Session, + owner_cls: type, + ) -> None: + """Inputs resolves tenant_id from real App row when file mapping has no tenant_id.""" + app = self._create_app(db_session_with_containers) + build_calls: list[tuple[dict, str]] = [] + + def fake_build_from_mapping( + *, mapping, tenant_id, config=None, strict_type_validation=False, access_controller + ): + build_calls.append((dict(mapping), tenant_id)) + return {"tenant_id": tenant_id, "upload_file_id": mapping.get("upload_file_id")} + + with patch("factories.file_factory.build_from_mapping", fake_build_from_mapping): + owner = owner_cls(app_id=app.id) + owner.inputs = {"file": _build_local_file_mapping("upload-1")} + + restored_inputs = owner.inputs + + # The tenant_id should come from the real App row in the DB + assert restored_inputs["file"] == {"tenant_id": app.tenant_id, "upload_file_id": "upload-1"} + assert len(build_calls) == 1 + assert build_calls[0][1] == app.tenant_id + + @pytest.mark.parametrize("owner_cls", [Conversation, Message]) + def test_inputs_uses_serialized_tenant_id_skipping_db_lookup( + self, + db_session_with_containers: Session, + owner_cls: type, + ) -> None: + """Inputs uses tenant_id from the file mapping payload without hitting the DB.""" + app = self._create_app(db_session_with_containers) + payload_tenant_id = "tenant-from-payload" + build_calls: list[tuple[dict, str]] = [] + + def fake_build_from_mapping( + *, mapping, tenant_id, config=None, strict_type_validation=False, access_controller + ): + build_calls.append((dict(mapping), tenant_id)) + return {"tenant_id": tenant_id, "upload_file_id": mapping.get("upload_file_id")} + + with patch("factories.file_factory.build_from_mapping", fake_build_from_mapping): + owner = owner_cls(app_id=app.id) + owner.inputs = {"file": _build_local_file_mapping("upload-1", tenant_id=payload_tenant_id)} + + restored_inputs = owner.inputs + + assert restored_inputs["file"] == {"tenant_id": payload_tenant_id, "upload_file_id": "upload-1"} + assert len(build_calls) == 1 + assert build_calls[0][1] == payload_tenant_id + + @pytest.mark.parametrize("owner_cls", [Conversation, Message]) + def test_inputs_resolves_tenant_for_file_list( + self, + db_session_with_containers: Session, + owner_cls: type, + ) -> None: + """Inputs resolves tenant_id for a list of file mappings.""" + app = self._create_app(db_session_with_containers) + build_calls: list[tuple[dict, str]] = [] + + def fake_build_from_mapping( + *, mapping, tenant_id, config=None, strict_type_validation=False, access_controller + ): + build_calls.append((dict(mapping), tenant_id)) + return {"tenant_id": tenant_id, "upload_file_id": mapping.get("upload_file_id")} + + with patch("factories.file_factory.build_from_mapping", fake_build_from_mapping): + owner = owner_cls(app_id=app.id) + owner.inputs = { + "files": [ + _build_local_file_mapping("upload-1"), + _build_local_file_mapping("upload-2"), + ] + } + + restored_inputs = owner.inputs + + assert len(build_calls) == 2 + assert all(call[1] == app.tenant_id for call in build_calls) + assert restored_inputs["files"] == [ + {"tenant_id": app.tenant_id, "upload_file_id": "upload-1"}, + {"tenant_id": app.tenant_id, "upload_file_id": "upload-2"}, + ] diff --git a/api/tests/test_containers_integration_tests/models/test_conversation_status_count.py b/api/tests/test_containers_integration_tests/models/test_conversation_status_count.py new file mode 100644 index 0000000000..4ca87de52d --- /dev/null +++ b/api/tests/test_containers_integration_tests/models/test_conversation_status_count.py @@ -0,0 +1,314 @@ +""" +Integration tests for Conversation.status_count and Site.generate_code model properties. + +Migrated from unit_tests/models/test_app_models.py TestConversationStatusCount and +test_site_generate_code, replacing db.session.scalars mocks with real PostgreSQL queries. +""" + +from collections.abc import Generator +from uuid import uuid4 + +import pytest +from graphon.enums import WorkflowExecutionStatus +from sqlalchemy.orm import Session + +from models.enums import ConversationFromSource, InvokeFrom +from models.model import App, AppMode, Conversation, Message, Site +from models.workflow import Workflow, WorkflowRun, WorkflowRunTriggeredFrom, WorkflowType + + +class TestConversationStatusCount: + """Integration tests for Conversation.status_count property.""" + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + """Automatically rollback session changes after each test.""" + yield + db_session_with_containers.rollback() + + def _create_app(self, db_session: Session, tenant_id: str, created_by: str) -> App: + app = App( + tenant_id=tenant_id, + name=f"App {uuid4()}", + mode=AppMode.ADVANCED_CHAT, + enable_site=False, + enable_api=True, + is_demo=False, + is_public=False, + is_universal=False, + created_by=created_by, + updated_by=created_by, + ) + db_session.add(app) + db_session.flush() + return app + + def _create_conversation(self, db_session: Session, app: App) -> Conversation: + conversation = Conversation( + app_id=app.id, + mode=app.mode, + name=f"Conversation {uuid4()}", + summary="", + inputs={}, + introduction="", + system_instruction="", + system_instruction_tokens=0, + status="normal", + invoke_from=InvokeFrom.WEB_APP, + from_source=ConversationFromSource.API, + dialogue_count=0, + is_deleted=False, + ) + conversation.inputs = {} + db_session.add(conversation) + db_session.flush() + return conversation + + def _create_workflow(self, db_session: Session, app: App, created_by: str) -> Workflow: + workflow = Workflow( + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.CHAT, + version="draft", + graph="{}", + created_by=created_by, + ) + workflow._features = "{}" + db_session.add(workflow) + db_session.flush() + return workflow + + def _create_workflow_run( + self, db_session: Session, app: App, workflow: Workflow, status: WorkflowExecutionStatus, created_by: str + ) -> WorkflowRun: + run = WorkflowRun( + tenant_id=app.tenant_id, + app_id=app.id, + workflow_id=workflow.id, + type=WorkflowType.CHAT, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="draft", + status=status, + created_by_role="account", + created_by=created_by, + ) + db_session.add(run) + db_session.flush() + return run + + def _create_message( + self, db_session: Session, app: App, conversation: Conversation, workflow_run_id: str | None = None + ) -> Message: + message = Message( + app_id=app.id, + conversation_id=conversation.id, + _inputs={}, + query="Test query", + message={"role": "user", "content": "Test query"}, + answer="Test answer", + model_provider="openai", + model_id="gpt-4", + message_tokens=10, + message_unit_price=0, + answer_tokens=10, + answer_unit_price=0, + total_price=0, + currency="USD", + from_source=ConversationFromSource.API, + invoke_from=InvokeFrom.WEB_APP, + workflow_run_id=workflow_run_id, + ) + db_session.add(message) + db_session.flush() + return message + + def test_status_count_returns_none_when_no_messages(self, db_session_with_containers: Session) -> None: + """status_count returns None when conversation has no messages with workflow_run_id.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + app = self._create_app(db_session_with_containers, tenant_id, created_by) + conversation = self._create_conversation(db_session_with_containers, app) + + result = conversation.status_count + + assert result is None + + def test_status_count_returns_none_when_messages_have_no_workflow_run_id( + self, db_session_with_containers: Session + ) -> None: + """status_count returns None when messages exist but none have workflow_run_id.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + app = self._create_app(db_session_with_containers, tenant_id, created_by) + conversation = self._create_conversation(db_session_with_containers, app) + self._create_message(db_session_with_containers, app, conversation, workflow_run_id=None) + + result = conversation.status_count + + assert result is None + + def test_status_count_counts_succeeded_workflow_run(self, db_session_with_containers: Session) -> None: + """status_count correctly counts succeeded workflow runs.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + app = self._create_app(db_session_with_containers, tenant_id, created_by) + conversation = self._create_conversation(db_session_with_containers, app) + workflow = self._create_workflow(db_session_with_containers, app, created_by) + run = self._create_workflow_run( + db_session_with_containers, app, workflow, WorkflowExecutionStatus.SUCCEEDED, created_by + ) + self._create_message(db_session_with_containers, app, conversation, workflow_run_id=run.id) + + result = conversation.status_count + + assert result is not None + assert result["success"] == 1 + assert result["failed"] == 0 + assert result["partial_success"] == 0 + assert result["paused"] == 0 + + def test_status_count_counts_failed_workflow_run(self, db_session_with_containers: Session) -> None: + """status_count correctly counts failed workflow runs.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + app = self._create_app(db_session_with_containers, tenant_id, created_by) + conversation = self._create_conversation(db_session_with_containers, app) + workflow = self._create_workflow(db_session_with_containers, app, created_by) + run = self._create_workflow_run( + db_session_with_containers, app, workflow, WorkflowExecutionStatus.FAILED, created_by + ) + self._create_message(db_session_with_containers, app, conversation, workflow_run_id=run.id) + + result = conversation.status_count + + assert result is not None + assert result["success"] == 0 + assert result["failed"] == 1 + assert result["partial_success"] == 0 + assert result["paused"] == 0 + + def test_status_count_counts_paused_workflow_run(self, db_session_with_containers: Session) -> None: + """status_count correctly counts paused workflow runs.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + app = self._create_app(db_session_with_containers, tenant_id, created_by) + conversation = self._create_conversation(db_session_with_containers, app) + workflow = self._create_workflow(db_session_with_containers, app, created_by) + run = self._create_workflow_run( + db_session_with_containers, app, workflow, WorkflowExecutionStatus.PAUSED, created_by + ) + self._create_message(db_session_with_containers, app, conversation, workflow_run_id=run.id) + + result = conversation.status_count + + assert result is not None + assert result["success"] == 0 + assert result["failed"] == 0 + assert result["partial_success"] == 0 + assert result["paused"] == 1 + + def test_status_count_multiple_statuses(self, db_session_with_containers: Session) -> None: + """status_count counts multiple workflow runs with different statuses.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + app = self._create_app(db_session_with_containers, tenant_id, created_by) + conversation = self._create_conversation(db_session_with_containers, app) + workflow = self._create_workflow(db_session_with_containers, app, created_by) + + for status in [ + WorkflowExecutionStatus.SUCCEEDED, + WorkflowExecutionStatus.FAILED, + WorkflowExecutionStatus.PARTIAL_SUCCEEDED, + WorkflowExecutionStatus.PAUSED, + ]: + run = self._create_workflow_run(db_session_with_containers, app, workflow, status, created_by) + self._create_message(db_session_with_containers, app, conversation, workflow_run_id=run.id) + + result = conversation.status_count + + assert result is not None + assert result["success"] == 1 + assert result["failed"] == 1 + assert result["partial_success"] == 1 + assert result["paused"] == 1 + + def test_status_count_filters_workflow_runs_by_app_id(self, db_session_with_containers: Session) -> None: + """status_count excludes workflow runs belonging to a different app.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + app = self._create_app(db_session_with_containers, tenant_id, created_by) + other_app = self._create_app(db_session_with_containers, tenant_id, created_by) + conversation = self._create_conversation(db_session_with_containers, app) + workflow = self._create_workflow(db_session_with_containers, other_app, created_by) + + # Workflow run belongs to other_app, not app + other_run = self._create_workflow_run( + db_session_with_containers, other_app, workflow, WorkflowExecutionStatus.SUCCEEDED, created_by + ) + # Message references that run but is in a conversation under app + self._create_message(db_session_with_containers, app, conversation, workflow_run_id=other_run.id) + + result = conversation.status_count + + # The run should be excluded because app_id filter doesn't match + assert result is not None + assert result["success"] == 0 + + +class TestSiteGenerateCode: + """Integration tests for Site.generate_code static method.""" + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + """Automatically rollback session changes after each test.""" + yield + db_session_with_containers.rollback() + + def test_generate_code_returns_string_of_correct_length(self, db_session_with_containers: Session) -> None: + """Site.generate_code returns a code string of the requested length.""" + code = Site.generate_code(8) + + assert isinstance(code, str) + assert len(code) == 8 + + def test_generate_code_avoids_duplicates(self, db_session_with_containers: Session) -> None: + """Site.generate_code returns a code not already in use.""" + tenant_id = str(uuid4()) + app = App( + tenant_id=tenant_id, + name="Test App", + mode=AppMode.CHAT, + enable_site=True, + enable_api=False, + is_demo=False, + is_public=False, + is_universal=False, + created_by=str(uuid4()), + updated_by=str(uuid4()), + ) + db_session_with_containers.add(app) + db_session_with_containers.flush() + + site = Site( + app_id=app.id, + title="Test Site", + default_language="en-US", + customize_token_strategy="not_allow", + ) + # Set an explicit code so generate_code must avoid it + site.code = "AAAAAAAA" + db_session_with_containers.add(site) + db_session_with_containers.flush() + + code = Site.generate_code(8) + + assert isinstance(code, str) + assert len(code) == 8 + assert code != site.code diff --git a/api/tests/test_containers_integration_tests/models/test_types_enum_text.py b/api/tests/test_containers_integration_tests/models/test_types_enum_text.py index 9cf96c1ca7..957b7145d3 100644 --- a/api/tests/test_containers_integration_tests/models/test_types_enum_text.py +++ b/api/tests/test_containers_integration_tests/models/test_types_enum_text.py @@ -4,8 +4,9 @@ from typing import Any, NamedTuple import pytest import sqlalchemy as sa +from graphon.model_runtime.entities.model_entities import ModelType from sqlalchemy import exc as sa_exc -from sqlalchemy import insert +from sqlalchemy import insert, select from sqlalchemy.engine import Connection, Engine from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column from sqlalchemy.sql.sqltypes import VARCHAR @@ -58,6 +59,13 @@ class _ColumnTest(_Base): long_value: Mapped[_EnumWithLongValue] = mapped_column(EnumText(enum_class=_EnumWithLongValue), nullable=False) +class _LegacyModelTypeRecord(_Base): + __tablename__ = "enum_text_legacy_model_type_test" + + id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) + model_type: Mapped[ModelType] = mapped_column(EnumText(enum_class=ModelType), nullable=False) + + def _first[T](it: Iterable[T]) -> T: ls = list(it) if not ls: @@ -129,12 +137,12 @@ class TestEnumText: session.commit() with Session(engine_with_containers) as session: - user = session.query(_User).where(_User.id == admin_user_id).first() + user = session.scalar(select(_User).where(_User.id == admin_user_id).limit(1)) assert user.user_type == _UserType.admin assert user.user_type_nullable is None with Session(engine_with_containers) as session: - user = session.query(_User).where(_User.id == normal_user_id).first() + user = session.scalar(select(_User).where(_User.id == normal_user_id).limit(1)) assert user.user_type == _UserType.normal assert user.user_type_nullable == _UserType.normal @@ -198,6 +206,26 @@ class TestEnumText: with pytest.raises(ValueError) as exc: with Session(engine_with_containers) as session: - _user = session.query(_User).where(_User.id == 1).first() + _user = session.scalar(select(_User).where(_User.id == 1).limit(1)) assert str(exc.value) == "'invalid' is not a valid _UserType" + + def test_select_legacy_model_type_values(self, engine_with_containers: Engine): + insertion_sql = """ + INSERT INTO enum_text_legacy_model_type_test (id, model_type) VALUES + (1, 'text-generation'), + (2, 'embeddings'), + (3, 'reranking'); + """ + with Session(engine_with_containers) as session: + session.execute(sa.text(insertion_sql)) + session.commit() + + with Session(engine_with_containers) as session: + records = session.scalars(select(_LegacyModelTypeRecord).order_by(_LegacyModelTypeRecord.id)).all() + + assert [record.model_type for record in records] == [ + ModelType.LLM, + ModelType.TEXT_EMBEDDING, + ModelType.RERANK, + ] diff --git a/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py b/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py new file mode 100644 index 0000000000..14c2263110 --- /dev/null +++ b/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py @@ -0,0 +1,170 @@ +""" +Integration tests for WorkflowNodeExecutionModel.created_by_account and .created_by_end_user. + +Migrated from unit_tests/models/test_workflow_trigger_log.py, replacing +monkeypatch.setattr(db.session, "scalar", ...) with real Account/EndUser rows +persisted in PostgreSQL so the db.session.get() call executes against the DB. +""" + +from collections.abc import Generator +from uuid import uuid4 + +import pytest +from sqlalchemy.orm import Session + +from models.account import Account +from models.enums import CreatorUserRole +from models.model import App, AppMode, EndUser +from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom + + +class TestWorkflowNodeExecutionModelCreatedBy: + """Integration tests for WorkflowNodeExecutionModel creator lookup properties.""" + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + """Automatically rollback session changes after each test.""" + yield + db_session_with_containers.rollback() + + def _create_account(self, db_session: Session) -> Account: + account = Account( + name="Test Account", + email=f"test_{uuid4()}@example.com", + password="hashed-password", + password_salt="salt", + interface_language="en-US", + timezone="UTC", + ) + db_session.add(account) + db_session.flush() + return account + + def _create_end_user(self, db_session: Session, tenant_id: str, app_id: str) -> EndUser: + end_user = EndUser( + tenant_id=tenant_id, + app_id=app_id, + type="service_api", + external_user_id=f"ext-{uuid4()}", + name="End User", + session_id=f"session-{uuid4()}", + ) + end_user.is_anonymous = False + db_session.add(end_user) + db_session.flush() + return end_user + + def _create_app(self, db_session: Session, tenant_id: str, created_by: str) -> App: + app = App( + tenant_id=tenant_id, + name=f"App {uuid4()}", + mode=AppMode.WORKFLOW, + enable_site=False, + enable_api=True, + is_demo=False, + is_public=False, + is_universal=False, + created_by=created_by, + updated_by=created_by, + ) + db_session.add(app) + db_session.flush() + return app + + def _make_execution( + self, tenant_id: str, app_id: str, created_by_role: str, created_by: str + ) -> WorkflowNodeExecutionModel: + return WorkflowNodeExecutionModel( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=str(uuid4()), + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + workflow_run_id=None, + index=1, + predecessor_node_id=None, + node_execution_id=None, + node_id="n1", + node_type="start", + title="Start", + inputs=None, + process_data=None, + outputs=None, + status="succeeded", + error=None, + elapsed_time=0.0, + execution_metadata=None, + created_by_role=created_by_role, + created_by=created_by, + ) + + def test_created_by_account_returns_account_when_role_is_account(self, db_session_with_containers: Session) -> None: + """created_by_account returns the Account row when role is ACCOUNT.""" + account = self._create_account(db_session_with_containers) + app = self._create_app(db_session_with_containers, str(uuid4()), account.id) + + execution = self._make_execution( + tenant_id=app.tenant_id, + app_id=app.id, + created_by_role=CreatorUserRole.ACCOUNT.value, + created_by=account.id, + ) + + result = execution.created_by_account + + assert result is not None + assert result.id == account.id + + def test_created_by_account_returns_none_when_role_is_end_user(self, db_session_with_containers: Session) -> None: + """created_by_account returns None when role is END_USER, even if an Account exists.""" + account = self._create_account(db_session_with_containers) + app = self._create_app(db_session_with_containers, str(uuid4()), account.id) + + execution = self._make_execution( + tenant_id=app.tenant_id, + app_id=app.id, + created_by_role=CreatorUserRole.END_USER.value, + created_by=account.id, + ) + + result = execution.created_by_account + + assert result is None + + def test_created_by_end_user_returns_end_user_when_role_is_end_user( + self, db_session_with_containers: Session + ) -> None: + """created_by_end_user returns the EndUser row when role is END_USER.""" + account = self._create_account(db_session_with_containers) + tenant_id = str(uuid4()) + app = self._create_app(db_session_with_containers, tenant_id, account.id) + end_user = self._create_end_user(db_session_with_containers, tenant_id, app.id) + + execution = self._make_execution( + tenant_id=tenant_id, + app_id=app.id, + created_by_role=CreatorUserRole.END_USER.value, + created_by=end_user.id, + ) + + result = execution.created_by_end_user + + assert result is not None + assert result.id == end_user.id + + def test_created_by_end_user_returns_none_when_role_is_account(self, db_session_with_containers: Session) -> None: + """created_by_end_user returns None when role is ACCOUNT, even if an EndUser exists.""" + account = self._create_account(db_session_with_containers) + tenant_id = str(uuid4()) + app = self._create_app(db_session_with_containers, tenant_id, account.id) + end_user = self._create_end_user(db_session_with_containers, tenant_id, app.id) + + execution = self._make_execution( + tenant_id=tenant_id, + app_id=app.id, + created_by_role=CreatorUserRole.ACCOUNT.value, + created_by=end_user.id, + ) + + result = execution.created_by_end_user + + assert result is None diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index d28cfda159..64c93ac07c 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -220,7 +220,6 @@ class TestDeleteRunsWithRelated: created_by=test_scope.user_id, ) pause = WorkflowPause( - id=str(uuid4()), workflow_id=test_scope.workflow_id, workflow_run_id=workflow_run.id, state_object_key=f"workflow-state-{uuid4()}.json", @@ -280,7 +279,6 @@ class TestCountRunsWithRelated: created_by=test_scope.user_id, ) pause = WorkflowPause( - id=str(uuid4()), workflow_id=test_scope.workflow_id, workflow_run_id=workflow_run.id, state_object_key=f"workflow-state-{uuid4()}.json", @@ -544,7 +542,6 @@ class TestPrivateWorkflowPauseEntity: status=WorkflowExecutionStatus.RUNNING, ) pause = WorkflowPause( - id=str(uuid4()), workflow_id=test_scope.workflow_id, workflow_run_id=workflow_run.id, state_object_key=f"workflow-state-{uuid4()}.json", @@ -574,7 +571,6 @@ class TestPrivateWorkflowPauseEntity: ) state_key = f"workflow-state-{uuid4()}.json" pause = WorkflowPause( - id=str(uuid4()), workflow_id=test_scope.workflow_id, workflow_run_id=workflow_run.id, state_object_key=state_key, @@ -606,7 +602,6 @@ class TestPrivateWorkflowPauseEntity: ) state_key = f"workflow-state-{uuid4()}.json" pause = WorkflowPause( - id=str(uuid4()), workflow_id=test_scope.workflow_id, workflow_run_id=workflow_run.id, state_object_key=state_key, @@ -672,7 +667,6 @@ class TestBuildHumanInputRequiredReason: status=WorkflowExecutionStatus.RUNNING, ) pause = WorkflowPause( - id=str(uuid4()), workflow_id=test_scope.workflow_id, workflow_run_id=workflow_run.id, state_object_key=f"workflow-state-{uuid4()}.json", diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_node_execution_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_node_execution_repository.py new file mode 100644 index 0000000000..22e0aa34ff --- /dev/null +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_node_execution_repository.py @@ -0,0 +1,395 @@ +"""Testcontainers integration tests for SQLAlchemyWorkflowNodeExecutionRepository.""" + +from __future__ import annotations + +import json +from datetime import datetime +from decimal import Decimal +from uuid import uuid4 + +from graphon.entities import WorkflowNodeExecution +from graphon.enums import ( + BuiltinNodeTypes, + WorkflowNodeExecutionMetadataKey, + WorkflowNodeExecutionStatus, +) +from graphon.model_runtime.utils.encoders import jsonable_encoder +from sqlalchemy import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository +from core.repositories.factory import OrderConfig +from models.account import Account, Tenant +from models.enums import CreatorUserRole +from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom + + +def _create_account_with_tenant(session: Session) -> Account: + tenant = Tenant(name="Test Workspace") + session.add(tenant) + session.flush() + + account = Account(name="test", email=f"test-{uuid4()}@example.com") + session.add(account) + session.flush() + + account._current_tenant = tenant + return account + + +def _make_repo(session: Session, account: Account, app_id: str) -> SQLAlchemyWorkflowNodeExecutionRepository: + engine = session.get_bind() + assert isinstance(engine, Engine) + return SQLAlchemyWorkflowNodeExecutionRepository( + session_factory=sessionmaker(bind=engine, expire_on_commit=False), + user=account, + app_id=app_id, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + ) + + +def _create_node_execution_model( + session: Session, + *, + tenant_id: str, + app_id: str, + workflow_id: str, + workflow_run_id: str, + index: int = 1, + status: WorkflowNodeExecutionStatus = WorkflowNodeExecutionStatus.RUNNING, +) -> WorkflowNodeExecutionModel: + model = WorkflowNodeExecutionModel( + id=str(uuid4()), + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + workflow_run_id=workflow_run_id, + index=index, + predecessor_node_id=None, + node_execution_id=str(uuid4()), + node_id=f"node-{index}", + node_type=BuiltinNodeTypes.START, + title=f"Test Node {index}", + inputs='{"input_key": "input_value"}', + process_data='{"process_key": "process_value"}', + outputs='{"output_key": "output_value"}', + status=status, + error=None, + elapsed_time=1.5, + execution_metadata="{}", + created_at=datetime.now(), + created_by_role=CreatorUserRole.ACCOUNT, + created_by=str(uuid4()), + finished_at=None, + ) + session.add(model) + session.flush() + return model + + +class TestSave: + def test_save_new_record(self, db_session_with_containers: Session) -> None: + account = _create_account_with_tenant(db_session_with_containers) + app_id = str(uuid4()) + repo = _make_repo(db_session_with_containers, account, app_id) + + execution = WorkflowNodeExecution( + id=str(uuid4()), + workflow_id=str(uuid4()), + node_execution_id=str(uuid4()), + workflow_execution_id=str(uuid4()), + index=1, + predecessor_node_id=None, + node_id="node-1", + node_type=BuiltinNodeTypes.START, + title="Test Node", + inputs={"input_key": "input_value"}, + process_data={"process_key": "process_value"}, + outputs={"result": "success"}, + status=WorkflowNodeExecutionStatus.RUNNING, + error=None, + elapsed_time=1.5, + metadata={WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: 100}, + created_at=datetime.now(), + finished_at=None, + ) + + repo.save(execution) + + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + with sessionmaker(bind=engine, expire_on_commit=False)() as verify_session: + saved = verify_session.get(WorkflowNodeExecutionModel, execution.id) + assert saved is not None + assert saved.tenant_id == account.current_tenant_id + assert saved.app_id == app_id + assert saved.node_id == "node-1" + assert saved.status == WorkflowNodeExecutionStatus.RUNNING + + def test_save_updates_existing_record(self, db_session_with_containers: Session) -> None: + account = _create_account_with_tenant(db_session_with_containers) + repo = _make_repo(db_session_with_containers, account, str(uuid4())) + + execution = WorkflowNodeExecution( + id=str(uuid4()), + workflow_id=str(uuid4()), + node_execution_id=str(uuid4()), + workflow_execution_id=str(uuid4()), + index=1, + predecessor_node_id=None, + node_id="node-1", + node_type=BuiltinNodeTypes.START, + title="Test Node", + inputs=None, + process_data=None, + outputs=None, + status=WorkflowNodeExecutionStatus.RUNNING, + error=None, + elapsed_time=0.0, + metadata=None, + created_at=datetime.now(), + finished_at=None, + ) + + repo.save(execution) + + execution.status = WorkflowNodeExecutionStatus.SUCCEEDED + execution.elapsed_time = 2.5 + repo.save(execution) + + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + with sessionmaker(bind=engine, expire_on_commit=False)() as verify_session: + saved = verify_session.get(WorkflowNodeExecutionModel, execution.id) + assert saved is not None + assert saved.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert saved.elapsed_time == 2.5 + + +class TestGetByWorkflowExecution: + def test_returns_executions_ordered(self, db_session_with_containers: Session) -> None: + account = _create_account_with_tenant(db_session_with_containers) + tenant_id = account.current_tenant_id + app_id = str(uuid4()) + workflow_id = str(uuid4()) + workflow_run_id = str(uuid4()) + repo = _make_repo(db_session_with_containers, account, app_id) + + _create_node_execution_model( + db_session_with_containers, + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + index=1, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + ) + _create_node_execution_model( + db_session_with_containers, + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + index=2, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + ) + db_session_with_containers.commit() + + order_config = OrderConfig(order_by=["index"], order_direction="desc") + result = repo.get_by_workflow_execution( + workflow_execution_id=workflow_run_id, + order_config=order_config, + ) + + assert len(result) == 2 + assert result[0].index == 2 + assert result[1].index == 1 + assert all(isinstance(r, WorkflowNodeExecution) for r in result) + + def test_excludes_paused_executions(self, db_session_with_containers: Session) -> None: + account = _create_account_with_tenant(db_session_with_containers) + tenant_id = account.current_tenant_id + app_id = str(uuid4()) + workflow_id = str(uuid4()) + workflow_run_id = str(uuid4()) + repo = _make_repo(db_session_with_containers, account, app_id) + + _create_node_execution_model( + db_session_with_containers, + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + index=1, + status=WorkflowNodeExecutionStatus.RUNNING, + ) + _create_node_execution_model( + db_session_with_containers, + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + index=2, + status=WorkflowNodeExecutionStatus.PAUSED, + ) + db_session_with_containers.commit() + + result = repo.get_by_workflow_execution(workflow_execution_id=workflow_run_id) + + assert len(result) == 1 + assert result[0].index == 1 + + +class TestToDbModel: + def test_converts_domain_to_db_model(self, db_session_with_containers: Session) -> None: + account = _create_account_with_tenant(db_session_with_containers) + app_id = str(uuid4()) + repo = _make_repo(db_session_with_containers, account, app_id) + + domain_model = WorkflowNodeExecution( + id="test-id", + workflow_id="test-workflow-id", + node_execution_id="test-node-execution-id", + workflow_execution_id="test-workflow-run-id", + index=1, + predecessor_node_id="test-predecessor-id", + node_id="test-node-id", + node_type=BuiltinNodeTypes.START, + title="Test Node", + inputs={"input_key": "input_value"}, + process_data={"process_key": "process_value"}, + outputs={"output_key": "output_value"}, + status=WorkflowNodeExecutionStatus.RUNNING, + error=None, + elapsed_time=1.5, + metadata={ + WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: 100, + WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: Decimal("0.0"), + }, + created_at=datetime.now(), + finished_at=None, + ) + + db_model = repo._to_db_model(domain_model) + + assert isinstance(db_model, WorkflowNodeExecutionModel) + assert db_model.id == domain_model.id + assert db_model.tenant_id == account.current_tenant_id + assert db_model.app_id == app_id + assert db_model.workflow_id == domain_model.workflow_id + assert db_model.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN + assert db_model.workflow_run_id == domain_model.workflow_execution_id + assert db_model.index == domain_model.index + assert db_model.predecessor_node_id == domain_model.predecessor_node_id + assert db_model.node_execution_id == domain_model.node_execution_id + assert db_model.node_id == domain_model.node_id + assert db_model.node_type == domain_model.node_type + assert db_model.title == domain_model.title + assert db_model.inputs_dict == domain_model.inputs + assert db_model.process_data_dict == domain_model.process_data + assert db_model.outputs_dict == domain_model.outputs + assert db_model.execution_metadata_dict == jsonable_encoder(domain_model.metadata) + assert db_model.status == domain_model.status + assert db_model.error == domain_model.error + assert db_model.elapsed_time == domain_model.elapsed_time + assert db_model.created_at == domain_model.created_at + assert db_model.created_by_role == CreatorUserRole.ACCOUNT + assert db_model.created_by == account.id + assert db_model.finished_at == domain_model.finished_at + + +class TestToDomainModel: + def test_converts_db_to_domain_model(self, db_session_with_containers: Session) -> None: + account = _create_account_with_tenant(db_session_with_containers) + app_id = str(uuid4()) + repo = _make_repo(db_session_with_containers, account, app_id) + + inputs_dict = {"input_key": "input_value"} + process_data_dict = {"process_key": "process_value"} + outputs_dict = {"output_key": "output_value"} + metadata_dict = {str(WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS): 100} + now = datetime.now() + + db_model = WorkflowNodeExecutionModel() + db_model.id = "test-id" + db_model.tenant_id = account.current_tenant_id + db_model.app_id = app_id + db_model.workflow_id = "test-workflow-id" + db_model.triggered_from = "workflow-run" + db_model.workflow_run_id = "test-workflow-run-id" + db_model.index = 1 + db_model.predecessor_node_id = "test-predecessor-id" + db_model.node_execution_id = "test-node-execution-id" + db_model.node_id = "test-node-id" + db_model.node_type = BuiltinNodeTypes.START + db_model.title = "Test Node" + db_model.inputs = json.dumps(inputs_dict) + db_model.process_data = json.dumps(process_data_dict) + db_model.outputs = json.dumps(outputs_dict) + db_model.status = WorkflowNodeExecutionStatus.RUNNING + db_model.error = None + db_model.elapsed_time = 1.5 + db_model.execution_metadata = json.dumps(metadata_dict) + db_model.created_at = now + db_model.created_by_role = "account" + db_model.created_by = account.id + db_model.finished_at = None + + domain_model = repo._to_domain_model(db_model) + + assert isinstance(domain_model, WorkflowNodeExecution) + assert domain_model.id == "test-id" + assert domain_model.workflow_id == "test-workflow-id" + assert domain_model.workflow_execution_id == "test-workflow-run-id" + assert domain_model.index == 1 + assert domain_model.predecessor_node_id == "test-predecessor-id" + assert domain_model.node_execution_id == "test-node-execution-id" + assert domain_model.node_id == "test-node-id" + assert domain_model.node_type == BuiltinNodeTypes.START + assert domain_model.title == "Test Node" + assert domain_model.inputs == inputs_dict + assert domain_model.process_data == process_data_dict + assert domain_model.outputs == outputs_dict + assert domain_model.status == WorkflowNodeExecutionStatus.RUNNING + assert domain_model.error is None + assert domain_model.elapsed_time == 1.5 + assert domain_model.metadata == {WorkflowNodeExecutionMetadataKey(k): v for k, v in metadata_dict.items()} + assert domain_model.created_at == now + assert domain_model.finished_at is None + + def test_domain_model_without_offload_data(self, db_session_with_containers: Session) -> None: + account = _create_account_with_tenant(db_session_with_containers) + repo = _make_repo(db_session_with_containers, account, str(uuid4())) + + process_data = {"normal": "data"} + db_model = WorkflowNodeExecutionModel() + db_model.id = str(uuid4()) + db_model.tenant_id = account.current_tenant_id + db_model.app_id = str(uuid4()) + db_model.workflow_id = str(uuid4()) + db_model.triggered_from = "workflow-run" + db_model.workflow_run_id = None + db_model.index = 1 + db_model.predecessor_node_id = None + db_model.node_execution_id = str(uuid4()) + db_model.node_id = "test-node-id" + db_model.node_type = "llm" + db_model.title = "Test Node" + db_model.inputs = None + db_model.process_data = json.dumps(process_data) + db_model.outputs = None + db_model.status = "succeeded" + db_model.error = None + db_model.elapsed_time = 1.5 + db_model.execution_metadata = "{}" + db_model.created_at = datetime.now() + db_model.created_by_role = "account" + db_model.created_by = account.id + db_model.finished_at = None + + domain_model = repo._to_domain_model(db_model) + + assert domain_model.process_data == process_data + assert domain_model.process_data_truncated is False + assert domain_model.get_truncated_process_data() is None diff --git a/api/tests/integration_tests/vdb/__mock/__init__.py b/api/tests/test_containers_integration_tests/services/rag_pipeline/__init__.py similarity index 100% rename from api/tests/integration_tests/vdb/__mock/__init__.py rename to api/tests/test_containers_integration_tests/services/rag_pipeline/__init__.py diff --git a/api/tests/test_containers_integration_tests/services/rag_pipeline/test_rag_pipeline_service_db.py b/api/tests/test_containers_integration_tests/services/rag_pipeline/test_rag_pipeline_service_db.py new file mode 100644 index 0000000000..8fc1809a46 --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/rag_pipeline/test_rag_pipeline_service_db.py @@ -0,0 +1,255 @@ +""" +Integration tests for RagPipelineService methods that interact with the database. + +Migrated from unit_tests/services/rag_pipeline/test_rag_pipeline_service.py, replacing +db.session.scalar/commit/delete mocker patches with real PostgreSQL operations. + +Covers: +- get_pipeline: Dataset and Pipeline lookups +- update_customized_pipeline_template: find + unique-name check + commit +- delete_customized_pipeline_template: find + delete + commit +""" + +from collections.abc import Generator +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +from models.dataset import Dataset, Pipeline, PipelineCustomizedTemplate +from models.enums import DataSourceType +from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity +from services.rag_pipeline.rag_pipeline import RagPipelineService + + +class TestRagPipelineServiceGetPipeline: + """Integration tests for RagPipelineService.get_pipeline.""" + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + yield + db_session_with_containers.rollback() + + def _make_service(self, flask_app_with_containers) -> RagPipelineService: + with ( + patch( + "services.rag_pipeline.rag_pipeline.DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository", + return_value=None, + ), + patch( + "services.rag_pipeline.rag_pipeline.DifyAPIRepositoryFactory.create_api_workflow_run_repository", + return_value=None, + ), + ): + session_factory = sessionmaker(bind=flask_app_with_containers.extensions["sqlalchemy"].engine) + return RagPipelineService(session_maker=session_factory) + + def _create_pipeline(self, db_session: Session, tenant_id: str, created_by: str) -> Pipeline: + pipeline = Pipeline( + tenant_id=tenant_id, + name=f"Pipeline {uuid4()}", + description="", + created_by=created_by, + ) + db_session.add(pipeline) + db_session.flush() + return pipeline + + def _create_dataset( + self, db_session: Session, tenant_id: str, created_by: str, pipeline_id: str | None = None + ) -> Dataset: + dataset = Dataset( + tenant_id=tenant_id, + name=f"Dataset {uuid4()}", + data_source_type=DataSourceType.UPLOAD_FILE, + created_by=created_by, + pipeline_id=pipeline_id, + ) + db_session.add(dataset) + db_session.flush() + return dataset + + def test_get_pipeline_raises_when_dataset_not_found( + self, db_session_with_containers: Session, flask_app_with_containers + ) -> None: + """get_pipeline raises ValueError when dataset does not exist.""" + service = self._make_service(flask_app_with_containers) + + with pytest.raises(ValueError, match="Dataset not found"): + service.get_pipeline(tenant_id=str(uuid4()), dataset_id=str(uuid4())) + + def test_get_pipeline_raises_when_pipeline_not_found( + self, db_session_with_containers: Session, flask_app_with_containers + ) -> None: + """get_pipeline raises ValueError when dataset exists but has no linked pipeline.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + dataset = self._create_dataset(db_session_with_containers, tenant_id, created_by, pipeline_id=None) + db_session_with_containers.flush() + + service = self._make_service(flask_app_with_containers) + + with pytest.raises(ValueError, match="(Dataset not found|Pipeline not found)"): + service.get_pipeline(tenant_id=tenant_id, dataset_id=dataset.id) + + def test_get_pipeline_returns_pipeline_when_found( + self, db_session_with_containers: Session, flask_app_with_containers + ) -> None: + """get_pipeline returns the Pipeline when both Dataset and Pipeline exist.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + + pipeline = self._create_pipeline(db_session_with_containers, tenant_id, created_by) + dataset = self._create_dataset(db_session_with_containers, tenant_id, created_by, pipeline_id=pipeline.id) + db_session_with_containers.flush() + + service = self._make_service(flask_app_with_containers) + + result = service.get_pipeline(tenant_id=tenant_id, dataset_id=dataset.id) + + assert result.id == pipeline.id + + +class TestUpdateCustomizedPipelineTemplate: + """Integration tests for RagPipelineService.update_customized_pipeline_template.""" + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + yield + db_session_with_containers.rollback() + + def _create_template( + self, db_session: Session, tenant_id: str, created_by: str, name: str = "Template" + ) -> PipelineCustomizedTemplate: + template = PipelineCustomizedTemplate( + tenant_id=tenant_id, + name=name, + description="Original description", + chunk_structure="fixed_size", + icon={"type": "emoji", "value": "📄"}, + position=1, + yaml_content="{}", + install_count=0, + language="en-US", + created_by=created_by, + ) + db_session.add(template) + db_session.flush() + return template + + def test_update_template_succeeds(self, db_session_with_containers: Session, flask_app_with_containers) -> None: + """update_customized_pipeline_template updates name and description.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + template = self._create_template(db_session_with_containers, tenant_id, created_by) + db_session_with_containers.flush() + + fake_user = SimpleNamespace(id=created_by, current_tenant_id=tenant_id) + + with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): + info = PipelineTemplateInfoEntity( + name="Updated Name", + description="Updated description", + icon_info=IconInfo(icon="🔥"), + ) + result = RagPipelineService.update_customized_pipeline_template(template.id, info) + + assert result.name == "Updated Name" + assert result.description == "Updated description" + + def test_update_template_raises_when_not_found( + self, db_session_with_containers: Session, flask_app_with_containers + ) -> None: + """update_customized_pipeline_template raises ValueError when template doesn't exist.""" + fake_user = SimpleNamespace(id=str(uuid4()), current_tenant_id=str(uuid4())) + + with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): + info = PipelineTemplateInfoEntity( + name="New Name", + description="desc", + icon_info=IconInfo(icon="📄"), + ) + with pytest.raises(ValueError, match="Customized pipeline template not found"): + RagPipelineService.update_customized_pipeline_template(str(uuid4()), info) + + def test_update_template_raises_on_duplicate_name( + self, db_session_with_containers: Session, flask_app_with_containers + ) -> None: + """update_customized_pipeline_template raises ValueError when new name already exists.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + template1 = self._create_template(db_session_with_containers, tenant_id, created_by, name="Original") + self._create_template(db_session_with_containers, tenant_id, created_by, name="Duplicate") + db_session_with_containers.flush() + + fake_user = SimpleNamespace(id=created_by, current_tenant_id=tenant_id) + + with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): + info = PipelineTemplateInfoEntity( + name="Duplicate", + description="desc", + icon_info=IconInfo(icon="📄"), + ) + with pytest.raises(ValueError, match="Template name is already exists"): + RagPipelineService.update_customized_pipeline_template(template1.id, info) + + +class TestDeleteCustomizedPipelineTemplate: + """Integration tests for RagPipelineService.delete_customized_pipeline_template.""" + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + yield + db_session_with_containers.rollback() + + def _create_template(self, db_session: Session, tenant_id: str, created_by: str) -> PipelineCustomizedTemplate: + template = PipelineCustomizedTemplate( + tenant_id=tenant_id, + name=f"Template {uuid4()}", + description="Description", + chunk_structure="fixed_size", + icon={"type": "emoji", "value": "📄"}, + position=1, + yaml_content="{}", + install_count=0, + language="en-US", + created_by=created_by, + ) + db_session.add(template) + db_session.flush() + return template + + def test_delete_template_succeeds(self, db_session_with_containers: Session, flask_app_with_containers) -> None: + """delete_customized_pipeline_template removes the template from the DB.""" + tenant_id = str(uuid4()) + created_by = str(uuid4()) + template = self._create_template(db_session_with_containers, tenant_id, created_by) + template_id = template.id + db_session_with_containers.flush() + + fake_user = SimpleNamespace(id=created_by, current_tenant_id=tenant_id) + + with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): + RagPipelineService.delete_customized_pipeline_template(template_id) + + # Verify the record is deleted within the same context + from sqlalchemy import select + + from extensions.ext_database import db as ext_db + + remaining = ext_db.session.scalar( + select(PipelineCustomizedTemplate).where(PipelineCustomizedTemplate.id == template_id) + ) + assert remaining is None + + def test_delete_template_raises_when_not_found( + self, db_session_with_containers: Session, flask_app_with_containers + ) -> None: + """delete_customized_pipeline_template raises ValueError when template doesn't exist.""" + fake_user = SimpleNamespace(id=str(uuid4()), current_tenant_id=str(uuid4())) + + with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): + with pytest.raises(ValueError, match="Customized pipeline template not found"): + RagPipelineService.delete_customized_pipeline_template(str(uuid4())) diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 33955d5d84..6c15587058 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -1,56 +1,104 @@ +from __future__ import annotations + +import base64 import json +from types import SimpleNamespace from unittest.mock import MagicMock, patch +from uuid import uuid4 import pytest import yaml from faker import Faker +from graphon.enums import BuiltinNodeTypes -from models.model import App, AppModelConfig +from core.trigger.constants import ( + TRIGGER_PLUGIN_NODE_TYPE, + TRIGGER_SCHEDULE_NODE_TYPE, + TRIGGER_WEBHOOK_NODE_TYPE, +) +from extensions.ext_redis import redis_client +from models import Account, AppMode +from models.model import AppModelConfig, IconType +from services import app_dsl_service from services.account_service import AccountService, TenantService -from services.app_dsl_service import AppDslService, ImportMode, ImportStatus +from services.app_dsl_service import ( + CHECK_DEPENDENCIES_REDIS_KEY_PREFIX, + CURRENT_DSL_VERSION, + DSL_MAX_SIZE, + IMPORT_INFO_REDIS_EXPIRY, + IMPORT_INFO_REDIS_KEY_PREFIX, + AppDslService, + CheckDependenciesPendingData, + ImportMode, + ImportStatus, + PendingData, + _check_version_compatibility, +) from services.app_service import AppService from tests.test_containers_integration_tests.helpers import generate_valid_password +_DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001" +_DEFAULT_ACCOUNT_ID = "00000000-0000-0000-0000-000000000002" + + +def _account_mock(*, tenant_id: str = _DEFAULT_TENANT_ID, account_id: str = _DEFAULT_ACCOUNT_ID) -> MagicMock: + account = MagicMock(spec=Account) + account.current_tenant_id = tenant_id + account.id = account_id + return account + + +def _yaml_dump(data: dict) -> str: + return yaml.safe_dump(data, allow_unicode=True) + + +def _workflow_yaml(*, version: str = CURRENT_DSL_VERSION) -> str: + return _yaml_dump( + { + "version": version, + "kind": "app", + "app": {"name": "My App", "mode": AppMode.WORKFLOW.value}, + "workflow": {"graph": {"nodes": []}, "features": {}}, + } + ) + + +def _pending_yaml_content(version: str = "99.0.0") -> bytes: + return (f'version: "{version}"\nkind: app\napp:\n name: Loop Test\n mode: workflow\n').encode() + class TestAppDslService: """Integration tests for AppDslService using testcontainers.""" + @pytest.fixture + def app(self, flask_app_with_containers): + return flask_app_with_containers + @pytest.fixture def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" with ( patch("services.app_dsl_service.WorkflowService") as mock_workflow_service, patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service, - patch("services.app_dsl_service.WorkflowDraftVariableService") as mock_draft_variable_service, - patch("services.app_dsl_service.ssrf_proxy") as mock_ssrf_proxy, - patch("services.app_dsl_service.redis_client") as mock_redis_client, patch("services.app_dsl_service.app_was_created") as mock_app_was_created, - patch("services.app_dsl_service.app_model_config_was_updated") as mock_app_model_config_was_updated, patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, patch("services.app_service.FeatureService") as mock_feature_service, patch("services.app_service.EnterpriseService") as mock_enterprise_service, ): - # Setup default mock returns mock_workflow_service.return_value.get_draft_workflow.return_value = None mock_workflow_service.return_value.sync_draft_workflow.return_value = MagicMock() mock_dependencies_service.generate_latest_dependencies.return_value = [] mock_dependencies_service.get_leaked_dependencies.return_value = [] mock_dependencies_service.generate_dependencies.return_value = [] - mock_draft_variable_service.return_value.delete_workflow_variables.return_value = None - mock_ssrf_proxy.get.return_value.content = b"test content" - mock_ssrf_proxy.get.return_value.raise_for_status.return_value = None - mock_redis_client.setex.return_value = None - mock_redis_client.get.return_value = None - mock_redis_client.delete.return_value = None mock_app_was_created.send.return_value = None - mock_app_model_config_was_updated.send.return_value = None - # Mock ModelManager for app service mock_model_instance = mock_model_manager.return_value mock_model_instance.get_default_model_instance.return_value = None - mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo") + mock_model_instance.get_default_provider_model_name.return_value = ( + "openai", + "gpt-3.5-turbo", + ) - # Mock FeatureService and EnterpriseService mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None @@ -58,34 +106,16 @@ class TestAppDslService: yield { "workflow_service": mock_workflow_service, "dependencies_service": mock_dependencies_service, - "draft_variable_service": mock_draft_variable_service, - "ssrf_proxy": mock_ssrf_proxy, - "redis_client": mock_redis_client, "app_was_created": mock_app_was_created, - "app_model_config_was_updated": mock_app_model_config_was_updated, "model_manager": mock_model_manager, "feature_service": mock_feature_service, "enterprise_service": mock_enterprise_service, } def _create_test_app_and_account(self, db_session_with_containers, mock_external_service_dependencies): - """ - Helper method to create a test app and account for testing. - - Args: - db_session_with_containers: Database session from testcontainers infrastructure - mock_external_service_dependencies: Mock dependencies - - Returns: - tuple: (app, account) - Created app and account instances - """ fake = Faker() - - # Setup mocks for account creation with patch("services.account_service.FeatureService") as mock_account_feature_service: mock_account_feature_service.get_system_features.return_value.is_allow_register = True - - # Create account and tenant first account = AccountService.create_account( email=fake.email(), name=fake.name(), @@ -94,8 +124,6 @@ class TestAppDslService: ) TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - - # Setup app creation arguments app_args = { "name": fake.company(), "description": fake.text(max_nb_chars=100), @@ -106,17 +134,11 @@ class TestAppDslService: "api_rph": 100, "api_rpm": 10, } - - # Create app app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) - return app, account - def _create_simple_yaml_content(self, app_name="Test App", app_mode="chat"): - """ - Helper method to create simple YAML content for testing. - """ + def _create_simple_yaml_content(self, app_name: str = "Test App", app_mode: str = "chat") -> str: yaml_data = { "version": "0.3.0", "kind": "app", @@ -145,88 +167,739 @@ class TestAppDslService: } return yaml.dump(yaml_data, allow_unicode=True) - def test_import_app_missing_yaml_content(self, db_session_with_containers, mock_external_service_dependencies): - """ - Test app import with missing YAML content. - """ - fake = Faker() - app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + # ── Version Compatibility ───────────────────────────────────────── - # Import app without YAML content - dsl_service = AppDslService(db_session_with_containers) - result = dsl_service.import_app( - account=account, - import_mode=ImportMode.YAML_CONTENT, - name="Missing Content App", - ) + def test_check_version_compatibility_invalid_version_returns_failed(self): + assert _check_version_compatibility("not-a-version") == ImportStatus.FAILED - # Verify import failed - assert result.status == ImportStatus.FAILED - assert result.app_id is None - assert "yaml_content is required" in result.error - assert result.imported_dsl_version == "" + def test_check_version_compatibility_newer_version_returns_pending(self): + assert _check_version_compatibility("99.0.0") == ImportStatus.PENDING - # Verify no app was created in database - apps_count = db_session_with_containers.query(App).where(App.tenant_id == account.current_tenant_id).count() - assert apps_count == 1 # Only the original test app + def test_check_version_compatibility_major_older_returns_pending(self, monkeypatch): + monkeypatch.setattr(app_dsl_service, "CURRENT_DSL_VERSION", "1.0.0") + assert _check_version_compatibility("0.9.9") == ImportStatus.PENDING - def test_import_app_missing_yaml_url(self, db_session_with_containers, mock_external_service_dependencies): - """ - Test app import with missing YAML URL. - """ - fake = Faker() - app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + def test_check_version_compatibility_minor_older_returns_completed_with_warnings( + self, + ): + assert _check_version_compatibility("0.5.0") == ImportStatus.COMPLETED_WITH_WARNINGS - # Import app without YAML URL - dsl_service = AppDslService(db_session_with_containers) - result = dsl_service.import_app( - account=account, - import_mode=ImportMode.YAML_URL, - name="Missing URL App", - ) + def test_check_version_compatibility_equal_returns_completed(self): + assert _check_version_compatibility(CURRENT_DSL_VERSION) == ImportStatus.COMPLETED - # Verify import failed - assert result.status == ImportStatus.FAILED - assert result.app_id is None - assert "yaml_url is required" in result.error - assert result.imported_dsl_version == "" + # ── Import: Validation ──────────────────────────────────────────── - # Verify no app was created in database - apps_count = db_session_with_containers.query(App).where(App.tenant_id == account.current_tenant_id).count() - assert apps_count == 1 # Only the original test app - - def test_import_app_invalid_import_mode(self, db_session_with_containers, mock_external_service_dependencies): - """ - Test app import with invalid import mode. - """ - fake = Faker() - app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) - - # Create YAML content - yaml_content = self._create_simple_yaml_content(fake.company(), "chat") - - # Import app with invalid mode should raise ValueError - dsl_service = AppDslService(db_session_with_containers) - with pytest.raises(ValueError, match="Invalid import_mode: invalid-mode"): - dsl_service.import_app( - account=account, + def test_import_app_invalid_import_mode_raises_value_error(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + with pytest.raises(ValueError, match="Invalid import_mode"): + service.import_app( + account=_account_mock(), import_mode="invalid-mode", - yaml_content=yaml_content, - name="Invalid Mode App", + yaml_content="version: '0.1.0'", ) - # Verify no app was created in database - apps_count = db_session_with_containers.query(App).where(App.tenant_id == account.current_tenant_id).count() - assert apps_count == 1 # Only the original test app + def test_import_app_missing_yaml_content(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content=None, + ) + assert result.status == ImportStatus.FAILED + assert "yaml_content is required" in result.error - def test_export_dsl_chat_app_success(self, db_session_with_containers, mock_external_service_dependencies): - """ - Test successful DSL export for chat app. - """ - fake = Faker() + def test_import_app_missing_yaml_url(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_URL, + yaml_url=None, + ) + assert result.status == ImportStatus.FAILED + assert "yaml_url is required" in result.error + + def test_import_app_yaml_not_mapping_returns_failed(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content="[]", + ) + assert result.status == ImportStatus.FAILED + assert "content must be a mapping" in result.error + + def test_import_app_version_not_str_returns_failed(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + yaml_content = _yaml_dump({"version": 1, "kind": "app", "app": {"name": "x", "mode": "workflow"}}) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content=yaml_content, + ) + assert result.status == ImportStatus.FAILED + assert "Invalid version type" in result.error + + def test_import_app_missing_app_data_returns_failed(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content=_yaml_dump({"version": "0.6.0", "kind": "app"}), + ) + assert result.status == ImportStatus.FAILED + assert "Missing app data" in result.error + + def test_import_app_yaml_error_returns_failed(self, db_session_with_containers, monkeypatch): + def bad_safe_load(_content: str): + raise yaml.YAMLError("bad") + + monkeypatch.setattr(app_dsl_service.yaml, "safe_load", bad_safe_load) + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content="x: y", + ) + assert result.status == ImportStatus.FAILED + assert result.error.startswith("Invalid YAML format:") + + def test_import_app_unexpected_error_returns_failed(self, db_session_with_containers, monkeypatch): + monkeypatch.setattr( + AppDslService, + "_create_or_update_app", + lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("oops")), + ) + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content=_workflow_yaml(), + ) + assert result.status == ImportStatus.FAILED + assert result.error == "oops" + + # ── Import: YAML URL ────────────────────────────────────────────── + + def test_import_app_yaml_url_fetch_error_returns_failed(self, db_session_with_containers, monkeypatch): + monkeypatch.setattr( + app_dsl_service.ssrf_proxy, + "get", + lambda _url, **_kw: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_URL, + yaml_url="https://example.com/a.yml", + ) + assert result.status == ImportStatus.FAILED + assert "Error fetching YAML from URL: boom" in result.error + + def test_import_app_yaml_url_empty_content_returns_failed(self, db_session_with_containers, monkeypatch): + response = MagicMock() + response.content = b"" + response.raise_for_status.return_value = None + monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", lambda _url, **_kw: response) + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_URL, + yaml_url="https://example.com/a.yml", + ) + assert result.status == ImportStatus.FAILED + assert "Empty content" in result.error + + def test_import_app_yaml_url_file_too_large_returns_failed(self, db_session_with_containers, monkeypatch): + response = MagicMock() + response.content = b"x" * (DSL_MAX_SIZE + 1) + response.raise_for_status.return_value = None + monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", lambda _url, **_kw: response) + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_URL, + yaml_url="https://example.com/a.yml", + ) + assert result.status == ImportStatus.FAILED + assert "File size exceeds" in result.error + + def test_import_app_yaml_url_user_attachments_keeps_original_url(self, db_session_with_containers, monkeypatch): + yaml_url = "https://github.com/user-attachments/files/24290802/loop-test.yml" + yaml_bytes = _pending_yaml_content() + + requested_urls: list[str] = [] + + def fake_get(url: str, **kwargs): + requested_urls.append(url) + response = MagicMock() + response.content = yaml_bytes + response.raise_for_status.return_value = None + return response + + monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get) + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_URL, + yaml_url=yaml_url, + ) + + assert result.status == ImportStatus.PENDING + assert result.imported_dsl_version == "99.0.0" + assert requested_urls == [yaml_url] + + def test_import_app_yaml_url_github_blob_rewrites_to_raw(self, db_session_with_containers, monkeypatch): + yaml_url = "https://github.com/acme/repo/blob/main/app.yml" + raw_url = "https://raw.githubusercontent.com/acme/repo/main/app.yml" + yaml_bytes = _pending_yaml_content() + + requested_urls: list[str] = [] + + def fake_get(url: str, **kwargs): + requested_urls.append(url) + assert url == raw_url + response = MagicMock() + response.content = yaml_bytes + response.raise_for_status.return_value = None + return response + + monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get) + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_URL, + yaml_url=yaml_url, + ) + + assert result.status == ImportStatus.PENDING + assert requested_urls == [raw_url] + + # ── Import: App ID checks ──────────────────────────────────────── + + def test_import_app_app_id_not_found_returns_failed(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content=_workflow_yaml(), + app_id=str(uuid4()), + ) + assert result.status == ImportStatus.FAILED + assert result.error == "App not found" + + def test_import_app_overwrite_only_allows_workflow_and_advanced_chat( + self, db_session_with_containers, mock_external_service_dependencies + ): + app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + assert app.mode == "chat" + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=account, + import_mode=ImportMode.YAML_CONTENT, + yaml_content=_workflow_yaml(), + app_id=app.id, + ) + assert result.status == ImportStatus.FAILED + assert "Only workflow or advanced chat apps" in result.error + + # ── Import: Flow ────────────────────────────────────────────────── + + def test_import_app_pending_stores_import_info_in_redis(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content=_workflow_yaml(version="99.0.0"), + name="n", + description="d", + icon_type="emoji", + icon="i", + icon_background="#000000", + ) + assert result.status == ImportStatus.PENDING + assert result.imported_dsl_version == "99.0.0" + + redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{result.id}" + stored = redis_client.get(redis_key) + assert stored is not None + + def test_import_app_completed_uses_declared_dependencies( + self, db_session_with_containers, mock_external_service_dependencies + ): + _, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + + dependencies_payload = [ + { + "type": "package", + "value": { + "plugin_unique_identifier": "langgenius/google", + "version": "1.0.0", + }, + } + ] + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=account, + import_mode=ImportMode.YAML_CONTENT, + yaml_content=_yaml_dump( + { + "version": CURRENT_DSL_VERSION, + "kind": "app", + "app": {"name": "My App", "mode": AppMode.WORKFLOW.value}, + "workflow": {"graph": {"nodes": []}, "features": {}}, + "dependencies": dependencies_payload, + } + ), + ) + + assert result.status == ImportStatus.COMPLETED + assert result.app_id is not None + + @pytest.mark.parametrize("has_workflow", [True, False]) + def test_import_app_legacy_versions_extract_dependencies( + self, db_session_with_containers, monkeypatch, has_workflow: bool + ): + monkeypatch.setattr( + AppDslService, + "_extract_dependencies_from_workflow_graph", + lambda *_args, **_kwargs: ["from-workflow"], + ) + monkeypatch.setattr( + AppDslService, + "_extract_dependencies_from_model_config", + lambda *_args, **_kwargs: ["from-model-config"], + ) + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "generate_latest_dependencies", + lambda deps: [SimpleNamespace(model_dump=lambda: {"dep": deps[0]})], + ) + + created_app = SimpleNamespace( + id=str(uuid4()), + mode=AppMode.WORKFLOW.value, + tenant_id=_DEFAULT_TENANT_ID, + ) + monkeypatch.setattr( + AppDslService, + "_create_or_update_app", + lambda *_args, **_kwargs: created_app, + ) + + draft_var_service = MagicMock() + monkeypatch.setattr( + app_dsl_service, + "WorkflowDraftVariableService", + lambda *args, **kwargs: draft_var_service, + ) + + data: dict = { + "version": "0.1.5", + "kind": "app", + "app": {"name": "Legacy", "mode": AppMode.WORKFLOW.value}, + } + if has_workflow: + data["workflow"] = {"graph": {"nodes": []}, "features": {}} + else: + data["model_config"] = {"model": {"provider": "openai"}} + + service = AppDslService(db_session_with_containers) + result = service.import_app( + account=_account_mock(), + import_mode=ImportMode.YAML_CONTENT, + yaml_content=_yaml_dump(data), + ) + assert result.status == ImportStatus.COMPLETED_WITH_WARNINGS + draft_var_service.delete_app_workflow_variables.assert_called_once_with(app_id=created_app.id) + + # ── Confirm Import ──────────────────────────────────────────────── + + def test_confirm_import_expired_returns_failed(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + result = service.confirm_import(import_id=str(uuid4()), account=_account_mock()) + assert result.status == ImportStatus.FAILED + assert "expired" in result.error + + def test_confirm_import_success_deletes_redis_key(self, db_session_with_containers, monkeypatch): + import_id = str(uuid4()) + redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" + + pending = PendingData( + import_mode=ImportMode.YAML_CONTENT, + yaml_content=_workflow_yaml(), + name="name", + description="desc", + icon_type="emoji", + icon="🤖", + icon_background="#fff", + app_id=None, + ) + redis_client.setex(redis_key, IMPORT_INFO_REDIS_EXPIRY, pending.model_dump_json()) + + created_app = SimpleNamespace( + id=str(uuid4()), + mode=AppMode.WORKFLOW.value, + tenant_id=_DEFAULT_TENANT_ID, + ) + monkeypatch.setattr( + AppDslService, + "_create_or_update_app", + lambda *_args, **_kwargs: created_app, + ) + + service = AppDslService(db_session_with_containers) + result = service.confirm_import(import_id=import_id, account=_account_mock()) + assert result.status == ImportStatus.COMPLETED + assert result.app_id == created_app.id + assert redis_client.get(redis_key) is None + + def test_confirm_import_invalid_pending_data_type_returns_failed(self, db_session_with_containers): + import_id = str(uuid4()) + redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" + redis_client.setex(redis_key, IMPORT_INFO_REDIS_EXPIRY, "123") + + service = AppDslService(db_session_with_containers) + result = service.confirm_import(import_id=import_id, account=_account_mock()) + assert result.status == ImportStatus.FAILED + assert "validation error" in result.error + + def test_confirm_import_exception_returns_failed(self, db_session_with_containers): + import_id = str(uuid4()) + redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" + redis_client.setex(redis_key, IMPORT_INFO_REDIS_EXPIRY, "not-valid-json") + + service = AppDslService(db_session_with_containers) + result = service.confirm_import(import_id=import_id, account=_account_mock()) + assert result.status == ImportStatus.FAILED + + # ── Check Dependencies ──────────────────────────────────────────── + + def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + app_model = SimpleNamespace(id=str(uuid4()), tenant_id=_DEFAULT_TENANT_ID) + result = service.check_dependencies(app_model=app_model) + assert result.leaked_dependencies == [] + + def test_check_dependencies_calls_analysis_service(self, db_session_with_containers, monkeypatch): + app_id = str(uuid4()) + pending = CheckDependenciesPendingData(dependencies=[], app_id=app_id) + redis_client.setex( + f"{CHECK_DEPENDENCIES_REDIS_KEY_PREFIX}{app_id}", + IMPORT_INFO_REDIS_EXPIRY, + pending.model_dump_json(), + ) + + dep = app_dsl_service.PluginDependency.model_validate( + { + "type": "package", + "value": { + "plugin_unique_identifier": "acme/foo", + "version": "1.0.0", + }, + } + ) + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "get_leaked_dependencies", + lambda *, tenant_id, dependencies: [dep], + ) + + service = AppDslService(db_session_with_containers) + result = service.check_dependencies(app_model=SimpleNamespace(id=app_id, tenant_id=_DEFAULT_TENANT_ID)) + assert len(result.leaked_dependencies) == 1 + + def test_check_dependencies_with_real_app(self, db_session_with_containers, mock_external_service_dependencies): + app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + + mock_dependencies_json = '{"app_id": "' + app.id + '", "dependencies": []}' + redis_client.setex( + f"{CHECK_DEPENDENCIES_REDIS_KEY_PREFIX}{app.id}", + IMPORT_INFO_REDIS_EXPIRY, + mock_dependencies_json, + ) + + dsl_service = AppDslService(db_session_with_containers) + result = dsl_service.check_dependencies(app_model=app) + assert result.leaked_dependencies == [] + + # ── Create/Update App ───────────────────────────────────────────── + + def test_create_or_update_app_missing_mode_raises(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + with pytest.raises(ValueError, match="loss app mode"): + service._create_or_update_app(app=None, data={"app": {}}, account=_account_mock()) + + def test_create_or_update_app_existing_app_updates_fields(self, db_session_with_containers, monkeypatch): + fixed_now = object() + monkeypatch.setattr(app_dsl_service, "naive_utc_now", lambda: fixed_now) + + workflow_service = MagicMock() + workflow_service.get_draft_workflow.return_value = None + monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service) + monkeypatch.setattr( + app_dsl_service.variable_factory, + "build_environment_variable_from_mapping", + lambda _m: SimpleNamespace(kind="env"), + ) + monkeypatch.setattr( + app_dsl_service.variable_factory, + "build_conversation_variable_from_mapping", + lambda _m: SimpleNamespace(kind="conv"), + ) + + app = SimpleNamespace( + id=str(uuid4()), + tenant_id=_DEFAULT_TENANT_ID, + mode=AppMode.WORKFLOW.value, + name="old", + description="old-desc", + icon_type=IconType.EMOJI, + icon="old-icon", + icon_background="#111111", + updated_by=None, + updated_at=None, + app_model_config=None, + ) + service = AppDslService(db_session_with_containers) + updated = service._create_or_update_app( + app=app, + data={ + "app": { + "mode": AppMode.WORKFLOW.value, + "name": "yaml-name", + "icon_type": IconType.IMAGE, + "icon": "X", + }, + "workflow": {"graph": {"nodes": []}, "features": {}}, + }, + account=_account_mock(), + name="override-name", + description=None, + icon_background="#222222", + ) + assert updated is app + assert app.name == "override-name" + assert app.icon_type == IconType.IMAGE + assert app.icon == "X" + assert app.icon_background == "#222222" + assert app.updated_at is fixed_now + + def test_create_or_update_app_new_app_requires_tenant(self, db_session_with_containers): + account = _account_mock() + account.current_tenant_id = None + service = AppDslService(db_session_with_containers) + with pytest.raises(ValueError, match="Current tenant is not set"): + service._create_or_update_app( + app=None, + data={"app": {"mode": AppMode.WORKFLOW.value, "name": "n"}}, + account=account, + ) + + def test_create_or_update_app_creates_workflow_app_and_saves_dependencies( + self, db_session_with_containers, mock_external_service_dependencies + ): + _, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + + mock_wf_svc = mock_external_service_dependencies["workflow_service"] + mock_wf_svc.return_value.get_draft_workflow.return_value = MagicMock(unique_hash="uh") + + service = AppDslService(db_session_with_containers) + deps = [ + app_dsl_service.PluginDependency.model_validate( + { + "type": "package", + "value": { + "plugin_unique_identifier": "acme/foo", + "version": "1.0.0", + }, + } + ) + ] + data = { + "app": {"mode": AppMode.WORKFLOW.value, "name": "n"}, + "workflow": { + "graph": {"nodes": []}, + "features": {}, + }, + } + + app = service._create_or_update_app(app=None, data=data, account=account, dependencies=deps) + + assert app.tenant_id == account.current_tenant_id + mock_external_service_dependencies["app_was_created"].send.assert_called_once() + mock_wf_svc.return_value.sync_draft_workflow.assert_called_once() + + stored = redis_client.get(f"{CHECK_DEPENDENCIES_REDIS_KEY_PREFIX}{app.id}") + assert stored is not None + + def test_create_or_update_app_workflow_missing_workflow_data_raises(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + with pytest.raises(ValueError, match="Missing workflow data"): + service._create_or_update_app( + app=SimpleNamespace( + id=str(uuid4()), + tenant_id=_DEFAULT_TENANT_ID, + mode=AppMode.WORKFLOW.value, + name="n", + description="d", + icon_background="#fff", + app_model_config=None, + ), + data={"app": {"mode": AppMode.WORKFLOW.value}}, + account=_account_mock(), + ) + + def test_create_or_update_app_chat_requires_model_config(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + with pytest.raises(ValueError, match="Missing model_config"): + service._create_or_update_app( + app=SimpleNamespace( + id=str(uuid4()), + tenant_id=_DEFAULT_TENANT_ID, + mode=AppMode.CHAT.value, + name="n", + description="d", + icon_background="#fff", + app_model_config=None, + ), + data={"app": {"mode": AppMode.CHAT.value}}, + account=_account_mock(), + ) + + def test_create_or_update_app_chat_creates_model_config_and_sends_event( + self, db_session_with_containers, mock_external_service_dependencies + ): + app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + app.app_model_config_id = None + db_session_with_containers.commit() + + service = AppDslService(db_session_with_containers) + service._create_or_update_app( + app=app, + data={ + "app": {"mode": AppMode.CHAT.value}, + "model_config": {"model": {"provider": "openai"}}, + }, + account=account, + ) + + db_session_with_containers.expire_all() + assert app.app_model_config_id is not None + + def test_create_or_update_app_invalid_mode_raises(self, db_session_with_containers): + service = AppDslService(db_session_with_containers) + with pytest.raises(ValueError, match="Invalid app mode"): + service._create_or_update_app( + app=SimpleNamespace( + id=str(uuid4()), + tenant_id=_DEFAULT_TENANT_ID, + mode=AppMode.RAG_PIPELINE.value, + name="n", + description="d", + icon_background="#fff", + app_model_config=None, + ), + data={"app": {"mode": AppMode.RAG_PIPELINE.value}}, + account=_account_mock(), + ) + + # ── Export ───────────────────────────────────────────────────────── + + def test_export_dsl_delegates_by_mode(self, monkeypatch): + workflow_calls: list[bool] = [] + model_calls: list[bool] = [] + monkeypatch.setattr( + AppDslService, + "_append_workflow_export_data", + lambda **_kwargs: workflow_calls.append(True), + ) + monkeypatch.setattr( + AppDslService, + "_append_model_config_export_data", + lambda *_args, **_kwargs: model_calls.append(True), + ) + + workflow_app = SimpleNamespace( + mode=AppMode.WORKFLOW.value, + tenant_id=_DEFAULT_TENANT_ID, + name="n", + icon="i", + icon_type="emoji", + icon_background="#fff", + description="d", + use_icon_as_answer_icon=False, + app_model_config=None, + ) + AppDslService.export_dsl(workflow_app) + assert workflow_calls == [True] + + chat_app = SimpleNamespace( + mode=AppMode.CHAT.value, + tenant_id=_DEFAULT_TENANT_ID, + name="n", + icon="i", + icon_type="emoji", + icon_background="#fff", + description="d", + use_icon_as_answer_icon=False, + app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}), + ) + AppDslService.export_dsl(chat_app) + assert model_calls == [True] + + def test_export_dsl_preserves_icon_and_icon_type(self, monkeypatch): + monkeypatch.setattr( + AppDslService, + "_append_workflow_export_data", + lambda **_kwargs: None, + ) + + emoji_app = SimpleNamespace( + mode=AppMode.WORKFLOW.value, + tenant_id=_DEFAULT_TENANT_ID, + name="Emoji App", + icon="🎨", + icon_type=IconType.EMOJI, + icon_background="#FF5733", + description="App with emoji icon", + use_icon_as_answer_icon=True, + app_model_config=None, + ) + yaml_output = AppDslService.export_dsl(emoji_app) + data = yaml.safe_load(yaml_output) + assert data["app"]["icon"] == "🎨" + assert data["app"]["icon_type"] == "emoji" + assert data["app"]["icon_background"] == "#FF5733" + + image_app = SimpleNamespace( + mode=AppMode.WORKFLOW.value, + tenant_id=_DEFAULT_TENANT_ID, + name="Image App", + icon="https://example.com/icon.png", + icon_type=IconType.IMAGE, + icon_background="#FFEAD5", + description="App with image icon", + use_icon_as_answer_icon=False, + app_model_config=None, + ) + yaml_output = AppDslService.export_dsl(image_app) + data = yaml.safe_load(yaml_output) + assert data["app"]["icon"] == "https://example.com/icon.png" + assert data["app"]["icon_type"] == "image" + assert data["app"]["icon_background"] == "#FFEAD5" + + def test_export_dsl_chat_app_success(self, db_session_with_containers, mock_external_service_dependencies): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) - # Create model config for the app model_config = AppModelConfig( app_id=app.id, provider="openai", @@ -247,53 +920,38 @@ class TestAppDslService: created_by=account.id, updated_by=account.id, ) - model_config.id = fake.uuid4() - - # Set the app_model_config_id to link the config + model_config.id = str(uuid4()) app.app_model_config_id = model_config.id db_session_with_containers.add(model_config) db_session_with_containers.commit() - # Export DSL exported_dsl = AppDslService.export_dsl(app, include_secret=False) - - # Parse exported YAML exported_data = yaml.safe_load(exported_dsl) - # Verify exported data structure assert exported_data["kind"] == "app" assert exported_data["app"]["name"] == app.name assert exported_data["app"]["mode"] == app.mode - assert exported_data["app"]["icon"] == app.icon - assert exported_data["app"]["icon_background"] == app.icon_background - assert exported_data["app"]["description"] == app.description - - # Verify model config was exported assert "model_config" in exported_data - # The exported model_config structure may be different from the database structure - # Check that the model config exists and has the expected content - assert exported_data["model_config"] is not None - - # Verify dependencies were exported assert "dependencies" in exported_data - assert isinstance(exported_data["dependencies"], list) def test_export_dsl_workflow_app_success(self, db_session_with_containers, mock_external_service_dependencies): - """ - Test successful DSL export for workflow app. - """ - fake = Faker() app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) - - # Update app to workflow mode app.mode = "workflow" db_session_with_containers.commit() - # Mock workflow service to return a workflow mock_workflow = MagicMock() mock_workflow.to_dict.return_value = { - "graph": {"nodes": [{"id": "start", "type": "start", "data": {"type": "start"}}], "edges": []}, + "graph": { + "nodes": [ + { + "id": "start", + "type": "start", + "data": {"type": "start"}, + } + ], + "edges": [], + }, "features": {}, "environment_variables": [], "conversation_variables": [], @@ -302,54 +960,40 @@ class TestAppDslService: "workflow_service" ].return_value.get_draft_workflow.return_value = mock_workflow - # Export DSL exported_dsl = AppDslService.export_dsl(app, include_secret=False) - - # Parse exported YAML exported_data = yaml.safe_load(exported_dsl) - # Verify exported data structure assert exported_data["kind"] == "app" - assert exported_data["app"]["name"] == app.name assert exported_data["app"]["mode"] == "workflow" - - # Verify workflow was exported assert "workflow" in exported_data - assert "graph" in exported_data["workflow"] - assert "nodes" in exported_data["workflow"]["graph"] - - # Verify dependencies were exported assert "dependencies" in exported_data - assert isinstance(exported_data["dependencies"], list) - - # Verify workflow service was called - mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( - app, None - ) def test_export_dsl_with_workflow_id_success(self, db_session_with_containers, mock_external_service_dependencies): - """ - Test successful DSL export with specific workflow ID. - """ - fake = Faker() app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) - - # Update app to workflow mode app.mode = "workflow" db_session_with_containers.commit() - # Mock workflow service to return a workflow when specific workflow_id is provided mock_workflow = MagicMock() mock_workflow.to_dict.return_value = { - "graph": {"nodes": [{"id": "start", "type": "start", "data": {"type": "start"}}], "edges": []}, + "graph": { + "nodes": [ + { + "id": "start", + "type": "start", + "data": {"type": "start"}, + } + ], + "edges": [], + }, "features": {}, "environment_variables": [], "conversation_variables": [], } - # Mock the get_draft_workflow method to return different workflows based on workflow_id - def mock_get_draft_workflow(app_model, workflow_id=None): - if workflow_id == "specific-workflow-id": + workflow_id = str(uuid4()) + + def mock_get_draft_workflow(app_model, wf_id=None): + if wf_id == workflow_id: return mock_workflow return None @@ -357,78 +1001,351 @@ class TestAppDslService: "workflow_service" ].return_value.get_draft_workflow.side_effect = mock_get_draft_workflow - # Export DSL with specific workflow ID - exported_dsl = AppDslService.export_dsl(app, include_secret=False, workflow_id="specific-workflow-id") - - # Parse exported YAML + exported_dsl = AppDslService.export_dsl(app, include_secret=False, workflow_id=workflow_id) exported_data = yaml.safe_load(exported_dsl) - # Verify exported data structure assert exported_data["kind"] == "app" - assert exported_data["app"]["name"] == app.name - assert exported_data["app"]["mode"] == "workflow" - - # Verify workflow was exported assert "workflow" in exported_data - assert "graph" in exported_data["workflow"] - assert "nodes" in exported_data["workflow"]["graph"] - - # Verify dependencies were exported - assert "dependencies" in exported_data - assert isinstance(exported_data["dependencies"], list) - - # Verify workflow service was called with specific workflow ID - mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( - app, "specific-workflow-id" - ) def test_export_dsl_with_invalid_workflow_id_raises_error( self, db_session_with_containers, mock_external_service_dependencies ): - """ - Test that export_dsl raises error when invalid workflow ID is provided. - """ - fake = Faker() app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) - - # Update app to workflow mode app.mode = "workflow" db_session_with_containers.commit() - # Mock workflow service to return None when invalid workflow ID is provided mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.return_value = None - # Export DSL with invalid workflow ID should raise ValueError - with pytest.raises(ValueError, match="Missing draft workflow configuration, please check."): - AppDslService.export_dsl(app, include_secret=False, workflow_id="invalid-workflow-id") + with pytest.raises( + ValueError, + match="Missing draft workflow configuration, please check.", + ): + AppDslService.export_dsl(app, include_secret=False, workflow_id=str(uuid4())) - # Verify workflow service was called with the invalid workflow ID - mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( - app, "invalid-workflow-id" + # ── Workflow Export Data ─────────────────────────────────────────── + + def test_append_workflow_export_data_filters_and_overrides(self, monkeypatch): + workflow_dict = { + "graph": { + "nodes": [ + { + "data": { + "type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL, + "dataset_ids": ["d1", "d2"], + } + }, + { + "data": { + "type": BuiltinNodeTypes.TOOL, + "credential_id": "secret", + } + }, + { + "data": { + "type": BuiltinNodeTypes.AGENT, + "agent_parameters": {"tools": {"value": [{"credential_id": "secret"}]}}, + } + }, + { + "data": { + "type": TRIGGER_SCHEDULE_NODE_TYPE, + "config": {"x": 1}, + } + }, + { + "data": { + "type": TRIGGER_WEBHOOK_NODE_TYPE, + "webhook_url": "x", + "webhook_debug_url": "y", + } + }, + { + "data": { + "type": TRIGGER_PLUGIN_NODE_TYPE, + "subscription_id": "s", + } + }, + ] + } + } + + workflow = SimpleNamespace(to_dict=lambda *, include_secret: workflow_dict) + workflow_service = MagicMock() + workflow_service.get_draft_workflow.return_value = workflow + monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service) + + monkeypatch.setattr( + AppDslService, + "encrypt_dataset_id", + lambda *, dataset_id, tenant_id: f"enc:{tenant_id}:{dataset_id}", + ) + monkeypatch.setattr( + app_dsl_service.TriggerScheduleNode, + "get_default_config", + lambda: {"config": {"default": True}}, + ) + monkeypatch.setattr( + AppDslService, + "_extract_dependencies_from_workflow", + lambda *_args, **_kwargs: ["dep-1"], + ) + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "generate_dependencies", + lambda *, tenant_id, dependencies: [ + SimpleNamespace( + model_dump=lambda: { + "tenant": tenant_id, + "dep": dependencies[0], + } + ) + ], + ) + monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x) + + export_data: dict = {} + AppDslService._append_workflow_export_data( + export_data=export_data, + app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID), + include_secret=False, + workflow_id=None, ) - def test_check_dependencies_success(self, db_session_with_containers, mock_external_service_dependencies): - """ - Test successful dependency checking. - """ - fake = Faker() - app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + nodes = export_data["workflow"]["graph"]["nodes"] + assert nodes[0]["data"]["dataset_ids"] == [ + f"enc:{_DEFAULT_TENANT_ID}:d1", + f"enc:{_DEFAULT_TENANT_ID}:d2", + ] + assert "credential_id" not in nodes[1]["data"] + assert "credential_id" not in nodes[2]["data"]["agent_parameters"]["tools"]["value"][0] + assert nodes[3]["data"]["config"] == {"default": True} + assert nodes[4]["data"]["webhook_url"] == "" + assert nodes[4]["data"]["webhook_debug_url"] == "" + assert nodes[5]["data"]["subscription_id"] == "" + assert export_data["dependencies"] == [{"tenant": _DEFAULT_TENANT_ID, "dep": "dep-1"}] - # Mock Redis to return dependencies - mock_dependencies_json = '{"app_id": "' + app.id + '", "dependencies": []}' - mock_external_service_dependencies["redis_client"].get.return_value = mock_dependencies_json + def test_append_workflow_export_data_missing_workflow_raises(self, monkeypatch): + workflow_service = MagicMock() + workflow_service.get_draft_workflow.return_value = None + monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service) - # Check dependencies - dsl_service = AppDslService(db_session_with_containers) - result = dsl_service.check_dependencies(app_model=app) + with pytest.raises(ValueError, match="Missing draft workflow configuration"): + AppDslService._append_workflow_export_data( + export_data={}, + app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID), + include_secret=False, + workflow_id=None, + ) - # Verify result - assert result.leaked_dependencies == [] + # ── Model Config Export Data ────────────────────────────────────── - # Verify Redis was queried - mock_external_service_dependencies["redis_client"].get.assert_called_once_with( - f"app_check_dependencies:{app.id}" + def test_append_model_config_export_data_filters_credential_id(self, monkeypatch): + monkeypatch.setattr( + AppDslService, + "_extract_dependencies_from_model_config", + lambda *_args, **_kwargs: ["dep-1"], + ) + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "generate_dependencies", + lambda *, tenant_id, dependencies: [ + SimpleNamespace( + model_dump=lambda: { + "tenant": tenant_id, + "dep": dependencies[0], + } + ) + ], + ) + monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x) + + app_model_config = SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": [{"credential_id": "secret"}]}}) + app_model = SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID, app_model_config=app_model_config) + export_data: dict = {} + + AppDslService._append_model_config_export_data(export_data, app_model) + assert export_data["model_config"]["agent_mode"]["tools"] == [{}] + assert export_data["dependencies"] == [{"tenant": _DEFAULT_TENANT_ID, "dep": "dep-1"}] + + def test_append_model_config_export_data_requires_app_config(self): + with pytest.raises(ValueError, match="Missing app configuration"): + AppDslService._append_model_config_export_data({}, SimpleNamespace(app_model_config=None)) + + # ── Dependency Extraction ───────────────────────────────────────── + + def test_extract_dependencies_from_workflow_graph_covers_all_node_types(self, monkeypatch): + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "analyze_tool_dependency", + lambda provider_id: f"tool:{provider_id}", + ) + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "analyze_model_provider_dependency", + lambda provider: f"model:{provider}", ) - # Verify dependencies service was called - mock_external_service_dependencies["dependencies_service"].get_leaked_dependencies.assert_called_once() + monkeypatch.setattr( + app_dsl_service.ToolNodeData, + "model_validate", + lambda _d: SimpleNamespace(provider_id="p1"), + ) + monkeypatch.setattr( + app_dsl_service.LLMNodeData, + "model_validate", + lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m1")), + ) + monkeypatch.setattr( + app_dsl_service.QuestionClassifierNodeData, + "model_validate", + lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m2")), + ) + monkeypatch.setattr( + app_dsl_service.ParameterExtractorNodeData, + "model_validate", + lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m3")), + ) + + def kr_validate(_d): + return SimpleNamespace( + retrieval_mode="multiple", + multiple_retrieval_config=SimpleNamespace( + reranking_mode="weighted_score", + weights=SimpleNamespace(vector_setting=SimpleNamespace(embedding_provider_name="m4")), + reranking_model=None, + ), + single_retrieval_config=None, + ) + + monkeypatch.setattr( + app_dsl_service.KnowledgeRetrievalNodeData, + "model_validate", + kr_validate, + ) + + graph = { + "nodes": [ + {"data": {"type": BuiltinNodeTypes.TOOL}}, + {"data": {"type": BuiltinNodeTypes.LLM}}, + {"data": {"type": BuiltinNodeTypes.QUESTION_CLASSIFIER}}, + {"data": {"type": BuiltinNodeTypes.PARAMETER_EXTRACTOR}}, + {"data": {"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL}}, + {"data": {"type": "unknown"}}, + ] + } + + deps = AppDslService._extract_dependencies_from_workflow_graph(graph) + assert deps == [ + "tool:p1", + "model:m1", + "model:m2", + "model:m3", + "model:m4", + ] + + def test_extract_dependencies_from_workflow_graph_handles_exceptions(self, monkeypatch): + monkeypatch.setattr( + app_dsl_service.ToolNodeData, + "model_validate", + lambda _d: (_ for _ in ()).throw(ValueError("bad")), + ) + deps = AppDslService._extract_dependencies_from_workflow_graph( + {"nodes": [{"data": {"type": BuiltinNodeTypes.TOOL}}]} + ) + assert deps == [] + + def test_extract_dependencies_from_model_config_parses_providers(self, monkeypatch): + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "analyze_model_provider_dependency", + lambda provider: f"model:{provider}", + ) + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "analyze_tool_dependency", + lambda provider_id: f"tool:{provider_id}", + ) + + deps = AppDslService._extract_dependencies_from_model_config( + { + "model": {"provider": "p1"}, + "dataset_configs": { + "datasets": {"datasets": [{"reranking_model": {"reranking_provider_name": {"provider": "p2"}}}]} + }, + "agent_mode": {"tools": [{"provider_id": "t1"}]}, + } + ) + assert deps == ["model:p1", "model:p2", "tool:t1"] + + def test_extract_dependencies_from_model_config_handles_exceptions(self, monkeypatch): + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "analyze_model_provider_dependency", + lambda _p: (_ for _ in ()).throw(ValueError("bad")), + ) + deps = AppDslService._extract_dependencies_from_model_config({"model": {"provider": "p1"}}) + assert deps == [] + + # ── Leaked Dependencies ─────────────────────────────────────────── + + def test_get_leaked_dependencies_empty_returns_empty(self): + assert AppDslService.get_leaked_dependencies(_DEFAULT_TENANT_ID, []) == [] + + def test_get_leaked_dependencies_delegates(self, monkeypatch): + monkeypatch.setattr( + app_dsl_service.DependenciesAnalysisService, + "get_leaked_dependencies", + lambda *, tenant_id, dependencies: [SimpleNamespace(tenant_id=tenant_id, deps=dependencies)], + ) + res = AppDslService.get_leaked_dependencies(_DEFAULT_TENANT_ID, [SimpleNamespace(id="x")]) + assert len(res) == 1 + + # ── Encryption/Decryption ───────────────────────────────────────── + + def test_encrypt_decrypt_dataset_id_respects_config(self, monkeypatch): + tenant_id = _DEFAULT_TENANT_ID + dataset_uuid = "00000000-0000-0000-0000-000000000000" + + monkeypatch.setattr( + app_dsl_service.dify_config, + "DSL_EXPORT_ENCRYPT_DATASET_ID", + False, + ) + assert AppDslService.encrypt_dataset_id(dataset_id=dataset_uuid, tenant_id=tenant_id) == dataset_uuid + + monkeypatch.setattr( + app_dsl_service.dify_config, + "DSL_EXPORT_ENCRYPT_DATASET_ID", + True, + ) + encrypted = AppDslService.encrypt_dataset_id(dataset_id=dataset_uuid, tenant_id=tenant_id) + assert encrypted != dataset_uuid + assert base64.b64decode(encrypted.encode()) + assert AppDslService.decrypt_dataset_id(encrypted_data=encrypted, tenant_id=tenant_id) == dataset_uuid + + def test_decrypt_dataset_id_returns_plain_uuid_unchanged(self): + value = "00000000-0000-0000-0000-000000000000" + assert AppDslService.decrypt_dataset_id(encrypted_data=value, tenant_id=_DEFAULT_TENANT_ID) == value + + def test_decrypt_dataset_id_returns_none_on_invalid_data(self, monkeypatch): + monkeypatch.setattr( + app_dsl_service.dify_config, + "DSL_EXPORT_ENCRYPT_DATASET_ID", + True, + ) + assert AppDslService.decrypt_dataset_id(encrypted_data="not-base64", tenant_id=_DEFAULT_TENANT_ID) is None + + def test_decrypt_dataset_id_returns_none_when_decrypted_is_not_uuid(self, monkeypatch): + monkeypatch.setattr( + app_dsl_service.dify_config, + "DSL_EXPORT_ENCRYPT_DATASET_ID", + True, + ) + encrypted = AppDslService.encrypt_dataset_id(dataset_id="not-a-uuid", tenant_id=_DEFAULT_TENANT_ID) + assert AppDslService.decrypt_dataset_id(encrypted_data=encrypted, tenant_id=_DEFAULT_TENANT_ID) is None + + # ── Utility ─────────────────────────────────────────────────────── + + def test_is_valid_uuid_handles_bad_inputs(self): + assert AppDslService._is_valid_uuid("00000000-0000-0000-0000-000000000000") is True + assert AppDslService._is_valid_uuid("nope") is False diff --git a/api/tests/test_containers_integration_tests/services/test_audio_service_db.py b/api/tests/test_containers_integration_tests/services/test_audio_service_db.py new file mode 100644 index 0000000000..2593b53fe8 --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_audio_service_db.py @@ -0,0 +1,211 @@ +""" +Integration tests for AudioService.transcript_tts message-ID path. + +Migrated from unit_tests/services/test_audio_service.py, replacing +db.session.get mock patches with real Message rows persisted in PostgreSQL. + +Covers: +- transcript_tts with valid message_id that resolves to a real Message +- transcript_tts returns None for invalid (non-UUID) message_id +- transcript_tts returns None when message_id is a valid UUID but no row exists +- transcript_tts returns None when message exists but has an empty answer +""" + +from collections.abc import Generator +from decimal import Decimal +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from core.app.entities.app_invoke_entities import InvokeFrom +from models.account import TenantAccountJoin +from models.enums import ConversationFromSource, MessageStatus +from models.model import App, AppMode, Conversation, Message +from services.audio_service import AudioService +from tests.test_containers_integration_tests.controllers.console.helpers import ( + create_console_account_and_tenant, + create_console_app, +) + + +def _create_conversation(db_session: Session, app: App, account_id: str) -> Conversation: + """Create a Conversation row via flush() so the rollback-based teardown can remove it.""" + conversation = Conversation( + app_id=app.id, + app_model_config_id=None, + model_provider=None, + model_id="", + override_model_configs=None, + mode=app.mode, + name=f"Conversation {uuid4()}", + summary="", + inputs={}, + introduction="", + system_instruction="", + system_instruction_tokens=0, + status="normal", + invoke_from=InvokeFrom.WEB_APP.value, + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id=account_id, + dialogue_count=0, + is_deleted=False, + ) + db_session.add(conversation) + db_session.flush() + return conversation + + +def _create_message( + db_session: Session, + app: App, + conversation: Conversation, + account_id: str, + *, + answer: str = "Message answer text", + status: MessageStatus | str = MessageStatus.NORMAL, +) -> Message: + """Create a Message row via flush() so the rollback-based teardown can remove it.""" + message = Message( + app_id=app.id, + model_provider=None, + model_id="", + override_model_configs=None, + conversation_id=conversation.id, + inputs={}, + query="Test query", + message={"messages": [{"role": "user", "content": "Test query"}]}, + message_tokens=0, + message_unit_price=Decimal(0), + message_price_unit=Decimal("0.001"), + answer=answer, + answer_tokens=0, + answer_unit_price=Decimal(0), + answer_price_unit=Decimal("0.001"), + parent_message_id=None, + provider_response_latency=0, + total_price=Decimal(0), + currency="USD", + status=status, + invoke_from=InvokeFrom.WEB_APP.value, + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id=account_id, + ) + db_session.add(message) + db_session.flush() + return message + + +class TestAudioServiceTranscriptTTSMessageLookup: + """Integration tests for AudioService.transcript_tts message-ID lookup via real DB.""" + + @pytest.fixture(autouse=True) + def _setup_cleanup(self, db_session_with_containers: Session) -> Generator[None, None, None]: + """Track rows created by shared helpers that commit, then clean up after the test. + + The shared console helpers (create_console_account_and_tenant, create_console_app) + commit their inserts so the rows survive a simple rollback. This fixture records + the app/account/tenant created per test and explicitly deletes them after the test + so the DB does not accumulate state across tests. Conversation/Message rows are + created via flush() only, so the trailing rollback removes them. + """ + self._committed_rows: list = [] + yield + db_session_with_containers.rollback() + for entity in reversed(self._committed_rows): + db_session_with_containers.execute(delete(type(entity)).where(type(entity).id == entity.id)) + db_session_with_containers.commit() + + def _setup_app_and_account(self, db_session: Session) -> tuple[App, str, str]: + """Create committed app/account/tenant using shared helpers and track them for cleanup.""" + account, tenant = create_console_account_and_tenant(db_session) + app = create_console_app(db_session, tenant_id=tenant.id, account_id=account.id, mode=AppMode.CHAT) + + # Track rows in the order they must be deleted (FK-safe: app and join before account/tenant) + self._committed_rows.append(app) + join = db_session.scalar( + select(TenantAccountJoin).where( + TenantAccountJoin.account_id == account.id, + TenantAccountJoin.tenant_id == tenant.id, + ) + ) + if join is not None: + self._committed_rows.append(join) + self._committed_rows.extend([account, tenant]) + return app, account.id, tenant.id + + def test_transcript_tts_with_message_id_success(self, db_session_with_containers: Session) -> None: + """transcript_tts invokes TTS with the message answer when message_id resolves to a real row.""" + app, account_id, _ = self._setup_app_and_account(db_session_with_containers) + conversation = _create_conversation(db_session_with_containers, app, account_id) + message = _create_message( + db_session_with_containers, + app, + conversation, + account_id, + answer="Hello from message", + ) + + mock_model_instance = MagicMock() + mock_model_instance.invoke_tts.return_value = b"audio from message" + mock_model_manager = MagicMock() + mock_model_manager.get_default_model_instance.return_value = mock_model_instance + + with patch("services.audio_service.ModelManager.for_tenant", return_value=mock_model_manager): + result = AudioService.transcript_tts( + app_model=app, + message_id=message.id, + voice="en-US-Neural", + ) + + assert result == b"audio from message" + mock_model_instance.invoke_tts.assert_called_once_with( + content_text="Hello from message", + voice="en-US-Neural", + ) + + def test_transcript_tts_returns_none_for_invalid_message_id(self, db_session_with_containers: Session) -> None: + """transcript_tts returns None immediately when message_id is not a valid UUID.""" + app, _, _ = self._setup_app_and_account(db_session_with_containers) + + result = AudioService.transcript_tts( + app_model=app, + message_id="invalid-uuid", + ) + + assert result is None + + def test_transcript_tts_returns_none_for_nonexistent_message(self, db_session_with_containers: Session) -> None: + """transcript_tts returns None when message_id is a valid UUID but no Message row exists.""" + app, _, _ = self._setup_app_and_account(db_session_with_containers) + + result = AudioService.transcript_tts( + app_model=app, + message_id=str(uuid4()), + ) + + assert result is None + + def test_transcript_tts_returns_none_for_empty_message_answer(self, db_session_with_containers: Session) -> None: + """transcript_tts returns None when the resolved message has an empty answer.""" + app, account_id, _ = self._setup_app_and_account(db_session_with_containers) + conversation = _create_conversation(db_session_with_containers, app, account_id) + message = _create_message( + db_session_with_containers, + app, + conversation, + account_id, + answer="", + status=MessageStatus.NORMAL, + ) + + result = AudioService.transcript_tts( + app_model=app, + message_id=message.id, + ) + + assert result is None diff --git a/api/tests/test_containers_integration_tests/services/test_billing_service.py b/api/tests/test_containers_integration_tests/services/test_billing_service.py index 76708b36b1..8092c7ad75 100644 --- a/api/tests/test_containers_integration_tests/services/test_billing_service.py +++ b/api/tests/test_containers_integration_tests/services/test_billing_service.py @@ -1,9 +1,13 @@ import json +from collections.abc import Generator from unittest.mock import patch +from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from extensions.ext_redis import redis_client +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from services.billing_service import BillingService @@ -363,3 +367,62 @@ class TestBillingServiceGetPlanBulkWithCache: assert ttl_1_new <= 600 assert ttl_2 > 0 assert ttl_2 <= 600 + + +class TestBillingServiceIsTenantOwnerOrAdmin: + """ + Integration tests for BillingService.is_tenant_owner_or_admin. + + Verifies that non-privileged roles (EDITOR, DATASET_OPERATOR) raise ValueError + when checked against real TenantAccountJoin rows in PostgreSQL. + """ + + @pytest.fixture(autouse=True) + def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]: + yield + db_session_with_containers.rollback() + + def _create_account_with_tenant_role(self, db_session: Session, role: TenantAccountRole) -> tuple[Account, Tenant]: + tenant = Tenant(name=f"Tenant {uuid4()}") + db_session.add(tenant) + db_session.flush() + + account = Account( + name=f"Account {uuid4()}", + email=f"billing_{uuid4()}@example.com", + password="hashed-password", + password_salt="salt", + interface_language="en-US", + timezone="UTC", + ) + db_session.add(account) + db_session.flush() + + join = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=role, + current=True, + ) + db_session.add(join) + db_session.flush() + + # Wire up in-memory reference so current_tenant_id resolves + account._current_tenant = tenant + return account, tenant + + def test_is_tenant_owner_or_admin_editor_role_raises_error(self, db_session_with_containers: Session) -> None: + """is_tenant_owner_or_admin raises ValueError for EDITOR role.""" + account, _ = self._create_account_with_tenant_role(db_session_with_containers, TenantAccountRole.EDITOR) + + with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"): + BillingService.is_tenant_owner_or_admin(account) + + def test_is_tenant_owner_or_admin_dataset_operator_raises_error(self, db_session_with_containers: Session) -> None: + """is_tenant_owner_or_admin raises ValueError for DATASET_OPERATOR role.""" + account, _ = self._create_account_with_tenant_role( + db_session_with_containers, TenantAccountRole.DATASET_OPERATOR + ) + + with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"): + BillingService.is_tenant_owner_or_admin(account) diff --git a/api/tests/test_containers_integration_tests/services/test_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_conversation_service.py index 6180d98b1e..98c38f2b5f 100644 --- a/api/tests/test_containers_integration_tests/services/test_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_conversation_service.py @@ -637,6 +637,40 @@ class TestConversationServiceSummarization: assert conversation.name == new_name assert conversation.updated_at == mock_time + @patch("services.conversation_service.LLMGenerator.generate_conversation_name") + def test_rename_with_auto_generate(self, mock_llm_generator, db_session_with_containers): + """ + Test rename delegates to auto_generate_name when auto_generate is True. + + When auto_generate is True, the service should call auto_generate_name + which uses an LLM to create a descriptive conversation title. + """ + # Arrange + app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( + db_session_with_containers + ) + conversation = ConversationServiceIntegrationTestDataFactory.create_conversation( + db_session_with_containers, app_model, user + ) + ConversationServiceIntegrationTestDataFactory.create_message( + db_session_with_containers, app_model, conversation, user + ) + generated_name = "Auto Generated Name" + mock_llm_generator.return_value = generated_name + + # Act + result = ConversationService.rename( + app_model=app_model, + conversation_id=conversation.id, + user=user, + name=None, + auto_generate=True, + ) + + # Assert + assert result == conversation + assert conversation.name == generated_name + class TestConversationServiceMessageAnnotation: """ @@ -1066,3 +1100,32 @@ class TestConversationServiceExport: not_deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation.id)) assert not_deleted is not None mock_delete_task.delay.assert_not_called() + + @patch("services.conversation_service.delete_conversation_related_data") + def test_delete_handles_exception_and_rollback(self, mock_delete_task, db_session_with_containers): + """ + Test that delete propagates exceptions and does not trigger the cleanup task. + + When a DB error occurs during deletion, the service must rollback the + transaction and re-raise the exception without scheduling async cleanup. + """ + # Arrange + app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( + db_session_with_containers + ) + conversation = ConversationServiceIntegrationTestDataFactory.create_conversation( + db_session_with_containers, app_model, user + ) + conversation_id = conversation.id + + # Act — force an error during the delete to exercise the rollback path + with patch("services.conversation_service.db.session.delete", side_effect=Exception("DB error")): + with pytest.raises(Exception, match="DB error"): + ConversationService.delete(app_model=app_model, conversation_id=conversation_id, user=user) + + # Assert — async cleanup must NOT have been scheduled + mock_delete_task.delay.assert_not_called() + + # Conversation is still present because the deletion was never committed + still_there = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id)) + assert still_there is not None diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_update_dataset.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_update_dataset.py index a814466e14..2a2d86a8a6 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_update_dataset.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_update_dataset.py @@ -1,3 +1,4 @@ +import json from unittest.mock import Mock, patch from uuid import uuid4 @@ -7,7 +8,7 @@ from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexTechniqueType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole -from models.dataset import Dataset, ExternalKnowledgeBindings +from models.dataset import Dataset, ExternalKnowledgeApis, ExternalKnowledgeBindings from models.enums import DataSourceType from services.dataset_service import DatasetService from services.errors.account import NoPermissionError @@ -103,6 +104,34 @@ class DatasetUpdateTestDataFactory: db_session_with_containers.commit() return binding + @staticmethod + def create_external_knowledge_api( + db_session_with_containers: Session, + tenant_id: str, + created_by: str, + api_id: str | None = None, + name: str = "test-api", + ) -> ExternalKnowledgeApis: + """Create a real external knowledge API template for tenant-scoped update validation.""" + external_api = ExternalKnowledgeApis( + tenant_id=tenant_id, + created_by=created_by, + updated_by=created_by, + name=name, + description="test description", + settings=json.dumps( + { + "endpoint": "https://example.com", + "api_key": "test-api-key", + } + ), + ) + if api_id is not None: + external_api.id = api_id + db_session_with_containers.add(external_api) + db_session_with_containers.commit() + return external_api + class TestDatasetServiceUpdateDataset: """ @@ -138,6 +167,11 @@ class TestDatasetServiceUpdateDataset: ) binding_id = binding.id db_session_with_containers.expunge(binding) + external_api = DatasetUpdateTestDataFactory.create_external_knowledge_api( + db_session_with_containers, + tenant_id=tenant.id, + created_by=user.id, + ) update_data = { "name": "new_name", @@ -145,7 +179,7 @@ class TestDatasetServiceUpdateDataset: "external_retrieval_model": "new_model", "permission": "only_me", "external_knowledge_id": "new_knowledge_id", - "external_knowledge_api_id": str(uuid4()), + "external_knowledge_api_id": external_api.id, } result = DatasetService.update_dataset(dataset.id, update_data, user) @@ -218,11 +252,16 @@ class TestDatasetServiceUpdateDataset: created_by=user.id, provider="external", ) + external_api = DatasetUpdateTestDataFactory.create_external_knowledge_api( + db_session_with_containers, + tenant_id=tenant.id, + created_by=user.id, + ) update_data = { "name": "new_name", "external_knowledge_id": "knowledge_id", - "external_knowledge_api_id": str(uuid4()), + "external_knowledge_api_id": external_api.id, } with pytest.raises(ValueError) as context: diff --git a/api/tests/unit_tests/services/test_hit_testing_service.py b/api/tests/test_containers_integration_tests/services/test_hit_testing_service.py similarity index 51% rename from api/tests/unit_tests/services/test_hit_testing_service.py rename to api/tests/test_containers_integration_tests/services/test_hit_testing_service.py index 80e9729f5b..f332ba05ec 100644 --- a/api/tests/unit_tests/services/test_hit_testing_service.py +++ b/api/tests/test_containers_integration_tests/services/test_hit_testing_service.py @@ -1,239 +1,193 @@ +from __future__ import annotations + import json from typing import Any, cast from unittest.mock import ANY, MagicMock, patch +from uuid import uuid4 import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session from core.rag.models.document import Document -from models.dataset import Dataset +from models.dataset import Dataset, DatasetQuery from services.hit_testing_service import HitTestingService -class TestHitTestingService: - """Test suite for HitTestingService""" +def _create_dataset(db_session: Session, *, provider: str = "vendor", **kwargs: Any) -> Dataset: + tenant_id = str(uuid4()) + created_by = str(uuid4()) + ds = Dataset( + tenant_id=kwargs.get("tenant_id", tenant_id), + name=kwargs.get("name", "test-dataset"), + created_by=kwargs.get("created_by", created_by), + provider=provider, + ) + db_session.add(ds) + db_session.commit() + db_session.refresh(ds) + return ds - # ===== Utility Method Tests ===== + +class TestHitTestingService: + # ── Utility methods (pure logic, no DB) ──────────────────────────── def test_escape_query_for_search_should_escape_double_quotes(self): - """Test that escape_query_for_search escapes double quotes correctly""" - # Arrange query = 'test "query" with quotes' - expected = 'test \\"query\\" with quotes' - - # Act result = HitTestingService.escape_query_for_search(query) - - # Assert - assert result == expected + assert result == 'test \\"query\\" with quotes' def test_hit_testing_args_check_should_pass_with_valid_query(self): - """Test that hit_testing_args_check passes with a valid query""" - # Arrange - args = {"query": "valid query"} - - # Act & Assert (should not raise) - HitTestingService.hit_testing_args_check(args) + HitTestingService.hit_testing_args_check({"query": "valid query"}) def test_hit_testing_args_check_should_pass_with_valid_attachments(self): - """Test that hit_testing_args_check passes with valid attachment_ids""" - # Arrange - args = {"attachment_ids": ["id1", "id2"]} - - # Act & Assert (should not raise) - HitTestingService.hit_testing_args_check(args) + HitTestingService.hit_testing_args_check({"attachment_ids": ["id1", "id2"]}) def test_hit_testing_args_check_should_raise_error_when_no_query_or_attachments(self): - """Test that hit_testing_args_check raises ValueError if both query and attachment_ids are missing""" - # Arrange - args = {} - - # Act & Assert - with pytest.raises(ValueError) as exc_info: - HitTestingService.hit_testing_args_check(args) - assert "Query or attachment_ids is required" in str(exc_info.value) + with pytest.raises(ValueError, match="Query or attachment_ids is required"): + HitTestingService.hit_testing_args_check({}) def test_hit_testing_args_check_should_raise_error_when_query_too_long(self): - """Test that hit_testing_args_check raises ValueError if query exceeds 250 characters""" - # Arrange - args = {"query": "a" * 251} - - # Act & Assert - with pytest.raises(ValueError) as exc_info: - HitTestingService.hit_testing_args_check(args) - assert "Query cannot exceed 250 characters" in str(exc_info.value) + with pytest.raises(ValueError, match="Query cannot exceed 250 characters"): + HitTestingService.hit_testing_args_check({"query": "a" * 251}) def test_hit_testing_args_check_should_raise_error_when_attachments_not_list(self): - """Test that hit_testing_args_check raises ValueError if attachment_ids is not a list""" - # Arrange - args = {"attachment_ids": "not a list"} + with pytest.raises(ValueError, match="Attachment_ids must be a list"): + HitTestingService.hit_testing_args_check({"attachment_ids": "not a list"}) - # Act & Assert - with pytest.raises(ValueError) as exc_info: - HitTestingService.hit_testing_args_check(args) - assert "Attachment_ids must be a list" in str(exc_info.value) - - # ===== Response Formatting Tests ===== + # ── Response formatting ──────────────────────────────────────────── @patch("core.rag.datasource.retrieval_service.RetrievalService.format_retrieval_documents") def test_compact_retrieve_response_should_format_correctly(self, mock_format): - """Test that compact_retrieve_response formats the response correctly""" - # Arrange query = "test query" mock_doc = MagicMock(spec=Document) - documents = [mock_doc] mock_record = MagicMock() mock_record.model_dump.return_value = {"content": "formatted content"} mock_format.return_value = [mock_record] - # Act - result = cast(dict[str, Any], HitTestingService.compact_retrieve_response(query, documents)) + result = cast(dict[str, Any], HitTestingService.compact_retrieve_response(query, [mock_doc])) - # Assert assert cast(dict[str, Any], result["query"])["content"] == query assert len(result["records"]) == 1 assert cast(dict[str, Any], result["records"][0])["content"] == "formatted content" - mock_format.assert_called_once_with(documents) + mock_format.assert_called_once_with([mock_doc]) - def test_compact_external_retrieve_response_should_return_records_for_external_provider(self): - """Test that compact_external_retrieve_response returns records when dataset provider is external""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.provider = "external" - query = "test query" + def test_compact_external_retrieve_response_should_return_records_for_external_provider( + self, db_session_with_containers: Session + ): + dataset = _create_dataset(db_session_with_containers, provider="external") documents = [ {"content": "c1", "title": "t1", "score": 0.9, "metadata": {"m1": "v1"}}, {"content": "c2", "title": "t2", "score": 0.8, "metadata": {"m2": "v2"}}, ] - # Act - result = cast(dict[str, Any], HitTestingService.compact_external_retrieve_response(dataset, query, documents)) + result = cast( + dict[str, Any], HitTestingService.compact_external_retrieve_response(dataset, "test query", documents) + ) - # Assert - assert cast(dict[str, Any], result["query"])["content"] == query + assert cast(dict[str, Any], result["query"])["content"] == "test query" assert len(result["records"]) == 2 assert cast(dict[str, Any], result["records"][0])["content"] == "c1" assert cast(dict[str, Any], result["records"][1])["title"] == "t2" - def test_compact_external_retrieve_response_should_return_empty_for_non_external_provider(self): - """Test that compact_external_retrieve_response returns empty records for non-external provider""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.provider = "not_external" - query = "test query" - documents = [{"content": "c1"}] + def test_compact_external_retrieve_response_should_return_empty_for_non_external_provider( + self, db_session_with_containers: Session + ): + dataset = _create_dataset(db_session_with_containers, provider="vendor") - # Act - result = cast(dict[str, Any], HitTestingService.compact_external_retrieve_response(dataset, query, documents)) + result = cast( + dict[str, Any], + HitTestingService.compact_external_retrieve_response(dataset, "test query", [{"content": "c1"}]), + ) - # Assert - assert cast(dict[str, Any], result["query"])["content"] == query + assert cast(dict[str, Any], result["query"])["content"] == "test query" assert result["records"] == [] - # ===== External Retrieve Tests ===== + # ── External retrieve (real DB) ──────────────────────────────────── @patch("core.rag.datasource.retrieval_service.RetrievalService.external_retrieve") - @patch("extensions.ext_database.db.session.add") - @patch("extensions.ext_database.db.session.commit") - def test_external_retrieve_should_succeed_for_external_provider(self, mock_commit, mock_add, mock_ext_retrieve): - """Test that external_retrieve successfully retrieves from external provider and commits query""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.id = "dataset_id" - dataset.provider = "external" - query = 'test "query"' + def test_external_retrieve_should_succeed_for_external_provider( + self, mock_ext_retrieve, db_session_with_containers: Session + ): + dataset = _create_dataset(db_session_with_containers, provider="external") + account_id = str(uuid4()) account = MagicMock() - account.id = "account_id" - + account.id = account_id mock_ext_retrieve.return_value = [{"content": "ext content", "score": 1.0}] - # Act + before_count = db_session_with_containers.scalar(select(func.count()).select_from(DatasetQuery)) or 0 + result = cast( dict[str, Any], HitTestingService.external_retrieve( dataset=dataset, - query=query, + query='test "query"', account=account, external_retrieval_model={"model": "test"}, metadata_filtering_conditions={"key": "val"}, ), ) - # Assert - assert cast(dict[str, Any], result["query"])["content"] == query + assert cast(dict[str, Any], result["query"])["content"] == 'test "query"' assert cast(dict[str, Any], result["records"][0])["content"] == "ext content" - - # Verify call to RetrievalService.external_retrieve with escaped query mock_ext_retrieve.assert_called_once_with( - dataset_id="dataset_id", + dataset_id=dataset.id, query='test \\"query\\"', external_retrieval_model={"model": "test"}, metadata_filtering_conditions={"key": "val"}, ) - # Verify DatasetQuery record was added and committed - mock_add.assert_called_once() - mock_commit.assert_called_once() + db_session_with_containers.expire_all() + after_count = db_session_with_containers.scalar(select(func.count()).select_from(DatasetQuery)) or 0 + assert after_count == before_count + 1 - def test_external_retrieve_should_return_empty_for_non_external_provider(self): - """Test that external_retrieve returns empty results immediately if provider is not external""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.provider = "not_external" - query = "test query" + def test_external_retrieve_should_return_empty_for_non_external_provider(self, db_session_with_containers: Session): + dataset = _create_dataset(db_session_with_containers, provider="vendor") account = MagicMock() - # Act - result = cast(dict[str, Any], HitTestingService.external_retrieve(dataset, query, account)) + result = cast(dict[str, Any], HitTestingService.external_retrieve(dataset, "test query", account)) - # Assert - assert cast(dict[str, Any], result["query"])["content"] == query + assert cast(dict[str, Any], result["query"])["content"] == "test query" assert result["records"] == [] - # ===== Retrieve Tests ===== + # ── Retrieve (real DB) ───────────────────────────────────────────── @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") - @patch("extensions.ext_database.db.session.add") - @patch("extensions.ext_database.db.session.commit") - def test_retrieve_should_use_default_model_when_none_provided(self, mock_commit, mock_add, mock_retrieve): - """Test that retrieve uses default model when retrieval_model is not provided""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.id = "dataset_id" + def test_retrieve_should_use_default_model_when_none_provided( + self, mock_retrieve, db_session_with_containers: Session + ): + dataset = _create_dataset(db_session_with_containers) dataset.retrieval_model = None - query = "test query" account = MagicMock() - account.id = "account_id" - + account.id = str(uuid4()) mock_retrieve.return_value = [] - # Act + before_count = db_session_with_containers.scalar(select(func.count()).select_from(DatasetQuery)) or 0 + result = cast( dict[str, Any], HitTestingService.retrieve( - dataset=dataset, query=query, account=account, retrieval_model=None, external_retrieval_model={} + dataset=dataset, query="test query", account=account, retrieval_model=None, external_retrieval_model={} ), ) - # Assert - assert cast(dict[str, Any], result["query"])["content"] == query + assert cast(dict[str, Any], result["query"])["content"] == "test query" mock_retrieve.assert_called_once() - # Verify top_k from default_retrieval_model (4) assert mock_retrieve.call_args.kwargs["top_k"] == 4 - mock_commit.assert_called_once() + + db_session_with_containers.expire_all() + after_count = db_session_with_containers.scalar(select(func.count()).select_from(DatasetQuery)) or 0 + assert after_count == before_count + 1 @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") @patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval.get_metadata_filter_condition") - @patch("extensions.ext_database.db.session.add") - @patch("extensions.ext_database.db.session.commit") - def test_retrieve_should_handle_metadata_filtering(self, mock_commit, mock_add, mock_get_meta, mock_retrieve): - """Test that retrieve correctly calls metadata filtering when conditions are present""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.id = "dataset_id" - query = "test query" + def test_retrieve_should_handle_metadata_filtering( + self, mock_get_meta, mock_retrieve, db_session_with_containers: Session + ): + dataset = _create_dataset(db_session_with_containers) account = MagicMock() - account.id = "account_id" + account.id = str(uuid4()) retrieval_model = { "search_method": "semantic_search", @@ -242,29 +196,27 @@ class TestHitTestingService: "reranking_enable": False, "score_threshold_enabled": False, } - - # Mock metadata filtering response - mock_get_meta.return_value = ({"dataset_id": ["doc_id1"]}, "condition_string") + mock_get_meta.return_value = ({dataset.id: ["doc_id1"]}, "condition_string") mock_retrieve.return_value = [] - # Act HitTestingService.retrieve( - dataset=dataset, query=query, account=account, retrieval_model=retrieval_model, external_retrieval_model={} + dataset=dataset, + query="test query", + account=account, + retrieval_model=retrieval_model, + external_retrieval_model={}, ) - # Assert mock_get_meta.assert_called_once() mock_retrieve.assert_called_once() assert mock_retrieve.call_args.kwargs["document_ids_filter"] == ["doc_id1"] @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") @patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval.get_metadata_filter_condition") - def test_retrieve_should_return_empty_if_metadata_filtering_fails(self, mock_get_meta, mock_retrieve): - """Test that retrieve returns empty response if metadata filtering returns condition but no document IDs""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.id = "dataset_id" - query = "test query" + def test_retrieve_should_return_empty_if_metadata_filtering_fails( + self, mock_get_meta, mock_retrieve, db_session_with_containers: Session + ): + dataset = _create_dataset(db_session_with_containers) account = MagicMock() retrieval_model = { @@ -274,37 +226,27 @@ class TestHitTestingService: "reranking_enable": False, "score_threshold_enabled": False, } - - # Mock metadata filtering response: condition returned but no IDs mock_get_meta.return_value = ({}, "condition_string") - # Act result = cast( dict[str, Any], HitTestingService.retrieve( dataset=dataset, - query=query, + query="test query", account=account, retrieval_model=retrieval_model, external_retrieval_model={}, ), ) - # Assert assert result["records"] == [] mock_retrieve.assert_not_called() @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") - @patch("extensions.ext_database.db.session.add") - @patch("extensions.ext_database.db.session.commit") - def test_retrieve_should_handle_attachments(self, mock_commit, mock_add, mock_retrieve): - """Test that retrieve handles attachment_ids and adds them to DatasetQuery""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.id = "dataset_id" - query = "test query" + def test_retrieve_should_handle_attachments(self, mock_retrieve, db_session_with_containers: Session): + dataset = _create_dataset(db_session_with_containers) account = MagicMock() - account.id = "account_id" + account.id = str(uuid4()) attachment_ids = ["att1", "att2"] retrieval_model = { @@ -315,21 +257,19 @@ class TestHitTestingService: } mock_retrieve.return_value = [] - # Act HitTestingService.retrieve( dataset=dataset, - query=query, + query="test query", account=account, retrieval_model=retrieval_model, external_retrieval_model={}, attachment_ids=attachment_ids, ) - # Assert mock_retrieve.assert_called_once_with( retrieval_method=ANY, - dataset_id="dataset_id", - query=query, + dataset_id=dataset.id, + query="test query", attachment_ids=attachment_ids, top_k=4, score_threshold=0.0, @@ -338,26 +278,27 @@ class TestHitTestingService: weights=None, document_ids_filter=None, ) - # Verify DatasetQuery record (there should be 2 queries: 1 text, 2 images) - # The content is json.dumps([{"content_type": "text_query", ...}, {"content_type": "image_query", ...}]) - called_query = mock_add.call_args[0][0] - query_content = json.loads(called_query.content) + + # Verify DatasetQuery was persisted with correct content structure + db_session_with_containers.expire_all() + latest = db_session_with_containers.scalar( + select(DatasetQuery) + .where(DatasetQuery.dataset_id == dataset.id) + .order_by(DatasetQuery.created_at.desc()) + .limit(1) + ) + assert latest is not None + query_content = json.loads(latest.content) assert len(query_content) == 3 # 1 text + 2 images assert query_content[0]["content_type"] == "text_query" assert query_content[1]["content_type"] == "image_query" assert query_content[1]["content"] == "att1" @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") - @patch("extensions.ext_database.db.session.add") - @patch("extensions.ext_database.db.session.commit") - def test_retrieve_should_handle_reranking_and_threshold(self, mock_commit, mock_add, mock_retrieve): - """Test that retrieve passes reranking and threshold parameters correctly""" - # Arrange - dataset = MagicMock(spec=Dataset) - dataset.id = "dataset_id" - query = "test query" + def test_retrieve_should_handle_reranking_and_threshold(self, mock_retrieve, db_session_with_containers: Session): + dataset = _create_dataset(db_session_with_containers) account = MagicMock() - account.id = "account_id" + account.id = str(uuid4()) retrieval_model = { "search_method": "hybrid_search", @@ -371,12 +312,14 @@ class TestHitTestingService: } mock_retrieve.return_value = [] - # Act HitTestingService.retrieve( - dataset=dataset, query=query, account=account, retrieval_model=retrieval_model, external_retrieval_model={} + dataset=dataset, + query="test query", + account=account, + retrieval_model=retrieval_model, + external_retrieval_model={}, ) - # Assert mock_retrieve.assert_called_once() kwargs = mock_retrieve.call_args.kwargs assert kwargs["score_threshold"] == 0.5 diff --git a/api/tests/test_containers_integration_tests/services/test_ops_service.py b/api/tests/test_containers_integration_tests/services/test_ops_service.py new file mode 100644 index 0000000000..e2e1a228b2 --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_ops_service.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import uuid +from unittest.mock import patch + +import pytest +from faker import Faker +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.ops.entities.config_entity import TracingProviderEnum +from models.model import TraceAppConfig +from services.account_service import AccountService, TenantService +from services.app_service import AppService +from services.ops_service import OpsService +from tests.test_containers_integration_tests.helpers import generate_valid_password + + +class TestOpsService: + @pytest.fixture + def mock_external_service_dependencies(self): + with ( + patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.EnterpriseService") as mock_enterprise_service, + patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, + patch("services.account_service.FeatureService") as mock_account_feature_service, + ): + mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None + mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None + mock_account_feature_service.get_system_features.return_value.is_allow_register = True + mock_model_instance = mock_model_manager.return_value + mock_model_instance.get_default_model_instance.return_value = None + mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo") + yield { + "feature_service": mock_feature_service, + "enterprise_service": mock_enterprise_service, + "model_manager": mock_model_manager, + "account_feature_service": mock_account_feature_service, + } + + @pytest.fixture + def mock_ops_trace_manager(self): + with patch("services.ops_service.OpsTraceManager") as mock: + yield mock + + def _create_app(self, db_session_with_containers: Session, mock_external_service_dependencies): + fake = Faker() + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + app_service = AppService() + app = app_service.create_app( + tenant.id, + { + "name": fake.company(), + "description": fake.text(max_nb_chars=100), + "mode": "chat", + "icon_type": "emoji", + "icon": "🤖", + "icon_background": "#FF6B6B", + }, + account, + ) + return app, account + + _SENTINEL = object() + + def _insert_trace_config( + self, + db_session: Session, + app_id: str, + provider: str, + tracing_config: dict | None | object = _SENTINEL, + ) -> TraceAppConfig: + trace_config = TraceAppConfig( + app_id=app_id, + tracing_provider=provider, + tracing_config=tracing_config if tracing_config is not self._SENTINEL else {"some": "config"}, + ) + db_session.add(trace_config) + db_session.commit() + return trace_config + + # ── get_tracing_app_config ───────────────────────────────────────── + + def test_get_tracing_app_config_no_config(self, db_session_with_containers: Session, mock_ops_trace_manager): + result = OpsService.get_tracing_app_config(str(uuid.uuid4()), "arize") + assert result is None + + def test_get_tracing_app_config_no_app(self, db_session_with_containers: Session, mock_ops_trace_manager): + fake_app_id = str(uuid.uuid4()) + self._insert_trace_config(db_session_with_containers, fake_app_id, "arize") + result = OpsService.get_tracing_app_config(fake_app_id, "arize") + assert result is None + + def test_get_tracing_app_config_none_config( + self, db_session_with_containers: Session, mock_external_service_dependencies, mock_ops_trace_manager + ): + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, "arize", tracing_config=None) + + with pytest.raises(ValueError, match="Tracing config cannot be None."): + OpsService.get_tracing_app_config(app.id, "arize") + + @pytest.mark.parametrize( + ("provider", "default_url"), + [ + ("arize", "https://app.arize.com/"), + ("phoenix", "https://app.phoenix.arize.com/projects/"), + ("langsmith", "https://smith.langchain.com/"), + ("opik", "https://www.comet.com/opik/"), + ("weave", "https://wandb.ai/"), + ("aliyun", "https://arms.console.aliyun.com/"), + ("tencent", "https://console.cloud.tencent.com/apm"), + ("mlflow", "http://localhost:5000/"), + ("databricks", "https://www.databricks.com/"), + ], + ) + def test_get_tracing_app_config_providers_exception( + self, db_session_with_containers: Session, mock_external_service_dependencies, provider, default_url + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.decrypt_tracing_config.return_value = {} + mock_otm.obfuscated_decrypt_token.return_value = {} + mock_otm.get_trace_config_project_url.side_effect = Exception("error") + mock_otm.get_trace_config_project_key.side_effect = Exception("error") + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, provider) + + result = OpsService.get_tracing_app_config(app.id, provider) + + assert result is not None + assert result["tracing_config"]["project_url"] == default_url + + @pytest.mark.parametrize( + "provider", + ["arize", "phoenix", "langsmith", "opik", "weave", "aliyun", "tencent", "mlflow", "databricks"], + ) + def test_get_tracing_app_config_providers_success( + self, db_session_with_containers: Session, mock_external_service_dependencies, provider + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.decrypt_tracing_config.return_value = {} + mock_otm.obfuscated_decrypt_token.return_value = {"project_url": "success_url"} + mock_otm.get_trace_config_project_url.return_value = "success_url" + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, provider) + + result = OpsService.get_tracing_app_config(app.id, provider) + + assert result is not None + assert result["tracing_config"]["project_url"] == "success_url" + + def test_get_tracing_app_config_langfuse_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"} + mock_otm.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"} + mock_otm.get_trace_config_project_key.return_value = "key" + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, "langfuse") + + result = OpsService.get_tracing_app_config(app.id, "langfuse") + + assert result is not None + assert result["tracing_config"]["project_url"] == "https://api.langfuse.com/project/key" + + def test_get_tracing_app_config_langfuse_exception( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"} + mock_otm.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"} + mock_otm.get_trace_config_project_key.side_effect = Exception("error") + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, "langfuse") + + result = OpsService.get_tracing_app_config(app.id, "langfuse") + + assert result is not None + assert result["tracing_config"]["project_url"] == "https://api.langfuse.com/" + + # ── create_tracing_app_config ────────────────────────────────────── + + def test_create_tracing_app_config_invalid_provider(self, db_session_with_containers: Session): + result = OpsService.create_tracing_app_config(str(uuid.uuid4()), "invalid_provider", {}) + assert result == {"error": "Invalid tracing provider: invalid_provider"} + + def test_create_tracing_app_config_invalid_credentials( + self, db_session_with_containers: Session, mock_ops_trace_manager + ): + mock_ops_trace_manager.check_trace_config_is_effective.return_value = False + result = OpsService.create_tracing_app_config( + str(uuid.uuid4()), TracingProviderEnum.LANGFUSE, {"public_key": "p", "secret_key": "s"} + ) + assert result == {"error": "Invalid Credentials"} + + @pytest.mark.parametrize( + ("provider", "config"), + [ + (TracingProviderEnum.ARIZE, {}), + (TracingProviderEnum.LANGFUSE, {"public_key": "p", "secret_key": "s"}), + (TracingProviderEnum.LANGSMITH, {"api_key": "k", "project": "p"}), + (TracingProviderEnum.ALIYUN, {"license_key": "k", "endpoint": "https://aliyun.com"}), + ], + ) + def test_create_tracing_app_config_project_url_exception( + self, db_session_with_containers: Session, mock_external_service_dependencies, provider, config + ): + # Existing config causes the service to return None before reaching the DB insert + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.check_trace_config_is_effective.return_value = True + mock_otm.get_trace_config_project_url.side_effect = Exception("error") + mock_otm.get_trace_config_project_key.side_effect = Exception("error") + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, str(provider)) + + result = OpsService.create_tracing_app_config(app.id, provider, config) + + assert result is None + + def test_create_tracing_app_config_langfuse_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.check_trace_config_is_effective.return_value = True + mock_otm.get_trace_config_project_key.return_value = "key" + mock_otm.encrypt_tracing_config.return_value = {} + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + result = OpsService.create_tracing_app_config( + app.id, + TracingProviderEnum.LANGFUSE, + {"public_key": "p", "secret_key": "s", "host": "https://api.langfuse.com"}, + ) + + assert result == {"result": "success"} + + def test_create_tracing_app_config_already_exists( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.check_trace_config_is_effective.return_value = True + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, str(TracingProviderEnum.ARIZE)) + + result = OpsService.create_tracing_app_config(app.id, TracingProviderEnum.ARIZE, {}) + + assert result is None + + def test_create_tracing_app_config_no_app(self, db_session_with_containers: Session, mock_ops_trace_manager): + mock_ops_trace_manager.check_trace_config_is_effective.return_value = True + result = OpsService.create_tracing_app_config(str(uuid.uuid4()), TracingProviderEnum.ARIZE, {}) + assert result is None + + def test_create_tracing_app_config_with_empty_other_keys( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + # "project" is in other_keys for Arize; providing "" triggers default substitution + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.check_trace_config_is_effective.return_value = True + mock_otm.get_trace_config_project_url.side_effect = Exception("no url") + mock_otm.encrypt_tracing_config.return_value = {} + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + result = OpsService.create_tracing_app_config(app.id, TracingProviderEnum.ARIZE, {"project": ""}) + + assert result == {"result": "success"} + + def test_create_tracing_app_config_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.check_trace_config_is_effective.return_value = True + mock_otm.get_trace_config_project_url.return_value = "http://project_url" + mock_otm.encrypt_tracing_config.return_value = {"encrypted": "config"} + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + result = OpsService.create_tracing_app_config(app.id, TracingProviderEnum.ARIZE, {}) + + assert result == {"result": "success"} + + # ── update_tracing_app_config ────────────────────────────────────── + + def test_update_tracing_app_config_invalid_provider(self, db_session_with_containers: Session): + with pytest.raises(ValueError, match="Invalid tracing provider: invalid_provider"): + OpsService.update_tracing_app_config(str(uuid.uuid4()), "invalid_provider", {}) + + def test_update_tracing_app_config_no_config(self, db_session_with_containers: Session, mock_ops_trace_manager): + result = OpsService.update_tracing_app_config(str(uuid.uuid4()), TracingProviderEnum.ARIZE, {}) + assert result is None + + def test_update_tracing_app_config_no_app(self, db_session_with_containers: Session, mock_ops_trace_manager): + fake_app_id = str(uuid.uuid4()) + self._insert_trace_config(db_session_with_containers, fake_app_id, str(TracingProviderEnum.ARIZE)) + mock_ops_trace_manager.encrypt_tracing_config.return_value = {} + result = OpsService.update_tracing_app_config(fake_app_id, TracingProviderEnum.ARIZE, {}) + assert result is None + + def test_update_tracing_app_config_invalid_credentials( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.encrypt_tracing_config.return_value = {} + mock_otm.decrypt_tracing_config.return_value = {} + mock_otm.check_trace_config_is_effective.return_value = False + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, str(TracingProviderEnum.ARIZE)) + + with pytest.raises(ValueError, match="Invalid Credentials"): + OpsService.update_tracing_app_config(app.id, TracingProviderEnum.ARIZE, {}) + + def test_update_tracing_app_config_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + with patch("services.ops_service.OpsTraceManager") as mock_otm: + mock_otm.encrypt_tracing_config.return_value = {"updated": "config"} + mock_otm.decrypt_tracing_config.return_value = {} + mock_otm.check_trace_config_is_effective.return_value = True + + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, str(TracingProviderEnum.ARIZE)) + + result = OpsService.update_tracing_app_config(app.id, TracingProviderEnum.ARIZE, {}) + + assert result is not None + assert result["app_id"] == app.id + + # ── delete_tracing_app_config ────────────────────────────────────── + + def test_delete_tracing_app_config_no_config(self, db_session_with_containers: Session): + result = OpsService.delete_tracing_app_config(str(uuid.uuid4()), "arize") + assert result is None + + def test_delete_tracing_app_config_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + app, _ = self._create_app(db_session_with_containers, mock_external_service_dependencies) + self._insert_trace_config(db_session_with_containers, app.id, "arize") + + result = OpsService.delete_tracing_app_config(app.id, "arize") + + assert result is True + remaining = db_session_with_containers.scalar( + select(TraceAppConfig) + .where(TraceAppConfig.app_id == app.id, TraceAppConfig.tracing_provider == "arize") + .limit(1) + ) + assert remaining is None diff --git a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py new file mode 100644 index 0000000000..ccc4188dbf --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.model import AccountTrialAppRecord, TrialApp +from services import recommended_app_service as service_module +from services.recommended_app_service import RecommendedAppService + +# ── Helpers ──────────────────────────────────────────────────────────── + + +def _apps_response( + recommended_apps: list[dict] | None = None, + categories: list[str] | None = None, +) -> dict: + if recommended_apps is None: + recommended_apps = [ + {"id": "app-1", "name": "Test App 1", "description": "d1", "category": "productivity"}, + {"id": "app-2", "name": "Test App 2", "description": "d2", "category": "communication"}, + ] + if categories is None: + categories = ["productivity", "communication", "utilities"] + return {"recommended_apps": recommended_apps, "categories": categories} + + +def _app_detail( + app_id: str = "app-123", + name: str = "Test App", + description: str = "Test description", + **kwargs: Any, +) -> dict: + detail: dict[str, Any] = { + "id": app_id, + "name": name, + "description": description, + "category": kwargs.get("category", "productivity"), + "icon": kwargs.get("icon", "🚀"), + "model_config": kwargs.get("model_config", {}), + } + detail.update(kwargs) + return detail + + +def _recommendation_detail(result: dict[str, Any] | None) -> dict[str, Any] | None: + return cast("dict[str, Any] | None", result) + + +def _mock_factory_for_apps( + monkeypatch: pytest.MonkeyPatch, + *, + mode: str, + result: dict[str, Any], + fallback_result: dict[str, Any] | None = None, +) -> tuple[MagicMock, MagicMock]: + retrieval_instance = MagicMock() + retrieval_instance.get_recommended_apps_and_categories.return_value = result + retrieval_factory = MagicMock(return_value=retrieval_instance) + monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", mode, raising=False) + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_recommend_app_factory", + MagicMock(return_value=retrieval_factory), + ) + builtin_instance = MagicMock() + if fallback_result is not None: + builtin_instance.fetch_recommended_apps_from_builtin.return_value = fallback_result + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_buildin_recommend_app_retrieval", + MagicMock(return_value=builtin_instance), + ) + return retrieval_instance, builtin_instance + + +# ── Pure logic tests: get_recommended_apps_and_categories ────────────── + + +class TestRecommendedAppServiceGetApps: + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_success_with_apps(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + expected = _apps_response() + + mock_instance = MagicMock() + mock_instance.get_recommended_apps_and_categories.return_value = expected + mock_factory = MagicMock(return_value=mock_instance) + mock_factory_class.get_recommend_app_factory.return_value = mock_factory + + result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + + assert result == expected + assert len(result["recommended_apps"]) == 2 + assert len(result["categories"]) == 3 + mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote") + mock_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_fallback_to_builtin_when_empty(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + empty_response = {"recommended_apps": [], "categories": []} + builtin_response = _apps_response( + recommended_apps=[{"id": "builtin-1", "name": "Builtin App", "category": "default"}] + ) + + mock_remote_instance = MagicMock() + mock_remote_instance.get_recommended_apps_and_categories.return_value = empty_response + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_remote_instance) + + mock_builtin_instance = MagicMock() + mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response + mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance + + result = RecommendedAppService.get_recommended_apps_and_categories("zh-CN") + + assert result == builtin_response + assert result["recommended_apps"][0]["id"] == "builtin-1" + mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_fallback_when_none_recommended_apps(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db" + none_response = {"recommended_apps": None, "categories": ["test"]} + builtin_response = _apps_response() + + mock_db_instance = MagicMock() + mock_db_instance.get_recommended_apps_and_categories.return_value = none_response + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_db_instance) + + mock_builtin_instance = MagicMock() + mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response + mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance + + result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + + assert result == builtin_response + mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once() + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_different_languages(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" + + for language in ["en-US", "zh-CN", "ja-JP", "fr-FR"]: + lang_response = _apps_response( + recommended_apps=[{"id": f"app-{language}", "name": f"App {language}", "category": "test"}] + ) + mock_instance = MagicMock() + mock_instance.get_recommended_apps_and_categories.return_value = lang_response + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = RecommendedAppService.get_recommended_apps_and_categories(language) + + assert result["recommended_apps"][0]["id"] == f"app-{language}" + mock_instance.get_recommended_apps_and_categories.assert_called_with(language) + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_uses_correct_factory_mode(self, mock_config, mock_factory_class): + for mode in ["remote", "builtin", "db"]: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode + response = _apps_response() + mock_instance = MagicMock() + mock_instance.get_recommended_apps_and_categories.return_value = response + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + RecommendedAppService.get_recommended_apps_and_categories("en-US") + + mock_factory_class.get_recommend_app_factory.assert_called_with(mode) + + +# ── Pure logic tests: get_recommend_app_detail ───────────────────────── + + +class TestRecommendedAppServiceGetDetail: + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_success(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + expected = _app_detail(app_id="app-123", name="Productivity App", description="A great app") + + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = expected + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("app-123")) + + assert result == expected + assert result["id"] == "app-123" + mock_instance.get_recommend_app_detail.assert_called_once_with("app-123") + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_different_modes(self, mock_config, mock_factory_class): + for mode in ["remote", "builtin", "db"]: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode + detail = _app_detail(app_id="test-app", name=f"App from {mode}") + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = detail + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("test-app")) + + assert result["name"] == f"App from {mode}" + mock_factory_class.get_recommend_app_factory.assert_called_with(mode) + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_returns_none_when_not_found(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = None + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("nonexistent")) + + assert result is None + mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent") + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_returns_empty_dict(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = {} + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("app-empty")) + + assert result == {} + + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_complex_model_config(self, mock_config, mock_factory_class): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + complex_config = { + "provider": "openai", + "model": "gpt-4", + "parameters": {"temperature": 0.7, "max_tokens": 2000, "top_p": 1.0}, + } + expected = _app_detail( + app_id="complex-app", + name="Complex App", + model_config=complex_config, + workflows=["workflow-1", "workflow-2"], + tools=["tool-1", "tool-2", "tool-3"], + ) + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = expected + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + + result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("complex-app")) + + assert result["model_config"] == complex_config + assert len(result["workflows"]) == 2 + assert len(result["tools"]) == 3 + + +# ── Integration tests: trial app features (real DB) ──────────────────── + + +class TestRecommendedAppServiceTrialFeatures: + def test_get_apps_should_not_query_trial_table_when_disabled( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): + expected = {"recommended_apps": [{"app_id": "app-1"}], "categories": ["all"]} + retrieval_instance, builtin_instance = _mock_factory_for_apps(monkeypatch, mode="remote", result=expected) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), + ) + + result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + + assert result == expected + retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") + builtin_instance.fetch_recommended_apps_from_builtin.assert_not_called() + + def test_get_apps_should_enrich_can_trial_when_enabled( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): + app_id_1 = str(uuid.uuid4()) + app_id_2 = str(uuid.uuid4()) + tenant_id = str(uuid.uuid4()) + + # app_id_1 has a TrialApp record; app_id_2 does not + db_session_with_containers.add(TrialApp(app_id=app_id_1, tenant_id=tenant_id)) + db_session_with_containers.commit() + + remote_result = {"recommended_apps": [], "categories": []} + fallback_result = { + "recommended_apps": [{"app_id": app_id_1}, {"app_id": app_id_2}], + "categories": ["all"], + } + _, builtin_instance = _mock_factory_for_apps( + monkeypatch, mode="remote", result=remote_result, fallback_result=fallback_result + ) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), + ) + + result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP") + + builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") + assert result["recommended_apps"][0]["can_trial"] is True + assert result["recommended_apps"][1]["can_trial"] is False + + @pytest.mark.parametrize("has_trial_app", [True, False]) + def test_get_detail_should_set_can_trial_when_enabled( + self, + db_session_with_containers: Session, + monkeypatch: pytest.MonkeyPatch, + has_trial_app: bool, + ): + app_id = str(uuid.uuid4()) + tenant_id = str(uuid.uuid4()) + + if has_trial_app: + db_session_with_containers.add(TrialApp(app_id=app_id, tenant_id=tenant_id)) + db_session_with_containers.commit() + + detail = {"id": app_id, "name": "Test App"} + retrieval_instance = MagicMock() + retrieval_instance.get_recommend_app_detail.return_value = detail + retrieval_factory = MagicMock(return_value=retrieval_instance) + monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False) + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_recommend_app_factory", + MagicMock(return_value=retrieval_factory), + ) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), + ) + + result = cast(dict[str, Any], RecommendedAppService.get_recommend_app_detail(app_id)) + + assert result["id"] == app_id + assert result["can_trial"] is has_trial_app + + def test_add_trial_app_record_increments_count_for_existing(self, db_session_with_containers: Session): + app_id = str(uuid.uuid4()) + account_id = str(uuid.uuid4()) + + db_session_with_containers.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=3)) + db_session_with_containers.commit() + + RecommendedAppService.add_trial_app_record(app_id, account_id) + + db_session_with_containers.expire_all() + record = db_session_with_containers.scalar( + select(AccountTrialAppRecord) + .where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id) + .limit(1) + ) + assert record is not None + assert record.count == 4 + + def test_add_trial_app_record_creates_new_record(self, db_session_with_containers: Session): + app_id = str(uuid.uuid4()) + account_id = str(uuid.uuid4()) + + RecommendedAppService.add_trial_app_record(app_id, account_id) + + db_session_with_containers.expire_all() + record = db_session_with_containers.scalar( + select(AccountTrialAppRecord) + .where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id) + .limit(1) + ) + assert record is not None + assert record.app_id == app_id + assert record.account_id == account_id + assert record.count == 1 diff --git a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py index 4fe65d5803..7825f502f7 100644 --- a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py @@ -233,11 +233,10 @@ class TestWebAppAuthService: assert result.status == AccountStatus.ACTIVE # Verify database state - - db_session_with_containers.refresh(result) - assert result.id is not None - assert result.password is not None - assert result.password_salt is not None + refreshed = db_session_with_containers.get(Account, result.id) + assert refreshed is not None + assert refreshed.password is not None + assert refreshed.password_salt is not None def test_authenticate_account_not_found( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -414,9 +413,8 @@ class TestWebAppAuthService: assert result.status == AccountStatus.ACTIVE # Verify database state - - db_session_with_containers.refresh(result) - assert result.id is not None + refreshed = db_session_with_containers.get(Account, result.id) + assert refreshed is not None def test_get_user_through_email_not_found( self, db_session_with_containers: Session, mock_external_service_dependencies diff --git a/api/tests/test_containers_integration_tests/tasks/test_create_segment_to_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_create_segment_to_index_task.py index 9f8e37fc9e..9084667c31 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_create_segment_to_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_create_segment_to_index_task.py @@ -11,6 +11,7 @@ from uuid import uuid4 import pytest from faker import Faker +from sqlalchemy import delete from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from extensions.ext_redis import redis_client @@ -28,12 +29,12 @@ class TestCreateSegmentToIndexTask: """Clean up database and Redis before each test to ensure isolation.""" # Clear all test data using fixture session - db_session_with_containers.query(DocumentSegment).delete() - db_session_with_containers.query(Document).delete() - db_session_with_containers.query(Dataset).delete() - db_session_with_containers.query(TenantAccountJoin).delete() - db_session_with_containers.query(Tenant).delete() - db_session_with_containers.query(Account).delete() + db_session_with_containers.execute(delete(DocumentSegment)) + db_session_with_containers.execute(delete(Document)) + db_session_with_containers.execute(delete(Dataset)) + db_session_with_containers.execute(delete(TenantAccountJoin)) + db_session_with_containers.execute(delete(Tenant)) + db_session_with_containers.execute(delete(Account)) db_session_with_containers.commit() # Clear Redis cache diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_email_code_login_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_email_code_login_task.py index c0ddc27286..8343711998 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_email_code_login_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_email_code_login_task.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker +from sqlalchemy import delete from libs.email_i18n import EmailType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole @@ -41,9 +42,9 @@ class TestSendEmailCodeLoginMailTask: from extensions.ext_redis import redis_client # Clear all test data - db_session_with_containers.query(TenantAccountJoin).delete() - db_session_with_containers.query(Tenant).delete() - db_session_with_containers.query(Account).delete() + db_session_with_containers.execute(delete(TenantAccountJoin)) + db_session_with_containers.execute(delete(Tenant)) + db_session_with_containers.execute(delete(Account)) db_session_with_containers.commit() # Clear Redis cache diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py index a16f3ff773..1b4dcf28ea 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py @@ -6,6 +6,7 @@ import pytest from graphon.enums import WorkflowExecutionStatus from graphon.nodes.human_input.entities import HumanInputNodeData from graphon.runtime import GraphRuntimeState, VariablePool +from sqlalchemy import delete from configs import dify_config from core.app.app_config.entities import WorkflowUIBasedAppConfig @@ -30,14 +31,14 @@ from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task @pytest.fixture(autouse=True) def cleanup_database(db_session_with_containers): - db_session_with_containers.query(HumanInputFormRecipient).delete() - db_session_with_containers.query(HumanInputDelivery).delete() - db_session_with_containers.query(HumanInputForm).delete() - db_session_with_containers.query(WorkflowPause).delete() - db_session_with_containers.query(WorkflowRun).delete() - db_session_with_containers.query(TenantAccountJoin).delete() - db_session_with_containers.query(Tenant).delete() - db_session_with_containers.query(Account).delete() + db_session_with_containers.execute(delete(HumanInputFormRecipient)) + db_session_with_containers.execute(delete(HumanInputDelivery)) + db_session_with_containers.execute(delete(HumanInputForm)) + db_session_with_containers.execute(delete(WorkflowPause)) + db_session_with_containers.execute(delete(WorkflowRun)) + db_session_with_containers.execute(delete(TenantAccountJoin)) + db_session_with_containers.execute(delete(Tenant)) + db_session_with_containers.execute(delete(Account)) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_invite_member_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_invite_member_task.py index 212fbd26cd..d34828c4b1 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_invite_member_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_invite_member_task.py @@ -17,6 +17,7 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker +from sqlalchemy import delete, select from extensions.ext_redis import redis_client from libs.email_i18n import EmailType @@ -44,9 +45,9 @@ class TestMailInviteMemberTask: def cleanup_database(self, db_session_with_containers): """Clean up database before each test to ensure isolation.""" # Clear all test data - db_session_with_containers.query(TenantAccountJoin).delete() - db_session_with_containers.query(Tenant).delete() - db_session_with_containers.query(Account).delete() + db_session_with_containers.execute(delete(TenantAccountJoin)) + db_session_with_containers.execute(delete(Tenant)) + db_session_with_containers.execute(delete(Account)) db_session_with_containers.commit() # Clear Redis cache @@ -491,10 +492,10 @@ class TestMailInviteMemberTask: assert tenant.name is not None # Verify tenant relationship exists - tenant_join = ( - db_session_with_containers.query(TenantAccountJoin) - .filter_by(tenant_id=tenant.id, account_id=pending_account.id) - .first() + tenant_join = db_session_with_containers.scalar( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == pending_account.id) + .limit(1) ) assert tenant_join is not None assert tenant_join.role == TenantAccountRole.NORMAL diff --git a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py index 96cf9cebf5..b5bef145d5 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py @@ -4,6 +4,7 @@ from unittest.mock import ANY, call, patch import pytest from graphon.variables.segments import StringSegment from graphon.variables.types import SegmentType +from sqlalchemy import delete, func, select from core.db.session_factory import session_factory from extensions.storage.storage_type import StorageType @@ -20,11 +21,11 @@ from tasks.remove_app_and_related_data_task import ( @pytest.fixture(autouse=True) def cleanup_database(db_session_with_containers): - db_session_with_containers.query(WorkflowDraftVariable).delete() - db_session_with_containers.query(WorkflowDraftVariableFile).delete() - db_session_with_containers.query(UploadFile).delete() - db_session_with_containers.query(App).delete() - db_session_with_containers.query(Tenant).delete() + db_session_with_containers.execute(delete(WorkflowDraftVariable)) + db_session_with_containers.execute(delete(WorkflowDraftVariableFile)) + db_session_with_containers.execute(delete(UploadFile)) + db_session_with_containers.execute(delete(App)) + db_session_with_containers.execute(delete(Tenant)) db_session_with_containers.commit() @@ -127,21 +128,21 @@ class TestDeleteDraftVariablesBatch: result = delete_draft_variables_batch(app1.id, batch_size=100) assert result == 150 - app1_remaining = db_session_with_containers.query(WorkflowDraftVariable).where( - WorkflowDraftVariable.app_id == app1.id + app1_remaining_count = db_session_with_containers.scalar( + select(func.count()).select_from(WorkflowDraftVariable).where(WorkflowDraftVariable.app_id == app1.id) ) - app2_remaining = db_session_with_containers.query(WorkflowDraftVariable).where( - WorkflowDraftVariable.app_id == app2.id + app2_remaining_count = db_session_with_containers.scalar( + select(func.count()).select_from(WorkflowDraftVariable).where(WorkflowDraftVariable.app_id == app2.id) ) - assert app1_remaining.count() == 0 - assert app2_remaining.count() == 100 + assert app1_remaining_count == 0 + assert app2_remaining_count == 100 def test_delete_draft_variables_batch_empty_result(self, db_session_with_containers): """Test deletion when no draft variables exist for the app.""" result = delete_draft_variables_batch(str(uuid.uuid4()), 1000) assert result == 0 - assert db_session_with_containers.query(WorkflowDraftVariable).count() == 0 + assert db_session_with_containers.scalar(select(func.count()).select_from(WorkflowDraftVariable)) == 0 @patch("tasks.remove_app_and_related_data_task._delete_draft_variable_offload_data") @patch("tasks.remove_app_and_related_data_task.logger") @@ -190,12 +191,16 @@ class TestDeleteDraftVariableOffloadData: expected_storage_calls = [call(storage_key) for storage_key in upload_file_keys] mock_storage.delete.assert_has_calls(expected_storage_calls, any_order=True) - remaining_var_files = db_session_with_containers.query(WorkflowDraftVariableFile).where( - WorkflowDraftVariableFile.id.in_(file_ids) + remaining_var_files_count = db_session_with_containers.scalar( + select(func.count()) + .select_from(WorkflowDraftVariableFile) + .where(WorkflowDraftVariableFile.id.in_(file_ids)) ) - remaining_upload_files = db_session_with_containers.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)) - assert remaining_var_files.count() == 0 - assert remaining_upload_files.count() == 0 + remaining_upload_files_count = db_session_with_containers.scalar( + select(func.count()).select_from(UploadFile).where(UploadFile.id.in_(upload_file_ids)) + ) + assert remaining_var_files_count == 0 + assert remaining_upload_files_count == 0 @patch("extensions.ext_storage.storage") @patch("tasks.remove_app_and_related_data_task.logging") @@ -217,9 +222,13 @@ class TestDeleteDraftVariableOffloadData: assert result == 1 mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0]) - remaining_var_files = db_session_with_containers.query(WorkflowDraftVariableFile).where( - WorkflowDraftVariableFile.id.in_(file_ids) + remaining_var_files_count = db_session_with_containers.scalar( + select(func.count()) + .select_from(WorkflowDraftVariableFile) + .where(WorkflowDraftVariableFile.id.in_(file_ids)) ) - remaining_upload_files = db_session_with_containers.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)) - assert remaining_var_files.count() == 0 - assert remaining_upload_files.count() == 0 + remaining_upload_files_count = db_session_with_containers.scalar( + select(func.count()).select_from(UploadFile).where(UploadFile.id.in_(upload_file_ids)) + ) + assert remaining_var_files_count == 0 + assert remaining_upload_files_count == 0 diff --git a/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py b/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py index 159ab51304..4bc022c415 100644 --- a/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py +++ b/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py @@ -26,7 +26,7 @@ from datetime import timedelta import pytest from graphon.entities import WorkflowExecution from graphon.enums import WorkflowExecutionStatus -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session, selectinload, sessionmaker from extensions.ext_storage import storage @@ -679,9 +679,12 @@ class TestWorkflowPauseIntegration: # Verify only 3 were deleted remaining_count = ( - self.session.query(WorkflowPauseModel) - .filter(WorkflowPauseModel.id.in_([pe.id for pe in pause_entities])) - .count() + self.session.scalar( + select(func.count(WorkflowPauseModel.id)).where( + WorkflowPauseModel.id.in_([pe.id for pe in pause_entities]) + ) + ) + or 0 ) assert remaining_count == 2 diff --git a/api/tests/test_containers_integration_tests/trigger/conftest.py b/api/tests/test_containers_integration_tests/trigger/conftest.py index e3832fb2ef..272bee9630 100644 --- a/api/tests/test_containers_integration_tests/trigger/conftest.py +++ b/api/tests/test_containers_integration_tests/trigger/conftest.py @@ -11,6 +11,7 @@ from collections.abc import Generator from typing import Any import pytest +from sqlalchemy import delete from sqlalchemy.orm import Session from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole @@ -40,9 +41,9 @@ def tenant_and_account(db_session_with_containers: Session) -> Generator[tuple[T yield tenant, account # Cleanup - db_session_with_containers.query(TenantAccountJoin).filter_by(tenant_id=tenant.id).delete() - db_session_with_containers.query(Account).filter_by(id=account.id).delete() - db_session_with_containers.query(Tenant).filter_by(id=tenant.id).delete() + db_session_with_containers.execute(delete(TenantAccountJoin).where(TenantAccountJoin.tenant_id == tenant.id)) + db_session_with_containers.execute(delete(Account).where(Account.id == account.id)) + db_session_with_containers.execute(delete(Tenant).where(Tenant.id == tenant.id)) db_session_with_containers.commit() @@ -93,14 +94,14 @@ def app_model( ) from models.workflow import Workflow - db_session_with_containers.query(WorkflowTriggerLog).filter_by(app_id=app.id).delete() - db_session_with_containers.query(WorkflowSchedulePlan).filter_by(app_id=app.id).delete() - db_session_with_containers.query(WorkflowWebhookTrigger).filter_by(app_id=app.id).delete() - db_session_with_containers.query(WorkflowPluginTrigger).filter_by(app_id=app.id).delete() - db_session_with_containers.query(AppTrigger).filter_by(app_id=app.id).delete() - db_session_with_containers.query(TriggerSubscription).filter_by(tenant_id=tenant.id).delete() - db_session_with_containers.query(Workflow).filter_by(app_id=app.id).delete() - db_session_with_containers.query(App).filter_by(id=app.id).delete() + db_session_with_containers.execute(delete(WorkflowTriggerLog).where(WorkflowTriggerLog.app_id == app.id)) + db_session_with_containers.execute(delete(WorkflowSchedulePlan).where(WorkflowSchedulePlan.app_id == app.id)) + db_session_with_containers.execute(delete(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.app_id == app.id)) + db_session_with_containers.execute(delete(WorkflowPluginTrigger).where(WorkflowPluginTrigger.app_id == app.id)) + db_session_with_containers.execute(delete(AppTrigger).where(AppTrigger.app_id == app.id)) + db_session_with_containers.execute(delete(TriggerSubscription).where(TriggerSubscription.tenant_id == tenant.id)) + db_session_with_containers.execute(delete(Workflow).where(Workflow.app_id == app.id)) + db_session_with_containers.execute(delete(App).where(App.id == app.id)) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py index 7539bae685..d725fb990a 100644 --- a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py +++ b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py @@ -11,6 +11,7 @@ import pytest from flask import Flask, Response from flask.testing import FlaskClient from graphon.enums import BuiltinNodeTypes +from sqlalchemy import select from sqlalchemy.orm import Session from configs import dify_config @@ -227,7 +228,9 @@ def test_webhook_trigger_creates_trigger_log( assert response.status_code == 200 db_session_with_containers.expire_all() - logs = db_session_with_containers.query(WorkflowTriggerLog).filter_by(app_id=app_model.id).all() + logs = db_session_with_containers.scalars( + select(WorkflowTriggerLog).where(WorkflowTriggerLog.app_id == app_model.id) + ).all() assert logs, "Webhook trigger should create trigger log" @@ -611,7 +614,9 @@ def test_schedule_trigger_creates_trigger_log( # Verify WorkflowTriggerLog was created db_session_with_containers.expire_all() - logs = db_session_with_containers.query(WorkflowTriggerLog).filter_by(app_id=app_model.id).all() + logs = db_session_with_containers.scalars( + select(WorkflowTriggerLog).where(WorkflowTriggerLog.app_id == app_model.id) + ).all() assert logs, "Schedule trigger should create WorkflowTriggerLog" assert logs[0].trigger_type == AppTriggerType.TRIGGER_SCHEDULE assert logs[0].root_node_id == schedule_node_id @@ -786,11 +791,12 @@ def test_plugin_trigger_full_chain_with_db_verification( # Verify database records exist db_session_with_containers.expire_all() - plugin_triggers = ( - db_session_with_containers.query(WorkflowPluginTrigger) - .filter_by(app_id=app_model.id, node_id=plugin_node_id) - .all() - ) + plugin_triggers = db_session_with_containers.scalars( + select(WorkflowPluginTrigger).where( + WorkflowPluginTrigger.app_id == app_model.id, + WorkflowPluginTrigger.node_id == plugin_node_id, + ) + ).all() assert plugin_triggers, "WorkflowPluginTrigger record should exist" assert plugin_triggers[0].provider_id == provider_id assert plugin_triggers[0].event_name == "test_event" diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index d6933e2180..3089750c3e 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -145,7 +145,7 @@ def test_inner_api_config_exist(monkeypatch: pytest.MonkeyPatch): def test_db_extras_options_merging(monkeypatch: pytest.MonkeyPatch): - """Test that DB_EXTRAS options are properly merged with default timezone setting""" + """Test that DB_EXTRAS options are merged with the default timezone startup option.""" # Set environment variables monkeypatch.setenv("DB_TYPE", "postgresql") monkeypatch.setenv("DB_USERNAME", "postgres") @@ -158,15 +158,28 @@ def test_db_extras_options_merging(monkeypatch: pytest.MonkeyPatch): # Create config config = DifyConfig() - # Get engine options - engine_options = config.SQLALCHEMY_ENGINE_OPTIONS - - # Verify options contains both search_path and timezone - options = engine_options["connect_args"]["options"] + options = config.SQLALCHEMY_ENGINE_OPTIONS["connect_args"]["options"] assert "search_path=myschema" in options assert "timezone=UTC" in options +def test_db_session_timezone_override_can_disable_app_level_timezone_injection(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("DB_TYPE", "postgresql") + monkeypatch.setenv("DB_USERNAME", "postgres") + monkeypatch.setenv("DB_PASSWORD", "postgres") + monkeypatch.setenv("DB_HOST", "localhost") + monkeypatch.setenv("DB_PORT", "5432") + monkeypatch.setenv("DB_DATABASE", "dify") + monkeypatch.setenv("DB_EXTRAS", "options=-c search_path=myschema") + monkeypatch.setenv("DB_SESSION_TIMEZONE_OVERRIDE", "") + + config = DifyConfig() + + assert config.SQLALCHEMY_ENGINE_OPTIONS["connect_args"] == { + "options": "-c search_path=myschema", + } + + def test_pubsub_redis_url_default(monkeypatch: pytest.MonkeyPatch): os.environ.clear() diff --git a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py new file mode 100644 index 0000000000..baac4cd4e0 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py @@ -0,0 +1,70 @@ +import datetime + +from controllers.console.app.mcp_server import AppMCPServerResponse + + +class TestAppMCPServerResponse: + def test_parameters_json_string_parsed(self): + data = { + "id": "s1", + "name": "test", + "server_code": "code", + "description": "desc", + "status": "active", + "parameters": '{"key": "value"}', + } + resp = AppMCPServerResponse.model_validate(data) + assert resp.parameters == {"key": "value"} + + def test_parameters_invalid_json_returns_original(self): + data = { + "id": "s1", + "name": "test", + "server_code": "code", + "description": "desc", + "status": "active", + "parameters": "not-valid-json", + } + resp = AppMCPServerResponse.model_validate(data) + assert resp.parameters == "not-valid-json" + + def test_parameters_dict_passthrough(self): + data = { + "id": "s1", + "name": "test", + "server_code": "code", + "description": "desc", + "status": "active", + "parameters": {"already": "parsed"}, + } + resp = AppMCPServerResponse.model_validate(data) + assert resp.parameters == {"already": "parsed"} + + def test_timestamps_normalized(self): + dt = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.UTC) + data = { + "id": "s1", + "name": "test", + "server_code": "code", + "description": "desc", + "status": "active", + "parameters": {}, + "created_at": dt, + "updated_at": dt, + } + resp = AppMCPServerResponse.model_validate(data) + assert resp.created_at == int(dt.timestamp()) + assert resp.updated_at == int(dt.timestamp()) + + def test_timestamps_none(self): + data = { + "id": "s1", + "name": "test", + "server_code": "code", + "description": "desc", + "status": "active", + "parameters": {}, + } + resp = AppMCPServerResponse.model_validate(data) + assert resp.created_at is None + assert resp.updated_at is None diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index 560971206f..0cf97da878 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -14,18 +14,20 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask from flask_restx import Api +from werkzeug.exceptions import Unauthorized from controllers.console.auth.error import ( AuthenticationFailedError, EmailPasswordLoginLimitError, InvalidEmailError, ) -from controllers.console.auth.login import LoginApi, LogoutApi +from controllers.console.auth.login import EmailCodeLoginApi, LoginApi, LogoutApi from controllers.console.error import ( AccountBannedError, AccountInFreezeError, WorkspacesLimitExceeded, ) +from services.entities.auth_entities import LoginFailureReason from services.errors.account import AccountLoginError, AccountPasswordError @@ -34,6 +36,11 @@ def encode_password(password: str) -> str: return base64.b64encode(password.encode("utf-8")).decode() +def encode_code(code: str) -> str: + """Helper to encode verification code as Base64 for testing.""" + return base64.b64encode(code.encode("utf-8")).decode() + + class TestLoginApi: """Test cases for the LoginApi endpoint.""" @@ -197,12 +204,17 @@ class TestLoginApi: mock_get_invitation.return_value = None # Act & Assert - with app.test_request_context( - "/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")} - ): - login_api = LoginApi() - with pytest.raises(EmailPasswordLoginLimitError): - login_api.post() + with patch("controllers.console.auth.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")} + ): + login_api = LoginApi() + with pytest.raises(EmailPasswordLoginLimitError): + login_api.post() + + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "test@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.LOGIN_RATE_LIMITED @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", True) @@ -220,12 +232,17 @@ class TestLoginApi: mock_is_frozen.return_value = True # Act & Assert - with app.test_request_context( - "/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")} - ): - login_api = LoginApi() - with pytest.raises(AccountInFreezeError): - login_api.post() + with patch("controllers.console.auth.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")} + ): + login_api = LoginApi() + with pytest.raises(AccountInFreezeError): + login_api.post() + + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "frozen@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_IN_FREEZE @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @@ -257,14 +274,20 @@ class TestLoginApi: mock_authenticate.side_effect = AccountPasswordError("Invalid password") # Act & Assert - with app.test_request_context( - "/login", method="POST", json={"email": "test@example.com", "password": encode_password("WrongPass123!")} - ): - login_api = LoginApi() - with pytest.raises(AuthenticationFailedError): - login_api.post() + with patch("controllers.console.auth.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/login", + method="POST", + json={"email": "test@example.com", "password": encode_password("WrongPass123!")}, + ): + login_api = LoginApi() + with pytest.raises(AuthenticationFailedError): + login_api.post() mock_add_rate_limit.assert_called_once_with("test@example.com") + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "test@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_CREDENTIALS @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @@ -288,12 +311,19 @@ class TestLoginApi: mock_authenticate.side_effect = AccountLoginError("Account is banned") # Act & Assert - with app.test_request_context( - "/login", method="POST", json={"email": "banned@example.com", "password": encode_password("ValidPass123!")} - ): - login_api = LoginApi() - with pytest.raises(AccountBannedError): - login_api.post() + with patch("controllers.console.auth.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/login", + method="POST", + json={"email": "banned@example.com", "password": encode_password("ValidPass123!")}, + ): + login_api = LoginApi() + with pytest.raises(AccountBannedError): + login_api.post() + + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "banned@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @@ -417,6 +447,36 @@ class TestLoginApi: mock_add_rate_limit.assert_not_called() mock_reset_rate_limit.assert_called_once_with("upper@example.com") + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") + @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login._get_account_with_case_fallback") + def test_email_code_login_logs_banned_account( + self, + mock_get_account, + mock_revoke_token, + mock_get_token_data, + mock_db, + app, + ): + mock_db.session.query.return_value.first.return_value = MagicMock() + mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"} + mock_get_account.side_effect = Unauthorized("Account is banned.") + + with patch("controllers.console.auth.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/email-code-login/validity", + method="POST", + json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"}, + ): + with pytest.raises(AccountBannedError): + EmailCodeLoginApi().post() + + mock_revoke_token.assert_called_once_with("token-123") + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "user@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED + class TestLogoutApi: """Test cases for the LogoutApi endpoint.""" diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index 8555900f4e..94d6c17915 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -1555,7 +1555,17 @@ class TestDatasetApiKeyApi: method = unwrap(api.get) mock_key_1 = MagicMock(spec=ApiToken) + mock_key_1.id = "key-1" + mock_key_1.type = "dataset" + mock_key_1.token = "ds-abc" + mock_key_1.last_used_at = None + mock_key_1.created_at = None mock_key_2 = MagicMock(spec=ApiToken) + mock_key_2.id = "key-2" + mock_key_2.type = "dataset" + mock_key_2.token = "ds-def" + mock_key_2.last_used_at = None + mock_key_2.created_at = None with ( app.test_request_context("/"), @@ -1570,13 +1580,26 @@ class TestDatasetApiKeyApi: ): response = method(api) - assert "items" in response - assert response["items"] == [mock_key_1, mock_key_2] + assert "data" in response + assert len(response["data"]) == 2 + assert response["data"][0]["id"] == "key-1" + assert response["data"][0]["token"] == "ds-abc" + assert response["data"][1]["id"] == "key-2" + assert response["data"][1]["token"] == "ds-def" def test_post_create_api_key_success(self, app): api = DatasetApiKeyApi() method = unwrap(api.post) + mock_token = MagicMock() + mock_token.id = "new-key-id" + mock_token.last_used_at = None + mock_token.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.UTC) + + mock_api_token_cls = MagicMock() + mock_api_token_cls.return_value = mock_token + mock_api_token_cls.generate_api_key.return_value = "dataset-abc123" + with ( app.test_request_context("/"), patch( @@ -1588,8 +1611,8 @@ class TestDatasetApiKeyApi: return_value=3, ), patch( - "controllers.console.datasets.datasets.ApiToken.generate_api_key", - return_value="dataset-abc123", + "controllers.console.datasets.datasets.ApiToken", + mock_api_token_cls, ), patch( "controllers.console.datasets.datasets.db.session.add", @@ -1603,9 +1626,11 @@ class TestDatasetApiKeyApi: response, status = method(api) assert status == 200 - assert isinstance(response, ApiToken) - assert response.token == "dataset-abc123" - assert response.type == "dataset" + assert isinstance(response, dict) + assert response["id"] == "new-key-id" + assert response["token"] == "dataset-abc123" + assert response["type"] == "dataset" + assert response["created_at"] is not None def test_post_exceed_max_keys(self, app): api = DatasetApiKeyApi() diff --git a/api/tests/unit_tests/controllers/console/datasets/test_external.py b/api/tests/unit_tests/controllers/console/datasets/test_external.py index 161d0c41e8..514bbbe040 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_external.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_external.py @@ -1,3 +1,4 @@ +from importlib import import_module from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -11,6 +12,7 @@ from controllers.console.datasets.external import ( BedrockRetrievalApi, ExternalApiTemplateApi, ExternalApiTemplateListApi, + ExternalApiUseCheckApi, ExternalDatasetCreateApi, ExternalKnowledgeHitTestingApi, ) @@ -19,6 +21,8 @@ from services.external_knowledge_service import ExternalDatasetService from services.hit_testing_service import HitTestingService from services.knowledge_service import ExternalDatasetTestService +external_controller = import_module("controllers.console.datasets.external") + def unwrap(func): while hasattr(func, "__wrapped__"): @@ -44,10 +48,11 @@ def current_user(): @pytest.fixture(autouse=True) -def mock_auth(mocker, current_user): - mocker.patch( - "controllers.console.datasets.external.current_account_with_tenant", - return_value=(current_user, "tenant-1"), +def mock_auth(monkeypatch, current_user): + monkeypatch.setattr( + external_controller, + "current_account_with_tenant", + lambda: (current_user, "tenant-1"), ) @@ -136,6 +141,26 @@ class TestExternalApiTemplateApi: method(api, "api-id") +class TestExternalApiUseCheckApi: + def test_get_scopes_usage_check_to_current_tenant(self, app): + api = ExternalApiUseCheckApi() + method = unwrap(api.get) + + with ( + app.test_request_context("/"), + patch.object( + ExternalDatasetService, + "external_knowledge_api_use_check", + return_value=(True, 2), + ) as mock_use_check, + ): + response, status = method(api, "api-id") + + assert status == 200 + assert response == {"is_using": True, "count": 2} + mock_use_check.assert_called_once_with("api-id", "tenant-1") + + class TestExternalDatasetCreateApi: def test_create_success(self, app): api = ExternalDatasetCreateApi() diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index 7f9fe9cbf9..dd643faac9 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -233,15 +233,20 @@ class TestCheckEmailUnique: def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup(): - session = MagicMock() + mock_session = MagicMock() first = MagicMock() first.scalar_one_or_none.return_value = None second = MagicMock() expected_account = MagicMock() second.scalar_one_or_none.return_value = expected_account - session.execute.side_effect = [first, second] + mock_session.execute.side_effect = [first, second] - result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=session) + mock_factory = MagicMock() + mock_factory.create_session.return_value.__enter__ = MagicMock(return_value=mock_session) + mock_factory.create_session.return_value.__exit__ = MagicMock(return_value=False) + + with patch("services.account_service.session_factory", mock_factory): + result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com") assert result is expected_account - assert session.execute.call_count == 2 + assert mock_session.execute.call_count == 2 diff --git a/api/tests/unit_tests/controllers/service_api/app/test_audio.py b/api/tests/unit_tests/controllers/service_api/app/test_audio.py index 5a8cb4619f..a26fea8fbd 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_audio.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_audio.py @@ -95,30 +95,6 @@ class TestTextToAudioPayload: assert payload.streaming is True -# --------------------------------------------------------------------------- -# AudioService Interface Tests -# --------------------------------------------------------------------------- - - -class TestAudioServiceInterface: - """Test AudioService method interfaces exist.""" - - def test_transcript_asr_method_exists(self): - """Test that AudioService.transcript_asr exists.""" - assert hasattr(AudioService, "transcript_asr") - assert callable(AudioService.transcript_asr) - - def test_transcript_tts_method_exists(self): - """Test that AudioService.transcript_tts exists.""" - assert hasattr(AudioService, "transcript_tts") - assert callable(AudioService.transcript_tts) - - -# --------------------------------------------------------------------------- -# Audio Service Tests -# --------------------------------------------------------------------------- - - class TestAudioServiceInterface: """Test suite for AudioService interface methods.""" diff --git a/api/tests/unit_tests/controllers/web/test_message_endpoints.py b/api/tests/unit_tests/controllers/web/test_message_endpoints.py index 89ab93d8d4..da88b109a8 100644 --- a/api/tests/unit_tests/controllers/web/test_message_endpoints.py +++ b/api/tests/unit_tests/controllers/web/test_message_endpoints.py @@ -129,12 +129,6 @@ class TestMessageSuggestedQuestionApi: with pytest.raises(NotChatAppError): MessageSuggestedQuestionApi().get(_completion_app(), _end_user(), msg_id) - def test_wrong_mode_raises(self, app: Flask) -> None: - msg_id = uuid4() - with app.test_request_context(f"/messages/{msg_id}/suggested-questions"): - with pytest.raises(NotChatAppError): - MessageSuggestedQuestionApi().get(_completion_app(), _end_user(), msg_id) - @patch("controllers.web.message.MessageService.get_suggested_questions_after_answer") def test_happy_path(self, mock_suggest: MagicMock, app: Flask) -> None: msg_id = uuid4() diff --git a/api/tests/unit_tests/controllers/web/test_pydantic_models.py b/api/tests/unit_tests/controllers/web/test_pydantic_models.py index dcf8133712..bceb65b89f 100644 --- a/api/tests/unit_tests/controllers/web/test_pydantic_models.py +++ b/api/tests/unit_tests/controllers/web/test_pydantic_models.py @@ -198,7 +198,7 @@ class TestMessageListQuery: assert q.limit == 20 def test_invalid_conversation_id(self) -> None: - with pytest.raises(ValidationError, match="not a valid uuid"): + with pytest.raises(ValidationError, match="must be a valid UUID"): MessageListQuery(conversation_id="bad") def test_limit_bounds(self) -> None: @@ -216,7 +216,7 @@ class TestMessageListQuery: def test_invalid_first_id(self) -> None: cid = str(uuid4()) - with pytest.raises(ValidationError, match="not a valid uuid"): + with pytest.raises(ValidationError, match="must be a valid UUID"): MessageListQuery(conversation_id=cid, first_id="invalid") diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index 0661c02578..a01587d64a 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -4,9 +4,12 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask +from jwt import InvalidTokenError +from werkzeug.exceptions import Unauthorized import services.errors.account from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi +from services.entities.auth_entities import LoginFailureReason def encode_code(code: str) -> str: @@ -115,13 +118,18 @@ class TestLoginApi: def test_login_banned_account(self, mock_auth: MagicMock, app: Flask) -> None: from controllers.console.error import AccountBannedError - with app.test_request_context( - "/web/login", - method="POST", - json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()}, - ): - with pytest.raises(AccountBannedError): - LoginApi().post() + with patch("controllers.web.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/web/login", + method="POST", + json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()}, + ): + with pytest.raises(AccountBannedError): + LoginApi().post() + + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "user@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED @patch( "controllers.web.login.WebAppAuthService.authenticate", @@ -130,13 +138,87 @@ class TestLoginApi: def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask) -> None: from controllers.console.auth.error import AuthenticationFailedError - with app.test_request_context( - "/web/login", - method="POST", - json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()}, - ): - with pytest.raises(AuthenticationFailedError): - LoginApi().post() + with patch("controllers.web.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/web/login", + method="POST", + json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()}, + ): + with pytest.raises(AuthenticationFailedError): + LoginApi().post() + + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "user@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_CREDENTIALS + + @patch( + "controllers.web.login.WebAppAuthService.authenticate", + side_effect=services.errors.account.AccountNotFoundError(), + ) + def test_login_account_not_found(self, mock_auth: MagicMock, app: Flask) -> None: + from controllers.console.auth.error import AuthenticationFailedError + + with patch("controllers.web.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/web/login", + method="POST", + json={"email": "missing@example.com", "password": base64.b64encode(b"Valid1234").decode()}, + ): + with pytest.raises(AuthenticationFailedError): + LoginApi().post() + + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "missing@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_NOT_FOUND + + @patch("controllers.web.login.WebAppAuthService.get_email_code_login_data", return_value=None) + def test_email_code_login_logs_invalid_token(self, mock_get_token_data: MagicMock, app: Flask) -> None: + with patch("controllers.web.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/web/email-code-login/validity", + method="POST", + json={"email": "user@example.com", "code": encode_code("123456"), "token": "token-123"}, + ): + with pytest.raises(InvalidTokenError): + EmailCodeLoginApi().post() + + mock_get_token_data.assert_called_once_with("token-123") + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "user@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_EMAIL_CODE_TOKEN + + @patch("controllers.web.login.WebAppAuthService.revoke_email_code_login_token") + @patch( + "controllers.web.login.WebAppAuthService.get_user_through_email", + side_effect=Unauthorized("Account is banned."), + ) + @patch( + "controllers.web.login.WebAppAuthService.get_email_code_login_data", + return_value={"email": "User@Example.com", "code": "123456"}, + ) + def test_email_code_login_logs_banned_account( + self, + mock_get_token_data: MagicMock, + mock_get_user: MagicMock, + mock_revoke_token: MagicMock, + app: Flask, + ) -> None: + from controllers.console.error import AccountBannedError + + with patch("controllers.web.login.logger.warning") as mock_log_warning: + with app.test_request_context( + "/web/email-code-login/validity", + method="POST", + json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"}, + ): + with pytest.raises(AccountBannedError): + EmailCodeLoginApi().post() + + mock_get_token_data.assert_called_once_with("token-123") + mock_revoke_token.assert_called_once_with("token-123") + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args.args[1] == "user@example.com" + assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED class TestLoginStatusApi: diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py index 061719d15a..1fb0dc6cf1 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py @@ -134,6 +134,7 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( + patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, @@ -150,7 +151,9 @@ class TestAdvancedChatAppRunnerConversationVariables: patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, ): # Setup mocks - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + mock_session_class.return_value.__enter__.return_value = MagicMock() mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() @@ -177,7 +180,6 @@ class TestAdvancedChatAppRunnerConversationVariables: # Note: Since we're mocking ConversationVariable.from_variable, # we can't directly check the id, but we can verify add_all was called assert mock_session.add_all.called, "Session add_all should have been called" - assert mock_session.commit.called, "Session commit should have been called" def test_no_variables_creates_all(self): """Test that all conversation variables are created when none exist in DB.""" @@ -278,6 +280,7 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( + patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, @@ -295,7 +298,9 @@ class TestAdvancedChatAppRunnerConversationVariables: patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, ): # Setup mocks - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + mock_session_class.return_value.__enter__.return_value = MagicMock() mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() @@ -326,7 +331,6 @@ class TestAdvancedChatAppRunnerConversationVariables: # Verify that all variables were created assert len(added_items) == 2, "Should have added both variables" assert mock_session.add_all.called, "Session add_all should have been called" - assert mock_session.commit.called, "Session commit should have been called" def test_all_variables_exist_no_changes(self): """Test that no changes are made when all variables already exist in DB.""" @@ -429,6 +433,7 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( + patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, @@ -445,7 +450,9 @@ class TestAdvancedChatAppRunnerConversationVariables: patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, ): # Setup mocks - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + mock_session_class.return_value.__enter__.return_value = MagicMock() mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() @@ -465,4 +472,3 @@ class TestAdvancedChatAppRunnerConversationVariables: # Verify that no variables were added assert not mock_session.add_all.called, "Session add_all should not have been called" - assert mock_session.commit.called, "Session commit should still be called" diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py index 079df0b4e6..5d8faee897 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py @@ -93,6 +93,16 @@ def _patch_common_run_deps(runner: AdvancedChatAppRunner): scalar=lambda *a, **k: MagicMock(), ), ), + sessionmaker=MagicMock( + return_value=MagicMock( + begin=MagicMock( + return_value=MagicMock( + __enter__=lambda s: MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: []))), + __exit__=lambda *a, **k: False, + ), + ), + ), + ), select=MagicMock(), db=MagicMock(engine=MagicMock()), RedisChannel=MagicMock(), diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index dabd2594b4..d91bb85aee 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -2,6 +2,7 @@ from __future__ import annotations from contextlib import contextmanager from types import SimpleNamespace +from unittest.mock import MagicMock import pytest from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus @@ -610,33 +611,33 @@ class TestWorkflowGenerateTaskPipeline: def test_database_session_rolls_back_on_error(self, monkeypatch): pipeline = _make_pipeline() - calls = {"commit": 0, "rollback": 0} - - class _Session: - def __init__(self, *args, **kwargs): - _ = args, kwargs + calls = {"enter": 0, "exit_exc": None} + class _BeginContext: def __enter__(self): - return self + calls["enter"] += 1 + return MagicMock() def __exit__(self, exc_type, exc, tb): + calls["exit_exc"] = exc_type return False - def commit(self): - calls["commit"] += 1 + class _Sessionmaker: + def __init__(self, *args, **kwargs): + pass - def rollback(self): - calls["rollback"] += 1 + def begin(self): + return _BeginContext() - monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.Session", _Session) + monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker) monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object())) with pytest.raises(RuntimeError, match="db error"): with pipeline._database_session(): raise RuntimeError("db error") - assert calls["commit"] == 0 - assert calls["rollback"] == 1 + assert calls["enter"] == 1 + assert calls["exit_exc"] is RuntimeError def test_node_retry_and_started_handlers_cover_none_and_value(self): pipeline = _make_pipeline() diff --git a/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py b/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py index fe533e62af..1f5fdd2657 100644 --- a/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py +++ b/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py @@ -862,6 +862,15 @@ class TestAuthOrchestration: result = discover_protected_resource_metadata(None, "https://api.example.com") assert result is None + # JSONDecodeError (non-JSON 200 response) + mock_get.side_effect = None + bad_json_response = Mock() + bad_json_response.status_code = 200 + bad_json_response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) + mock_get.return_value = bad_json_response + result = discover_protected_resource_metadata(None, "https://api.example.com") + assert result is None + @patch("core.helper.ssrf_proxy.get") def test_discover_oauth_authorization_server_metadata(self, mock_get): # Success @@ -892,6 +901,14 @@ class TestAuthOrchestration: result = discover_oauth_authorization_server_metadata(None, "https://api.example.com") assert result is None + # JSONDecodeError (non-JSON 200 response) + bad_json_response = Mock() + bad_json_response.status_code = 200 + bad_json_response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) + mock_get.return_value = bad_json_response + result = discover_oauth_authorization_server_metadata(None, "https://api.example.com") + assert result is None + def test_get_effective_scope(self): prm = ProtectedResourceMetadata( resource="https://api.example.com", @@ -997,6 +1014,24 @@ class TestAuthOrchestration: supported, url = check_support_resource_discovery("https://api") assert supported is False + # Case 6: JSONDecodeError (non-JSON 200 response) + mock_get.side_effect = None + bad_json_res = Mock() + bad_json_res.status_code = 200 + bad_json_res.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) + mock_get.return_value = bad_json_res + supported, url = check_support_resource_discovery("https://api") + assert supported is False + assert url == "" + + # Case 7: Empty authorization_servers array (IndexError) + empty_res = Mock() + empty_res.status_code = 200 + empty_res.json.return_value = {"authorization_servers": []} + mock_get.return_value = empty_res + supported, url = check_support_resource_discovery("https://api") + assert supported is False + def test_discover_oauth_metadata(self): with patch("core.mcp.auth.auth_flow.discover_protected_resource_metadata") as mock_prm: with patch("core.mcp.auth.auth_flow.discover_oauth_authorization_server_metadata") as mock_asm: diff --git a/api/tests/unit_tests/core/mcp/test_entities.py b/api/tests/unit_tests/core/mcp/test_entities.py index 3fede55916..e99c38285c 100644 --- a/api/tests/unit_tests/core/mcp/test_entities.py +++ b/api/tests/unit_tests/core/mcp/test_entities.py @@ -4,9 +4,7 @@ from unittest.mock import Mock from core.mcp.entities import ( SUPPORTED_PROTOCOL_VERSIONS, - LifespanContextT, RequestContext, - SessionT, ) from core.mcp.session.base_session import BaseSession from core.mcp.types import LATEST_PROTOCOL_VERSION, RequestParams @@ -198,42 +196,3 @@ class TestRequestContext: assert "RequestContext" in repr_str assert "test-123" in repr_str assert "MockSession" in repr_str - - -class TestTypeVariables: - """Test type variables defined in the module.""" - - def test_session_type_var(self): - """Test SessionT type variable.""" - - # Create a custom session class - class CustomSession(BaseSession): - pass - - # Use in generic context - def process_session(session: SessionT) -> SessionT: - return session - - mock_session = Mock(spec=CustomSession) - result = process_session(mock_session) - assert result == mock_session - - def test_lifespan_context_type_var(self): - """Test LifespanContextT type variable.""" - - # Use in generic context - def process_lifespan(context: LifespanContextT) -> LifespanContextT: - return context - - # Test with different types - str_context = "string-context" - assert process_lifespan(str_context) == str_context - - dict_context = {"key": "value"} - assert process_lifespan(dict_context) == dict_context - - class CustomContext: - pass - - custom_context = CustomContext() - assert process_lifespan(custom_context) == custom_context diff --git a/api/tests/unit_tests/core/ops/test_langfuse_trace.py b/api/tests/unit_tests/core/ops/test_langfuse_trace.py new file mode 100644 index 0000000000..f8951d2b4a --- /dev/null +++ b/api/tests/unit_tests/core/ops/test_langfuse_trace.py @@ -0,0 +1,137 @@ +"""Tests for Langfuse TTFT reporting support.""" + +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from graphon.enums import BuiltinNodeTypes + +from core.ops.entities.config_entity import LangfuseConfig +from core.ops.entities.trace_entity import MessageTraceInfo, WorkflowTraceInfo +from core.ops.langfuse_trace.langfuse_trace import LangFuseDataTrace + + +def _create_trace_instance() -> LangFuseDataTrace: + with patch("core.ops.langfuse_trace.langfuse_trace.Langfuse", autospec=True): + return LangFuseDataTrace( + LangfuseConfig( + public_key="public-key", + secret_key="secret-key", + host="https://cloud.langfuse.com", + ) + ) + + +class TestLangFuseDataTraceCompletionStartTime: + def test_message_trace_reports_completion_start_time(self): + trace = _create_trace_instance() + start_time = datetime(2026, 3, 11, 13, 0, 0) + trace_info = MessageTraceInfo( + trace_id="trace-123", + message_id="message-123", + message_data=SimpleNamespace( + id="message-123", + from_account_id="account-1", + from_end_user_id=None, + conversation_id="conversation-1", + model_id="gpt-4o-mini", + answer="hi there", + status="normal", + error="", + total_price=0.12, + provider_response_latency=3.5, + ), + conversation_model="chat", + message_tokens=10, + answer_tokens=20, + total_tokens=30, + error="", + inputs="hello", + outputs="hi there", + file_list=[], + start_time=start_time, + end_time=start_time + timedelta(seconds=3.5), + metadata={}, + message_file_data=None, + conversation_mode="chat", + gen_ai_server_time_to_first_token=1.2, + llm_streaming_time_to_generate=2.3, + is_streaming_request=True, + ) + + with patch.object(trace, "add_trace"), patch.object(trace, "add_generation") as add_generation: + trace.message_trace(trace_info) + + generation = add_generation.call_args.args[0] + assert generation.completion_start_time == start_time + timedelta(seconds=1.2) + + def test_workflow_trace_reports_completion_start_time_from_llm_usage(self): + trace = _create_trace_instance() + start_time = datetime(2026, 3, 11, 13, 0, 0) + node_execution = SimpleNamespace( + id="node-exec-1", + title="Chat LLM", + node_type=BuiltinNodeTypes.LLM, + status="succeeded", + process_data={ + "model_mode": "chat", + "model_name": "gpt-4o-mini", + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "time_to_first_token": 1.2, + }, + }, + inputs={"question": "hello"}, + outputs={"text": "hi there"}, + created_at=start_time, + elapsed_time=3.5, + metadata={}, + ) + trace_info = WorkflowTraceInfo( + trace_id="trace-123", + workflow_data={}, + conversation_id=None, + workflow_app_log_id=None, + workflow_id="workflow-1", + tenant_id="tenant-1", + workflow_run_id="workflow-run-1", + workflow_run_elapsed_time=3.5, + workflow_run_status="succeeded", + workflow_run_inputs={"question": "hello"}, + workflow_run_outputs={"answer": "hi there"}, + workflow_run_version="1", + error="", + total_tokens=30, + file_list=[], + query="hello", + metadata={"app_id": "app-1", "user_id": "user-1"}, + start_time=start_time, + end_time=start_time + timedelta(seconds=3.5), + ) + repository = MagicMock() + repository.get_by_workflow_execution.return_value = [node_execution] + + with ( + patch.object(trace, "add_trace"), + patch.object(trace, "add_span"), + patch.object(trace, "add_generation") as add_generation, + patch.object(trace, "get_service_account_with_tenant", return_value=MagicMock()), + patch("core.ops.langfuse_trace.langfuse_trace.db", MagicMock()), + patch( + "core.ops.langfuse_trace.langfuse_trace.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + return_value=repository, + ), + ): + trace.workflow_trace(trace_info) + + generation = add_generation.call_args.kwargs["langfuse_generation_data"] + assert generation.completion_start_time == start_time + timedelta(seconds=1.2) + + def test_ignores_invalid_ttft_values(self): + trace = _create_trace_instance() + start_time = datetime(2026, 3, 11, 13, 0, 0) + + assert trace._get_completion_start_time(start_time, None) is None + assert trace._get_completion_start_time(start_time, -1) is None + assert trace._get_completion_start_time(start_time, "invalid") is None diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/pgvector/__init__.py b/api/tests/unit_tests/core/rag/datasource/vdb/pgvector/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py index 4e9ceddda9..dc21d378a2 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py @@ -21,6 +21,9 @@ def _register_fake_factory_module(monkeypatch, module_path: str, class_name: str def vector_factory_module(): import importlib + from core.rag.datasource.vdb import vector_backend_registry as reg + + reg.clear_vector_factory_cache() import core.rag.datasource.vdb.vector_factory as module return importlib.reload(module) @@ -41,61 +44,62 @@ def test_gen_index_struct_dict(vector_factory_module): @pytest.mark.parametrize( ("vector_type", "module_path", "class_name"), [ - ("CHROMA", "core.rag.datasource.vdb.chroma.chroma_vector", "ChromaVectorFactory"), - ("MILVUS", "core.rag.datasource.vdb.milvus.milvus_vector", "MilvusVectorFactory"), + ("CHROMA", "dify_vdb_chroma.chroma_vector", "ChromaVectorFactory"), + ("MILVUS", "dify_vdb_milvus.milvus_vector", "MilvusVectorFactory"), ( "ALIBABACLOUD_MYSQL", - "core.rag.datasource.vdb.alibabacloud_mysql.alibabacloud_mysql_vector", + "dify_vdb_alibabacloud_mysql.alibabacloud_mysql_vector", "AlibabaCloudMySQLVectorFactory", ), - ("MYSCALE", "core.rag.datasource.vdb.myscale.myscale_vector", "MyScaleVectorFactory"), - ("PGVECTOR", "core.rag.datasource.vdb.pgvector.pgvector", "PGVectorFactory"), - ("VASTBASE", "core.rag.datasource.vdb.pyvastbase.vastbase_vector", "VastbaseVectorFactory"), - ("PGVECTO_RS", "core.rag.datasource.vdb.pgvecto_rs.pgvecto_rs", "PGVectoRSFactory"), - ("QDRANT", "core.rag.datasource.vdb.qdrant.qdrant_vector", "QdrantVectorFactory"), - ("RELYT", "core.rag.datasource.vdb.relyt.relyt_vector", "RelytVectorFactory"), + ("MYSCALE", "dify_vdb_myscale.myscale_vector", "MyScaleVectorFactory"), + ("PGVECTOR", "dify_vdb_pgvector.pgvector", "PGVectorFactory"), + ("VASTBASE", "dify_vdb_vastbase.vastbase_vector", "VastbaseVectorFactory"), + ("PGVECTO_RS", "dify_vdb_pgvecto_rs.pgvecto_rs", "PGVectoRSFactory"), + ("QDRANT", "dify_vdb_qdrant.qdrant_vector", "QdrantVectorFactory"), + ("RELYT", "dify_vdb_relyt.relyt_vector", "RelytVectorFactory"), ( "ELASTICSEARCH", - "core.rag.datasource.vdb.elasticsearch.elasticsearch_vector", + "dify_vdb_elasticsearch.elasticsearch_vector", "ElasticSearchVectorFactory", ), ( "ELASTICSEARCH_JA", - "core.rag.datasource.vdb.elasticsearch.elasticsearch_ja_vector", + "dify_vdb_elasticsearch.elasticsearch_ja_vector", "ElasticSearchJaVectorFactory", ), - ("TIDB_VECTOR", "core.rag.datasource.vdb.tidb_vector.tidb_vector", "TiDBVectorFactory"), - ("WEAVIATE", "core.rag.datasource.vdb.weaviate.weaviate_vector", "WeaviateVectorFactory"), - ("TENCENT", "core.rag.datasource.vdb.tencent.tencent_vector", "TencentVectorFactory"), - ("ORACLE", "core.rag.datasource.vdb.oracle.oraclevector", "OracleVectorFactory"), + ("TIDB_VECTOR", "dify_vdb_tidb_vector.tidb_vector", "TiDBVectorFactory"), + ("WEAVIATE", "dify_vdb_weaviate.weaviate_vector", "WeaviateVectorFactory"), + ("TENCENT", "dify_vdb_tencent.tencent_vector", "TencentVectorFactory"), + ("ORACLE", "dify_vdb_oracle.oraclevector", "OracleVectorFactory"), ( "OPENSEARCH", - "core.rag.datasource.vdb.opensearch.opensearch_vector", + "dify_vdb_opensearch.opensearch_vector", "OpenSearchVectorFactory", ), - ("ANALYTICDB", "core.rag.datasource.vdb.analyticdb.analyticdb_vector", "AnalyticdbVectorFactory"), - ("COUCHBASE", "core.rag.datasource.vdb.couchbase.couchbase_vector", "CouchbaseVectorFactory"), - ("BAIDU", "core.rag.datasource.vdb.baidu.baidu_vector", "BaiduVectorFactory"), - ("VIKINGDB", "core.rag.datasource.vdb.vikingdb.vikingdb_vector", "VikingDBVectorFactory"), - ("UPSTASH", "core.rag.datasource.vdb.upstash.upstash_vector", "UpstashVectorFactory"), + ("ANALYTICDB", "dify_vdb_analyticdb.analyticdb_vector", "AnalyticdbVectorFactory"), + ("COUCHBASE", "dify_vdb_couchbase.couchbase_vector", "CouchbaseVectorFactory"), + ("BAIDU", "dify_vdb_baidu.baidu_vector", "BaiduVectorFactory"), + ("VIKINGDB", "dify_vdb_vikingdb.vikingdb_vector", "VikingDBVectorFactory"), + ("UPSTASH", "dify_vdb_upstash.upstash_vector", "UpstashVectorFactory"), ( "TIDB_ON_QDRANT", - "core.rag.datasource.vdb.tidb_on_qdrant.tidb_on_qdrant_vector", + "dify_vdb_tidb_on_qdrant.tidb_on_qdrant_vector", "TidbOnQdrantVectorFactory", ), - ("LINDORM", "core.rag.datasource.vdb.lindorm.lindorm_vector", "LindormVectorStoreFactory"), - ("OCEANBASE", "core.rag.datasource.vdb.oceanbase.oceanbase_vector", "OceanBaseVectorFactory"), - ("SEEKDB", "core.rag.datasource.vdb.oceanbase.oceanbase_vector", "OceanBaseVectorFactory"), - ("OPENGAUSS", "core.rag.datasource.vdb.opengauss.opengauss", "OpenGaussFactory"), - ("TABLESTORE", "core.rag.datasource.vdb.tablestore.tablestore_vector", "TableStoreVectorFactory"), + ("LINDORM", "dify_vdb_lindorm.lindorm_vector", "LindormVectorStoreFactory"), + ("OCEANBASE", "dify_vdb_oceanbase.oceanbase_vector", "OceanBaseVectorFactory"), + ("SEEKDB", "dify_vdb_oceanbase.oceanbase_vector", "OceanBaseVectorFactory"), + ("OPENGAUSS", "dify_vdb_opengauss.opengauss", "OpenGaussFactory"), + ("TABLESTORE", "dify_vdb_tablestore.tablestore_vector", "TableStoreVectorFactory"), ( "HUAWEI_CLOUD", - "core.rag.datasource.vdb.huawei.huawei_cloud_vector", + "dify_vdb_huawei_cloud.huawei_cloud_vector", "HuaweiCloudVectorFactory", ), - ("MATRIXONE", "core.rag.datasource.vdb.matrixone.matrixone_vector", "MatrixoneVectorFactory"), - ("CLICKZETTA", "core.rag.datasource.vdb.clickzetta.clickzetta_vector", "ClickzettaVectorFactory"), - ("IRIS", "core.rag.datasource.vdb.iris.iris_vector", "IrisVectorFactory"), + ("MATRIXONE", "dify_vdb_matrixone.matrixone_vector", "MatrixoneVectorFactory"), + ("CLICKZETTA", "dify_vdb_clickzetta.clickzetta_vector", "ClickzettaVectorFactory"), + ("IRIS", "dify_vdb_iris.iris_vector", "IrisVectorFactory"), + ("HOLOGRES", "dify_vdb_hologres.hologres_vector", "HologresVectorFactory"), ], ) def test_get_vector_factory_supported(vector_factory_module, monkeypatch, vector_type, module_path, class_name): @@ -111,6 +115,34 @@ def test_get_vector_factory_unsupported(vector_factory_module): vector_factory_module.Vector.get_vector_factory("unknown") +class _PluginChromaFactory: + """Stub used only for entry-point override test.""" + + +def test_get_vector_factory_entry_point_overrides_builtin(vector_factory_module, monkeypatch): + from importlib.metadata import EntryPoint + + from core.rag.datasource.vdb import vector_backend_registry as reg + + reg.clear_vector_factory_cache() + ep = EntryPoint( + name="chroma", + value=f"{__name__}:_PluginChromaFactory", + group="dify.vector_backends", + ) + + class _FakeGroups: + def select(self, *, group: str): + if group == "dify.vector_backends": + return (ep,) + return () + + monkeypatch.setattr(reg, "entry_points", lambda: _FakeGroups()) + + result_cls = vector_factory_module.Vector.get_vector_factory(vector_factory_module.VectorType.CHROMA) + assert result_cls is _PluginChromaFactory + + def test_vector_init_uses_default_and_custom_attributes(vector_factory_module): dataset = SimpleNamespace(id="dataset-1") @@ -121,7 +153,18 @@ def test_vector_init_uses_default_and_custom_attributes(vector_factory_module): default_vector = vector_factory_module.Vector(dataset) custom_vector = vector_factory_module.Vector(dataset, attributes=["doc_id"]) - assert default_vector._attributes == ["doc_id", "dataset_id", "document_id", "doc_hash", "doc_type"] + # `is_summary` and `original_chunk_id` must be in the default return-properties + # projection so summary index retrieval works on backends that honor the list + # as an explicit projection (e.g. Weaviate). See #34884. + assert default_vector._attributes == [ + "doc_id", + "dataset_id", + "document_id", + "doc_hash", + "doc_type", + "is_summary", + "original_chunk_id", + ] assert custom_vector._attributes == ["doc_id"] assert default_vector._embeddings == "embeddings" assert default_vector._vector_processor == "processor" diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/tidb_on_qdrant/__init__.py b/api/tests/unit_tests/core/rag/datasource/vdb/tidb_on_qdrant/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/weaviate/__init__.py b/api/tests/unit_tests/core/rag/datasource/vdb/weaviate/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py b/api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py index a7b7c1595b..007a76aa66 100644 --- a/api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py +++ b/api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py @@ -721,6 +721,30 @@ class TestDatasetDocumentStoreMultimodelBinding: mock_db.session.add.assert_not_called() + def test_add_multimodel_documents_binding_with_none_document_id(self): + """Test that no bindings are added when document_id is None.""" + + mock_dataset = MagicMock(spec=Dataset) + mock_dataset.id = "test-dataset-id" + mock_dataset.tenant_id = "tenant-1" + + mock_attachment = MagicMock(spec=AttachmentDocument) + mock_attachment.metadata = {"doc_id": "attachment-1"} + + with patch("core.rag.docstore.dataset_docstore.db") as mock_db: + mock_session = MagicMock() + mock_db.session = mock_session + + store = DatasetDocumentStore( + dataset=mock_dataset, + user_id="test-user-id", + document_id=None, + ) + + store.add_multimodel_documents_binding("seg-1", [mock_attachment]) + + mock_db.session.add.assert_not_called() + class TestDatasetDocumentStoreAddDocumentsUpdateChild: """Tests for add_documents when updating existing documents with children.""" diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py index c241b44d52..8ef0e046ef 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py @@ -258,10 +258,10 @@ class TestParentChildIndexProcessor: session.commit.assert_called_once() def test_clean_deletes_summaries_when_requested(self, processor: ParentChildIndexProcessor, dataset: Mock) -> None: - segment_query = Mock() - segment_query.filter.return_value.all.return_value = [SimpleNamespace(id="seg-1")] + scalars_result = Mock() + scalars_result.all.return_value = [SimpleNamespace(id="seg-1")] session = Mock() - session.query.return_value = segment_query + session.scalars.return_value = scalars_result session_ctx = MagicMock() session_ctx.__enter__.return_value = session session_ctx.__exit__.return_value = False diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py index 98c47bec8f..b1b1835a52 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py @@ -220,10 +220,10 @@ class TestQAIndexProcessor: self, processor: QAIndexProcessor, dataset: Mock ) -> None: mock_segment = SimpleNamespace(id="seg-1") - mock_query = Mock() - mock_query.filter.return_value.all.return_value = [mock_segment] + scalars_result = Mock() + scalars_result.all.return_value = [mock_segment] mock_session = Mock() - mock_session.query.return_value = mock_query + mock_session.scalars.return_value = scalars_result session_context = MagicMock() session_context.__enter__.return_value = mock_session session_context.__exit__.return_value = False diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index 40d138df90..1b17cbc368 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -8,7 +8,6 @@ import pytest from flask import Flask, current_app from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.model_runtime.entities.model_entities import ModelFeature -from sqlalchemy import column from core.app.app_config.entities import ( DatasetEntity, @@ -4039,21 +4038,9 @@ class TestDatasetRetrievalAdditionalHelpers: def test_get_available_datasets(self, retrieval: DatasetRetrieval) -> None: session = Mock() - subquery_query = Mock() - subquery_query.where.return_value = subquery_query - subquery_query.group_by.return_value = subquery_query - subquery_query.having.return_value = subquery_query - subquery_query.subquery.return_value = SimpleNamespace( - c=SimpleNamespace( - dataset_id=column("dataset_id"), available_document_count=column("available_document_count") - ) - ) - - dataset_query = Mock() - dataset_query.outerjoin.return_value = dataset_query - dataset_query.where.return_value = dataset_query - dataset_query.all.return_value = [SimpleNamespace(id="d1"), None, SimpleNamespace(id="d2")] - session.query.side_effect = [subquery_query, dataset_query] + scalars_result = Mock() + scalars_result.all.return_value = [SimpleNamespace(id="d1"), None, SimpleNamespace(id="d2")] + session.scalars.return_value = scalars_result session_ctx = MagicMock() session_ctx.__enter__.return_value = session @@ -4902,22 +4889,21 @@ class TestInternalHooksCoverage: _scalars(segments), _scalars(bindings), ] - query = Mock() - query.where.return_value = query - session.query.return_value = query session_ctx = MagicMock() session_ctx.__enter__.return_value = session session_ctx.__exit__.return_value = False + sessionmaker_ctx = MagicMock() + sessionmaker_ctx.begin.return_value = session_ctx + with ( patch("core.rag.retrieval.dataset_retrieval.db", SimpleNamespace(engine=Mock())), - patch("core.rag.retrieval.dataset_retrieval.Session", return_value=session_ctx), + patch("core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=sessionmaker_ctx), patch.object(retrieval, "_send_trace_task") as mock_trace, ): retrieval._on_retrieval_end(flask_app=app, documents=docs, message_id="m1", timer={"cost": 1}) - query.update.assert_called_once() - session.commit.assert_called_once() + session.execute.assert_called_once() mock_trace.assert_called_once() def test_retriever_variants(self, retrieval: DatasetRetrieval) -> None: diff --git a/api/tests/unit_tests/core/tools/test_tool_engine.py b/api/tests/unit_tests/core/tools/test_tool_engine.py index 40c107667c..cd16557ef6 100644 --- a/api/tests/unit_tests/core/tools/test_tool_engine.py +++ b/api/tests/unit_tests/core/tools/test_tool_engine.py @@ -260,6 +260,28 @@ def test_agent_invoke_engine_meta_error(): assert error_meta.error == "meta failure" +def test_convert_tool_response_excludes_variable_messages(): + """Regression test for issue #34723. + + WorkflowTool._invoke yields VARIABLE, TEXT, and suppressed-JSON messages. + _convert_tool_response_to_str must skip VARIABLE messages so that the + returned string contains only the TEXT representation and not a + duplicated, garbled Pydantic repr of the same data. + """ + tool = _build_tool() + outputs = {"reports": "hello"} + messages = [ + tool.create_variable_message(variable_name="reports", variable_value="hello"), + tool.create_text_message('{"reports": "hello"}'), + tool.create_json_message(outputs, suppress_output=True), + ] + + result = ToolEngine._convert_tool_response_to_str(messages) + + assert result == '{"reports": "hello"}' + assert "variable_name" not in result + + def test_agent_invoke_tool_invoke_error(): tool = _build_tool(with_llm_parameter=True) callback = Mock() diff --git a/api/tests/unit_tests/core/tools/test_tool_file_manager.py b/api/tests/unit_tests/core/tools/test_tool_file_manager.py index 7fcebde3c5..2889cb9db1 100644 --- a/api/tests/unit_tests/core/tools/test_tool_file_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_file_manager.py @@ -129,7 +129,7 @@ def test_get_file_binary_returns_none_when_not_found() -> None: # Arrange manager = ToolFileManager() session = Mock() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None # Act with _patch_session_factory(session): @@ -144,7 +144,7 @@ def test_get_file_binary_returns_bytes_when_found() -> None: manager = ToolFileManager() tool_file = SimpleNamespace(file_key="k1", mimetype="text/plain") session = Mock() - session.query.return_value.where.return_value.first.return_value = tool_file + session.scalar.return_value = tool_file # Act with patch("core.tools.tool_file_manager.storage") as storage: @@ -160,11 +160,7 @@ def test_get_file_binary_by_message_file_id_when_messagefile_missing() -> None: # Arrange manager = ToolFileManager() session = Mock() - first_query = Mock() - second_query = Mock() - first_query.where.return_value.first.return_value = None - second_query.where.return_value.first.return_value = None - session.query.side_effect = [first_query, second_query] + session.scalar.side_effect = [None, None] # Act with _patch_session_factory(session): @@ -179,11 +175,7 @@ def test_get_file_binary_by_message_file_id_when_url_is_none() -> None: manager = ToolFileManager() message_file = SimpleNamespace(url=None) session = Mock() - first_query = Mock() - second_query = Mock() - first_query.where.return_value.first.return_value = message_file - second_query.where.return_value.first.return_value = None - session.query.side_effect = [first_query, second_query] + session.scalar.side_effect = [message_file, None] # Act with _patch_session_factory(session): @@ -199,11 +191,7 @@ def test_get_file_binary_by_message_file_id_returns_bytes_when_found() -> None: message_file = SimpleNamespace(url="https://x/files/tools/tool123.png") tool_file = SimpleNamespace(file_key="k2", mimetype="image/png") session = Mock() - first_query = Mock() - second_query = Mock() - first_query.where.return_value.first.return_value = message_file - second_query.where.return_value.first.return_value = tool_file - session.query.side_effect = [first_query, second_query] + session.scalar.side_effect = [message_file, tool_file] # Act with patch("core.tools.tool_file_manager.storage") as storage: @@ -219,7 +207,7 @@ def test_get_file_generator_returns_none_when_toolfile_missing() -> None: # Arrange manager = ToolFileManager() session = Mock() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None # Act with _patch_session_factory(session): @@ -242,7 +230,7 @@ def test_get_file_generator_returns_stream_when_found() -> None: size=12, ) session = Mock() - session.query.return_value.where.return_value.first.return_value = tool_file + session.scalar.return_value = tool_file # Act with patch("core.tools.tool_file_manager.storage") as storage: diff --git a/api/tests/unit_tests/core/tools/test_tool_label_manager.py b/api/tests/unit_tests/core/tools/test_tool_label_manager.py index 8c0e7e9419..e13f430f9b 100644 --- a/api/tests/unit_tests/core/tools/test_tool_label_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_label_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import SimpleNamespace from typing import Any -from unittest.mock import PropertyMock, patch +from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -12,11 +12,13 @@ from core.tools.tool_label_manager import ToolLabelManager from core.tools.workflow_as_tool.provider import WorkflowToolProviderController +# Create a mock class for testing abstract/base classes class _ConcreteBuiltinToolProviderController(BuiltinToolProviderController): def _validate_credentials(self, user_id: str, credentials: dict[str, Any]): return None +# Factory function to create a "lightweight" controller for testing def _api_controller(provider_id: str = "api-1") -> ApiToolProviderController: controller = object.__new__(ApiToolProviderController) controller.provider_id = provider_id @@ -29,6 +31,7 @@ def _workflow_controller(provider_id: str = "wf-1") -> WorkflowToolProviderContr return controller +# Test pure logic: filtering and deduplication def test_tool_label_manager_filter_tool_labels(): filtered = ToolLabelManager.filter_tool_labels(["search", "search", "invalid", "news"]) assert set(filtered) == {"search", "news"} @@ -36,22 +39,68 @@ def test_tool_label_manager_filter_tool_labels(): def test_tool_label_manager_update_tool_labels_db(): + """ + Test the database update logic for tool labels. + Focus: Verify that labels are filtered, de-duplicated, and safely handled within a database session. + """ + # 1. Setup expected data from the controller controller = _api_controller("api-1") - with patch("core.tools.tool_label_manager.db") as mock_db: + expected_id = controller.provider_id + expected_type = controller.provider_type + + # 2. Patching External Dependencies + # - We patch 'db' to prevent Flask from trying to access a real database. + # - We patch 'sessionmaker' to intercept and control the creation of SQLAlchemy sessions. + with ( + patch("core.tools.tool_label_manager.db"), + patch("core.tools.tool_label_manager.sessionmaker") as mock_sessionmaker, + ): + # 3. Constructing the "Mocking Chain" + # In the business logic, we use: with sessionmaker(db.engine).begin() as _session: + # We need to link our 'mock_session' to the end of this complex context manager chain: + # Step A: sessionmaker(db.engine) -> returns an object (mock_sessionmaker.return_value) + # Step B: .begin() -> returns a context manager (begin.return_value) + # Step C: with ... as _session: -> calls __enter__(), and _session gets the __enter__.return_value + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + + # 4. Trigger the logic under test + # Input: ["search", "search", "invalid"] + # Logic: + # - "invalid" should be filtered out (not in default_tool_label_name_list). + # - The duplicate "search" should be merged (unique labels). ToolLabelManager.update_tool_labels(controller, ["search", "search", "invalid"]) - mock_db.session.execute.assert_called_once() - # only one valid unique label should be inserted. - assert mock_db.session.add.call_count == 1 - mock_db.session.commit.assert_called_once() + # 5. Behavior Assertion: DELETE operation + # Verify that the manager first attempts to clear existing labels for this specific tool. + # This ensures the update is idempotent. + mock_session.execute.assert_called_once() + + # 6. Behavior Assertion: INSERT operation + # Verify that only ONE valid label ("search") was added after filtering and deduplication. + # If call_count == 1, it proves filter_tool_labels() worked as expected. + assert mock_session.add.call_count == 1 + + # 7. State Assertion: Data Integrity & Isolation + # Inspect the actual object passed to session.add() to ensure it has correct properties. + # This confirms that the data isolation (tool_id + tool_type) we refactored is active. + call_args = mock_session.add.call_args + added_label = call_args[0][0] # Retrieve the ToolLabelBinding instance + + assert added_label.label_name == "search", "The label name should be 'search' after filtering." + assert added_label.tool_id == expected_id, "The tool_id must match the provider_id for correct binding." + assert added_label.tool_type == expected_type, "Isolation failed: tool_type must be verified during update." +# Test error handling def test_tool_label_manager_update_tool_labels_unsupported(): with pytest.raises(ValueError, match="Unsupported tool type"): ToolLabelManager.update_tool_labels(object(), ["search"]) # type: ignore[arg-type] +# Test retrieval logic def test_tool_label_manager_get_tool_labels_for_builtin_and_db(): + # Mocking a property (@property) using PropertyMock with patch.object( _ConcreteBuiltinToolProviderController, "tool_labels", @@ -62,29 +111,67 @@ def test_tool_label_manager_get_tool_labels_for_builtin_and_db(): assert ToolLabelManager.get_tool_labels(builtin) == ["search", "news"] api = _api_controller("api-1") - with patch("core.tools.tool_label_manager.db") as mock_db: - mock_db.session.scalars.return_value.all.return_value = ["search", "news"] - labels = ToolLabelManager.get_tool_labels(api) - assert labels == ["search", "news"] + with ( + patch("core.tools.tool_label_manager.db"), + patch("core.tools.tool_label_manager.sessionmaker") as mock_sessionmaker, + ): + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + # Inject mock data into the query result: session.scalars(stmt).all() + mock_session.scalars.return_value.all.return_value = ["search", "news"] + + labels = ToolLabelManager.get_tool_labels(api) + assert labels == ["search", "news"] + + +def test_tool_label_manager_get_tool_labels_unsupported(): + """ + Negative Test: Ensure get_tool_labels raises ValueError for unsupported controller types. + This protects the internal API contract against accidental regressions during refactoring. + """ + # Passing a generic object() which doesn't match Api, Workflow, or Builtin controllers. with pytest.raises(ValueError, match="Unsupported tool type"): ToolLabelManager.get_tool_labels(object()) # type: ignore[arg-type] +# Test batch processing and mapping def test_tool_label_manager_get_tools_labels_batch(): assert ToolLabelManager.get_tools_labels([]) == {} api = _api_controller("api-1") wf = _workflow_controller("wf-1") + + # SimpleNamespace is a quick way to simulate SQLAlchemy row objects records = [ SimpleNamespace(tool_id="api-1", label_name="search"), SimpleNamespace(tool_id="api-1", label_name="news"), SimpleNamespace(tool_id="wf-1", label_name="utilities"), ] - with patch("core.tools.tool_label_manager.db") as mock_db: - mock_db.session.scalars.return_value.all.return_value = records + + with ( + patch("core.tools.tool_label_manager.db"), + patch("core.tools.tool_label_manager.sessionmaker") as mock_sessionmaker, + ): + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + + # Simulating the batch query result + mock_session.scalars.return_value.all.return_value = records + labels = ToolLabelManager.get_tools_labels([api, wf]) + + # Verify the final dictionary mapping assert labels == {"api-1": ["search", "news"], "wf-1": ["utilities"]} + +def test_tool_label_manager_get_tools_labels_unsupported(): + """ + Negative Test: Ensure get_tools_labels raises ValueError if the list contains + unsupported controller types, even alongside valid ones. + """ + api = _api_controller("api-1") + + # Passing a list with one valid controller and one invalid object() with pytest.raises(ValueError, match="Unsupported tool type"): ToolLabelManager.get_tools_labels([api, object()]) # type: ignore[list-item] diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index 31b68f0b3f..9ebaa0417b 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -637,7 +637,7 @@ def test_list_default_builtin_providers_for_postgres_and_mysql(): for scheme in ("postgresql", "mysql"): session = Mock() session.execute.return_value.all.return_value = [SimpleNamespace(id="id-1"), SimpleNamespace(id="id-2")] - session.query.return_value.where.return_value.all.return_value = provider_records + session.scalars.return_value = iter(provider_records) with patch("core.tools.tool_manager.dify_config", SimpleNamespace(SQLALCHEMY_DATABASE_URI_SCHEME=scheme)): with patch("core.tools.tool_manager.db") as mock_db: diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py index 2607861b59..4767480a5a 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py @@ -43,7 +43,7 @@ def test_get_db_provider_tool_builds_entity(): controller = _controller() session = Mock() workflow = SimpleNamespace(graph_dict={"nodes": []}, features_dict={}) - session.query.return_value.where.return_value.first.return_value = workflow + session.scalar.return_value = workflow app = SimpleNamespace(id="app-1") db_provider = SimpleNamespace( id="provider-1", @@ -136,7 +136,7 @@ def test_from_db_builds_controller(): parameter_configurations=[], ) session = _mock_session_with_begin() - session.query.return_value.where.return_value.first.return_value = db_provider + session.scalar.return_value = db_provider session.get.side_effect = [app, user] fake_cm = MagicMock() fake_cm.__enter__.return_value = session @@ -163,7 +163,7 @@ def test_get_tools_returns_empty_when_provider_missing(): mock_db.engine = object() with patch("core.tools.workflow_as_tool.provider.Session") as session_cls: session = _mock_session_with_begin() - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None session_cls.return_value.__enter__.return_value = session assert controller.get_tools("tenant-1") == [] @@ -189,7 +189,7 @@ def test_get_tools_raises_when_app_missing(): mock_db.engine = object() with patch("core.tools.workflow_as_tool.provider.Session") as session_cls: session = _mock_session_with_begin() - session.query.return_value.where.return_value.first.return_value = db_provider + session.scalar.return_value = db_provider session.get.return_value = None session_cls.return_value.__enter__.return_value = session with pytest.raises(ValueError, match="app not found"): diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index bc0b339fec..dfe1a47e37 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -110,6 +110,34 @@ class TestFetchMemory: ) +class TestDifyGraphInitContext: + def test_to_graph_init_params_preserves_explicit_values(self): + run_context = { + DIFY_RUN_CONTEXT_KEY: DifyRunContext( + tenant_id="tenant-id", + app_id="app-id", + user_id="user-id", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ), + "extra": "value", + } + graph_config = {"nodes": [], "edges": []} + graph_init_context = node_factory.DifyGraphInitContext( + workflow_id="workflow-id", + graph_config=graph_config, + run_context=run_context, + call_depth=2, + ) + + result = graph_init_context.to_graph_init_params() + + assert result.workflow_id == "workflow-id" + assert result.graph_config == graph_config + assert result.run_context == run_context + assert result.call_depth == 2 + + class TestDefaultWorkflowCodeExecutor: def test_execute_delegates_to_code_executor(self, monkeypatch): executor = node_factory.DefaultWorkflowCodeExecutor() @@ -172,6 +200,23 @@ class TestCodeExecutorJinja2TemplateRenderer: class TestDifyNodeFactoryInit: + def test_from_graph_init_context_translates_before_init(self): + graph_init_context = MagicMock() + graph_init_context.to_graph_init_params.return_value = sentinel.graph_init_params + + with patch.object(node_factory.DifyNodeFactory, "__init__", return_value=None) as init: + factory = node_factory.DifyNodeFactory.from_graph_init_context( + graph_init_context=graph_init_context, + graph_runtime_state=sentinel.graph_runtime_state, + ) + + assert isinstance(factory, node_factory.DifyNodeFactory) + graph_init_context.to_graph_init_params.assert_called_once_with() + init.assert_called_once_with( + graph_init_params=sentinel.graph_init_params, + graph_runtime_state=sentinel.graph_runtime_state, + ) + def test_init_builds_default_dependencies(self): graph_init_params = SimpleNamespace(run_context={"context": "value"}) graph_runtime_state = sentinel.graph_runtime_state diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index 879c0bb721..6dcaed1143 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -349,7 +349,7 @@ class TestWorkflowEntrySingleStepRun: ] with ( - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object( workflow_entry, "GraphRuntimeState", @@ -358,7 +358,7 @@ class TestWorkflowEntrySingleStepRun: patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeLLMNode), - patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory, + patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory, patch.object(workflow_entry, "load_into_variable_pool"), patch.object(workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool"), patch.object( @@ -412,12 +412,12 @@ class TestWorkflowEntrySingleStepRun: raise NotImplementedError with ( - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeNode), - patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory, + patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory, patch.object(workflow_entry, "add_node_inputs_to_pool") as add_node_inputs_to_pool, patch.object(workflow_entry, "load_into_variable_pool") as load_into_variable_pool, patch.object( @@ -481,12 +481,12 @@ class TestWorkflowEntrySingleStepRun: return {"question": ["node", "question"]} with ( - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeDatasourceNode), - patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory, + patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory, patch.object(workflow_entry, "add_node_inputs_to_pool") as add_node_inputs_to_pool, patch.object(workflow_entry, "load_into_variable_pool") as load_into_variable_pool, patch.object( @@ -541,12 +541,12 @@ class TestWorkflowEntrySingleStepRun: return "1" with ( - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeNode), - patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory, + patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory, patch.object(workflow_entry, "add_node_inputs_to_pool"), patch.object(workflow_entry, "load_into_variable_pool"), patch.object(workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool"), @@ -651,14 +651,18 @@ class TestWorkflowEntryHelpers: patch.object(workflow_entry, "VariablePool", return_value=sentinel.variable_pool) as variable_pool_cls, patch.object(workflow_entry, "add_variables_to_pool") as add_variables_to_pool, patch.object( - workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params - ) as graph_init_params, + workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context + ) as graph_init_context_cls, patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object( workflow_entry, "build_dify_run_context", return_value={"_dify": "context"} ) as build_dify_run_context, patch.object(workflow_entry.time, "perf_counter", return_value=123.0), - patch.object(workflow_entry, "DifyNodeFactory", return_value=dify_node_factory) as dify_node_factory_cls, + patch.object( + workflow_entry.DifyNodeFactory, + "from_graph_init_context", + return_value=dify_node_factory, + ) as dify_node_factory_cls, patch.object( workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool", @@ -688,7 +692,7 @@ class TestWorkflowEntryHelpers: user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) - graph_init_params.assert_called_once_with( + graph_init_context_cls.assert_called_once_with( workflow_id="", graph_config=workflow_entry.WorkflowEntry._create_single_node_graph( "node-id", {"type": BuiltinNodeTypes.PARAMETER_EXTRACTOR, "title": "Node"} @@ -697,7 +701,7 @@ class TestWorkflowEntryHelpers: call_depth=0, ) dify_node_factory_cls.assert_called_once_with( - graph_init_params=sentinel.graph_init_params, + graph_init_context=sentinel.graph_init_context, graph_runtime_state=sentinel.graph_runtime_state, ) mapping_user_inputs_to_variable_pool.assert_called_once_with( @@ -734,11 +738,15 @@ class TestWorkflowEntryHelpers: patch.object(workflow_entry, "default_system_variables", return_value=sentinel.system_variables), patch.object(workflow_entry, "VariablePool", return_value=sentinel.variable_pool), patch.object(workflow_entry, "add_variables_to_pool"), - patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params), + patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), - patch.object(workflow_entry, "DifyNodeFactory", return_value=dify_node_factory), + patch.object( + workflow_entry.DifyNodeFactory, + "from_graph_init_context", + return_value=dify_node_factory, + ), patch.object( workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool", diff --git a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py index f7475f2239..12e91f190f 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py @@ -39,7 +39,7 @@ class TestAppGenerateHandler: "root_node_id": None, } - arguments = handler._extract_arguments(AppGenerateService.generate, (), kwargs) + arguments = handler._extract_arguments(AppGenerateService.generate, **kwargs) assert arguments is not None, "Failed to extract arguments from AppGenerateService.generate" assert "app_model" in arguments, "Handler uses app_model but parameter is missing" @@ -70,14 +70,11 @@ class TestAppGenerateHandler: handler.wrapper( tracer, dummy_func, - (), - { - "app_model": mock_app_model, - "user": mock_account_user, - "args": {"workflow_id": test_workflow_id}, - "invoke_from": InvokeFrom.DEBUGGER, - "streaming": False, - }, + app_model=mock_app_model, + user=mock_account_user, + args={"workflow_id": test_workflow_id}, + invoke_from=InvokeFrom.DEBUGGER, + streaming=False, ) spans = memory_span_exporter.get_finished_spans() diff --git a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py index 500f80fc3c..842e7f55e2 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py @@ -63,7 +63,7 @@ class TestWorkflowAppRunnerHandler: def runner_run(self): return "result" - handler.wrapper(tracer, runner_run, (mock_workflow_runner,), {}) + handler.wrapper(tracer, runner_run, mock_workflow_runner) spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 diff --git a/api/tests/unit_tests/extensions/otel/decorators/test_handler.py b/api/tests/unit_tests/extensions/otel/decorators/test_handler.py index 44788bab9a..bf861e3ef7 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/test_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/test_handler.py @@ -28,7 +28,7 @@ class TestSpanHandlerExtractArguments: args = (1, 2, 3) kwargs = {} - result = handler._extract_arguments(func, args, kwargs) + result = handler._extract_arguments(func, *args, **kwargs) assert result is not None assert result["a"] == 1 @@ -44,7 +44,7 @@ class TestSpanHandlerExtractArguments: args = () kwargs = {"a": 1, "b": 2, "c": 3} - result = handler._extract_arguments(func, args, kwargs) + result = handler._extract_arguments(func, *args, **kwargs) assert result is not None assert result["a"] == 1 @@ -60,7 +60,7 @@ class TestSpanHandlerExtractArguments: args = (1,) kwargs = {"b": 2, "c": 3} - result = handler._extract_arguments(func, args, kwargs) + result = handler._extract_arguments(func, *args, **kwargs) assert result is not None assert result["a"] == 1 @@ -76,7 +76,7 @@ class TestSpanHandlerExtractArguments: args = (1,) kwargs = {} - result = handler._extract_arguments(func, args, kwargs) + result = handler._extract_arguments(func, *args, **kwargs) assert result is not None assert result["a"] == 1 @@ -94,7 +94,7 @@ class TestSpanHandlerExtractArguments: instance = MyClass() args = (1, 2) kwargs = {} - result = handler._extract_arguments(instance.method, args, kwargs) + result = handler._extract_arguments(instance.method, *args, **kwargs) assert result is not None assert result["a"] == 1 @@ -109,7 +109,7 @@ class TestSpanHandlerExtractArguments: args = (1,) kwargs = {} - result = handler._extract_arguments(func, args, kwargs) + result = handler._extract_arguments(func, *args, **kwargs) assert result is None @@ -122,11 +122,11 @@ class TestSpanHandlerExtractArguments: assert func not in handler._signature_cache - handler._extract_arguments(func, (1, 2), {}) + handler._extract_arguments(func, 1, 2) assert func in handler._signature_cache cached_sig = handler._signature_cache[func] - handler._extract_arguments(func, (3, 4), {}) + handler._extract_arguments(func, 3, 4) assert handler._signature_cache[func] is cached_sig @@ -142,7 +142,7 @@ class TestSpanHandlerWrapper: def test_func(): return "result" - result = handler.wrapper(tracer, test_func, (), {}) + result = handler.wrapper(tracer, test_func) assert result == "result" spans = memory_span_exporter.get_finished_spans() @@ -159,7 +159,7 @@ class TestSpanHandlerWrapper: def test_func(): return "result" - handler.wrapper(tracer, test_func, (), {}) + handler.wrapper(tracer, test_func) spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 @@ -174,7 +174,7 @@ class TestSpanHandlerWrapper: def test_func(): return "result" - handler.wrapper(tracer, test_func, (), {}) + handler.wrapper(tracer, test_func) spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 @@ -190,7 +190,7 @@ class TestSpanHandlerWrapper: raise ValueError("test error") with pytest.raises(ValueError, match="test error"): - handler.wrapper(tracer, test_func, (), {}) + handler.wrapper(tracer, test_func) spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 @@ -208,7 +208,7 @@ class TestSpanHandlerWrapper: raise ValueError("test error") with pytest.raises(ValueError): - handler.wrapper(tracer, test_func, (), {}) + handler.wrapper(tracer, test_func) spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 @@ -225,7 +225,7 @@ class TestSpanHandlerWrapper: raise ValueError("test error") with pytest.raises(ValueError, match="test error"): - handler.wrapper(tracer, test_func, (), {}) + handler.wrapper(tracer, test_func) @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_passes_arguments_correctly(self, tracer_provider_with_memory_exporter, memory_span_exporter): @@ -236,7 +236,7 @@ class TestSpanHandlerWrapper: def test_func(a, b, c=10): return a + b + c - result = handler.wrapper(tracer, test_func, (1, 2), {"c": 3}) + result = handler.wrapper(tracer, test_func, 1, 2, c=3) assert result == 6 @@ -249,7 +249,7 @@ class TestSpanHandlerWrapper: def my_function(x): return x * 2 - result = handler.wrapper(tracer, my_function, (5,), {}) + result = handler.wrapper(tracer, my_function, 5) assert result == 10 spans = memory_span_exporter.get_finished_spans() diff --git a/api/tests/unit_tests/extensions/test_redis.py b/api/tests/unit_tests/extensions/test_redis.py index 933fa32894..5e9be4ab9b 100644 --- a/api/tests/unit_tests/extensions/test_redis.py +++ b/api/tests/unit_tests/extensions/test_redis.py @@ -1,53 +1,125 @@ +from unittest.mock import patch + from redis import RedisError +from redis.retry import Retry -from extensions.ext_redis import redis_fallback +from extensions.ext_redis import ( + _get_base_redis_params, + _get_cluster_connection_health_params, + _get_connection_health_params, + redis_fallback, +) -def test_redis_fallback_success(): - @redis_fallback(default_return=None) - def test_func(): - return "success" +class TestGetConnectionHealthParams: + @patch("extensions.ext_redis.dify_config") + def test_includes_all_health_params(self, mock_config): + mock_config.REDIS_RETRY_RETRIES = 3 + mock_config.REDIS_RETRY_BACKOFF_BASE = 1.0 + mock_config.REDIS_RETRY_BACKOFF_CAP = 10.0 + mock_config.REDIS_SOCKET_TIMEOUT = 5.0 + mock_config.REDIS_SOCKET_CONNECT_TIMEOUT = 5.0 + mock_config.REDIS_HEALTH_CHECK_INTERVAL = 30 - assert test_func() == "success" + params = _get_connection_health_params() + + assert "retry" in params + assert "socket_timeout" in params + assert "socket_connect_timeout" in params + assert "health_check_interval" in params + assert isinstance(params["retry"], Retry) + assert params["retry"]._retries == 3 + assert params["socket_timeout"] == 5.0 + assert params["socket_connect_timeout"] == 5.0 + assert params["health_check_interval"] == 30 -def test_redis_fallback_error(): - @redis_fallback(default_return="fallback") - def test_func(): - raise RedisError("Redis error") +class TestGetClusterConnectionHealthParams: + @patch("extensions.ext_redis.dify_config") + def test_excludes_health_check_interval(self, mock_config): + mock_config.REDIS_RETRY_RETRIES = 3 + mock_config.REDIS_RETRY_BACKOFF_BASE = 1.0 + mock_config.REDIS_RETRY_BACKOFF_CAP = 10.0 + mock_config.REDIS_SOCKET_TIMEOUT = 5.0 + mock_config.REDIS_SOCKET_CONNECT_TIMEOUT = 5.0 + mock_config.REDIS_HEALTH_CHECK_INTERVAL = 30 - assert test_func() == "fallback" + params = _get_cluster_connection_health_params() + + assert "retry" in params + assert "socket_timeout" in params + assert "socket_connect_timeout" in params + assert "health_check_interval" not in params -def test_redis_fallback_none_default(): - @redis_fallback() - def test_func(): - raise RedisError("Redis error") +class TestGetBaseRedisParams: + @patch("extensions.ext_redis.dify_config") + def test_includes_retry_and_health_params(self, mock_config): + mock_config.REDIS_USERNAME = None + mock_config.REDIS_PASSWORD = None + mock_config.REDIS_DB = 0 + mock_config.REDIS_SERIALIZATION_PROTOCOL = 3 + mock_config.REDIS_ENABLE_CLIENT_SIDE_CACHE = False + mock_config.REDIS_RETRY_RETRIES = 3 + mock_config.REDIS_RETRY_BACKOFF_BASE = 1.0 + mock_config.REDIS_RETRY_BACKOFF_CAP = 10.0 + mock_config.REDIS_SOCKET_TIMEOUT = 5.0 + mock_config.REDIS_SOCKET_CONNECT_TIMEOUT = 5.0 + mock_config.REDIS_HEALTH_CHECK_INTERVAL = 30 - assert test_func() is None + params = _get_base_redis_params() + + assert "retry" in params + assert isinstance(params["retry"], Retry) + assert params["socket_timeout"] == 5.0 + assert params["socket_connect_timeout"] == 5.0 + assert params["health_check_interval"] == 30 + # Existing params still present + assert params["db"] == 0 + assert params["encoding"] == "utf-8" -def test_redis_fallback_with_args(): - @redis_fallback(default_return=0) - def test_func(x, y): - raise RedisError("Redis error") +class TestRedisFallback: + def test_redis_fallback_success(self): + @redis_fallback(default_return=None) + def test_func(): + return "success" - assert test_func(1, 2) == 0 + assert test_func() == "success" + def test_redis_fallback_error(self): + @redis_fallback(default_return="fallback") + def test_func(): + raise RedisError("Redis error") -def test_redis_fallback_with_kwargs(): - @redis_fallback(default_return={}) - def test_func(x=None, y=None): - raise RedisError("Redis error") + assert test_func() == "fallback" - assert test_func(x=1, y=2) == {} + def test_redis_fallback_none_default(self): + @redis_fallback() + def test_func(): + raise RedisError("Redis error") + assert test_func() is None -def test_redis_fallback_preserves_function_metadata(): - @redis_fallback(default_return=None) - def test_func(): - """Test function docstring""" - pass + def test_redis_fallback_with_args(self): + @redis_fallback(default_return=0) + def test_func(x, y): + raise RedisError("Redis error") - assert test_func.__name__ == "test_func" - assert test_func.__doc__ == "Test function docstring" + assert test_func(1, 2) == 0 + + def test_redis_fallback_with_kwargs(self): + @redis_fallback(default_return={}) + def test_func(x=None, y=None): + raise RedisError("Redis error") + + assert test_func(x=1, y=2) == {} + + def test_redis_fallback_preserves_function_metadata(self): + @redis_fallback(default_return=None) + def test_func(): + """Test function docstring""" + pass + + assert test_func.__name__ == "test_func" + assert test_func.__doc__ == "Test function docstring" diff --git a/api/tests/unit_tests/libs/test_pyrefly_type_coverage.py b/api/tests/unit_tests/libs/test_pyrefly_type_coverage.py new file mode 100644 index 0000000000..7087490845 --- /dev/null +++ b/api/tests/unit_tests/libs/test_pyrefly_type_coverage.py @@ -0,0 +1,138 @@ +import json + +from libs.pyrefly_type_coverage import ( + CoverageSummary, + format_comparison_markdown, + format_summary_markdown, + parse_summary, +) + + +def _make_report(summary: dict) -> str: + return json.dumps({"module_reports": [], "summary": summary}) + + +_SAMPLE_SUMMARY: dict = { + "n_modules": 100, + "n_typable": 1000, + "n_typed": 400, + "n_any": 50, + "n_untyped": 550, + "coverage": 45.0, + "strict_coverage": 40.0, + "n_functions": 200, + "n_methods": 300, + "n_function_params": 150, + "n_method_params": 250, + "n_classes": 80, + "n_attrs": 40, + "n_properties": 20, + "n_type_ignores": 10, +} + + +def _make_summary( + *, + n_modules: int = 100, + n_typable: int = 1000, + n_typed: int = 400, + n_any: int = 50, + n_untyped: int = 550, + coverage: float = 45.0, + strict_coverage: float = 40.0, +) -> CoverageSummary: + return { + "n_modules": n_modules, + "n_typable": n_typable, + "n_typed": n_typed, + "n_any": n_any, + "n_untyped": n_untyped, + "coverage": coverage, + "strict_coverage": strict_coverage, + } + + +def test_parse_summary_extracts_fields() -> None: + report_json = _make_report(_SAMPLE_SUMMARY) + + result = parse_summary(report_json) + + assert result["n_modules"] == 100 + assert result["n_typable"] == 1000 + assert result["n_typed"] == 400 + assert result["n_any"] == 50 + assert result["n_untyped"] == 550 + assert result["coverage"] == 45.0 + assert result["strict_coverage"] == 40.0 + + +def test_parse_summary_handles_empty_input() -> None: + assert parse_summary("")["n_modules"] == 0 + assert parse_summary(" ")["n_modules"] == 0 + + +def test_parse_summary_handles_invalid_json() -> None: + assert parse_summary("not json")["n_modules"] == 0 + + +def test_parse_summary_handles_missing_summary_key() -> None: + assert parse_summary(json.dumps({"other": 1}))["n_modules"] == 0 + + +def test_parse_summary_handles_incomplete_summary() -> None: + partial = json.dumps({"summary": {"n_modules": 5}}) + assert parse_summary(partial)["n_modules"] == 0 + + +def test_format_summary_markdown_contains_key_metrics() -> None: + summary = _make_summary() + + result = format_summary_markdown(summary) + + assert "**Type coverage**" in result + assert "45.00%" in result + assert "40.00%" in result + assert "| Modules | 100 |" in result + + +def test_format_comparison_markdown_shows_positive_delta() -> None: + base = _make_summary() + pr = _make_summary( + n_modules=101, + n_typable=1010, + n_typed=420, + n_untyped=540, + coverage=46.53, + strict_coverage=41.58, + ) + + result = format_comparison_markdown(base, pr) + + assert "| Base | PR | Delta |" in result + assert "+1.53%" in result + assert "+1.58%" in result + assert "+20" in result + + +def test_format_comparison_markdown_shows_negative_delta() -> None: + base = _make_summary() + pr = _make_summary( + n_typed=390, + n_any=60, + coverage=44.0, + strict_coverage=39.0, + ) + + result = format_comparison_markdown(base, pr) + + assert "-1.00%" in result + assert "-10" in result + + +def test_format_comparison_markdown_shows_zero_delta() -> None: + summary = _make_summary() + + result = format_comparison_markdown(summary, summary) + + assert "0.00%" in result + assert "| 0 |" in result diff --git a/api/tests/unit_tests/models/test_account_models.py b/api/tests/unit_tests/models/test_account_models.py index f48db77bb5..25933dd15b 100644 --- a/api/tests/unit_tests/models/test_account_models.py +++ b/api/tests/unit_tests/models/test_account_models.py @@ -12,7 +12,6 @@ This test suite covers: import base64 import secrets from datetime import UTC, datetime -from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest @@ -310,90 +309,6 @@ class TestAccountStatusTransitions: class TestTenantRelationshipIntegrity: """Test suite for tenant relationship integrity.""" - @patch("models.account.db") - def test_account_current_tenant_property(self, mock_db): - """Test the current_tenant property getter.""" - # Arrange - account = Account( - name="Test User", - email="test@example.com", - ) - account.id = str(uuid4()) - - tenant = Tenant(name="Test Tenant") - tenant.id = str(uuid4()) - - account._current_tenant = tenant - - # Act - result = account.current_tenant - - # Assert - assert result == tenant - - @patch("models.account.Session") - @patch("models.account.db") - def test_account_current_tenant_setter_with_valid_tenant(self, mock_db, mock_session_class): - """Test setting current_tenant with a valid tenant relationship.""" - # Arrange - account = Account( - name="Test User", - email="test@example.com", - ) - account.id = str(uuid4()) - - tenant = Tenant(name="Test Tenant") - tenant.id = str(uuid4()) - - # Mock the session and queries - mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session - - # Mock TenantAccountJoin query result - tenant_join = TenantAccountJoin( - tenant_id=tenant.id, - account_id=account.id, - role=TenantAccountRole.OWNER, - ) - mock_session.scalar.return_value = tenant_join - - # Mock Tenant query result - mock_session.scalars.return_value.one.return_value = tenant - - # Act - account.current_tenant = tenant - - # Assert - assert account._current_tenant == tenant - assert account.role == TenantAccountRole.OWNER - - @patch("models.account.Session") - @patch("models.account.db") - def test_account_current_tenant_setter_without_relationship(self, mock_db, mock_session_class): - """Test setting current_tenant when no relationship exists.""" - # Arrange - account = Account( - name="Test User", - email="test@example.com", - ) - account.id = str(uuid4()) - - tenant = Tenant(name="Test Tenant") - tenant.id = str(uuid4()) - - # Mock the session and queries - mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session - - # Mock no TenantAccountJoin found - mock_session.scalar.return_value = None - - # Act - account.current_tenant = tenant - - # Assert - assert account._current_tenant is None - def test_account_current_tenant_id_property(self): """Test the current_tenant_id property.""" # Arrange @@ -418,61 +333,6 @@ class TestTenantRelationshipIntegrity: # Assert assert tenant_id_none is None - @patch("models.account.Session") - @patch("models.account.db") - def test_account_set_tenant_id_method(self, mock_db, mock_session_class): - """Test the set_tenant_id method.""" - # Arrange - account = Account( - name="Test User", - email="test@example.com", - ) - account.id = str(uuid4()) - - tenant = Tenant(name="Test Tenant") - tenant.id = str(uuid4()) - - tenant_join = TenantAccountJoin( - tenant_id=tenant.id, - account_id=account.id, - role=TenantAccountRole.ADMIN, - ) - - # Mock the session and queries - mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session - mock_session.execute.return_value.first.return_value = (tenant, tenant_join) - - # Act - account.set_tenant_id(tenant.id) - - # Assert - assert account._current_tenant == tenant - assert account.role == TenantAccountRole.ADMIN - - @patch("models.account.Session") - @patch("models.account.db") - def test_account_set_tenant_id_with_no_relationship(self, mock_db, mock_session_class): - """Test set_tenant_id when no relationship exists.""" - # Arrange - account = Account( - name="Test User", - email="test@example.com", - ) - account.id = str(uuid4()) - tenant_id = str(uuid4()) - - # Mock the session and queries - mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session - mock_session.execute.return_value.first.return_value = None - - # Act - account.set_tenant_id(tenant_id) - - # Assert - should not set tenant when no relationship exists - # The method returns early without setting _current_tenant - class TestAccountRolePermissions: """Test suite for account role permissions.""" @@ -605,51 +465,6 @@ class TestAccountRolePermissions: assert current_role == TenantAccountRole.EDITOR -class TestAccountGetByOpenId: - """Test suite for get_by_openid class method.""" - - @patch("models.account.db") - def test_get_by_openid_success(self, mock_db): - """Test successful retrieval of account by OpenID.""" - # Arrange - provider = "google" - open_id = "google_user_123" - account_id = str(uuid4()) - - mock_account_integrate = MagicMock() - mock_account_integrate.account_id = account_id - - mock_account = Account(name="Test User", email="test@example.com") - mock_account.id = account_id - - # Mock db.session.execute().scalar_one_or_none() for AccountIntegrate lookup - mock_db.session.execute.return_value.scalar_one_or_none.return_value = mock_account_integrate - # Mock db.session.scalar() for Account lookup - mock_db.session.scalar.return_value = mock_account - - # Act - result = Account.get_by_openid(provider, open_id) - - # Assert - assert result == mock_account - - @patch("models.account.db") - def test_get_by_openid_not_found(self, mock_db): - """Test get_by_openid when account integrate doesn't exist.""" - # Arrange - provider = "github" - open_id = "github_user_456" - - # Mock db.session.execute().scalar_one_or_none() to return None - mock_db.session.execute.return_value.scalar_one_or_none.return_value = None - - # Act - result = Account.get_by_openid(provider, open_id) - - # Assert - assert result is None - - class TestTenantAccountJoinModel: """Test suite for TenantAccountJoin model.""" @@ -760,31 +575,6 @@ class TestTenantModel: # Assert assert tenant.custom_config == '{"feature1": true, "feature2": "value"}' - @patch("models.account.db") - def test_tenant_get_accounts(self, mock_db): - """Test getting accounts associated with a tenant.""" - # Arrange - tenant = Tenant(name="Test Workspace") - tenant.id = str(uuid4()) - - account1 = Account(name="User 1", email="user1@example.com") - account1.id = str(uuid4()) - account2 = Account(name="User 2", email="user2@example.com") - account2.id = str(uuid4()) - - # Mock the query chain - mock_scalars = MagicMock() - mock_scalars.all.return_value = [account1, account2] - mock_db.session.scalars.return_value = mock_scalars - - # Act - accounts = tenant.get_accounts() - - # Assert - assert len(accounts) == 2 - assert account1 in accounts - assert account2 in accounts - class TestTenantStatusEnum: """Test suite for TenantStatus enum.""" diff --git a/api/tests/unit_tests/models/test_app_models.py b/api/tests/unit_tests/models/test_app_models.py index 59597fb8cd..4e46cf9654 100644 --- a/api/tests/unit_tests/models/test_app_models.py +++ b/api/tests/unit_tests/models/test_app_models.py @@ -291,24 +291,6 @@ class TestAppModelConfig: # Assert assert result == questions - def test_app_model_config_annotation_reply_dict_disabled(self): - """Test annotation_reply_dict when annotation is disabled.""" - # Arrange - config = AppModelConfig( - app_id=str(uuid4()), - provider="openai", - model_id="gpt-4", - created_by=str(uuid4()), - ) - - # Mock database scalar to return None (no annotation setting found) - with patch("models.model.db.session.scalar", return_value=None): - # Act - result = config.annotation_reply_dict - - # Assert - assert result == {"enabled": False} - class TestConversationModel: """Test suite for Conversation model integrity.""" @@ -948,17 +930,6 @@ class TestSiteModel: with pytest.raises(ValueError, match="Custom disclaimer cannot exceed 512 characters"): site.custom_disclaimer = long_disclaimer - def test_site_generate_code(self): - """Test Site.generate_code static method.""" - # Mock database scalar to return 0 (no existing codes) - with patch("models.model.db.session.scalar", return_value=0): - # Act - code = Site.generate_code(8) - - # Assert - assert isinstance(code, str) - assert len(code) == 8 - class TestModelIntegration: """Test suite for model integration scenarios.""" @@ -1146,314 +1117,3 @@ class TestModelIntegration: # Assert assert site.app_id == app.id assert app.enable_site is True - - -class TestConversationStatusCount: - """Test suite for Conversation.status_count property N+1 query fix.""" - - def test_status_count_no_messages(self): - """Test status_count returns None when conversation has no messages.""" - # Arrange - conversation = Conversation( - app_id=str(uuid4()), - mode=AppMode.CHAT, - name="Test Conversation", - status="normal", - from_source=ConversationFromSource.API, - ) - conversation.id = str(uuid4()) - - # Mock the database query to return no messages - with patch("models.model.db.session.scalars") as mock_scalars: - mock_scalars.return_value.all.return_value = [] - - # Act - result = conversation.status_count - - # Assert - assert result is None - - def test_status_count_messages_without_workflow_runs(self): - """Test status_count when messages have no workflow_run_id.""" - # Arrange - app_id = str(uuid4()) - conversation_id = str(uuid4()) - - conversation = Conversation( - app_id=app_id, - mode=AppMode.CHAT, - name="Test Conversation", - status="normal", - from_source=ConversationFromSource.API, - ) - conversation.id = conversation_id - - # Mock the database query to return no messages with workflow_run_id - with patch("models.model.db.session.scalars") as mock_scalars: - mock_scalars.return_value.all.return_value = [] - - # Act - result = conversation.status_count - - # Assert - assert result is None - - def test_status_count_batch_loading_implementation(self): - """Test that status_count uses batch loading instead of N+1 queries.""" - # Arrange - from graphon.enums import WorkflowExecutionStatus - - app_id = str(uuid4()) - conversation_id = str(uuid4()) - - # Create workflow run IDs - workflow_run_id_1 = str(uuid4()) - workflow_run_id_2 = str(uuid4()) - workflow_run_id_3 = str(uuid4()) - - conversation = Conversation( - app_id=app_id, - mode=AppMode.CHAT, - name="Test Conversation", - status="normal", - from_source=ConversationFromSource.API, - ) - conversation.id = conversation_id - - # Mock messages with workflow_run_id - mock_messages = [ - MagicMock( - conversation_id=conversation_id, - workflow_run_id=workflow_run_id_1, - ), - MagicMock( - conversation_id=conversation_id, - workflow_run_id=workflow_run_id_2, - ), - MagicMock( - conversation_id=conversation_id, - workflow_run_id=workflow_run_id_3, - ), - ] - - # Mock workflow runs with different statuses - mock_workflow_runs = [ - MagicMock( - id=workflow_run_id_1, - status=WorkflowExecutionStatus.SUCCEEDED.value, - app_id=app_id, - ), - MagicMock( - id=workflow_run_id_2, - status=WorkflowExecutionStatus.FAILED.value, - app_id=app_id, - ), - MagicMock( - id=workflow_run_id_3, - status=WorkflowExecutionStatus.PARTIAL_SUCCEEDED.value, - app_id=app_id, - ), - ] - - # Track database calls - calls_made = [] - - def mock_scalars(query): - calls_made.append(str(query)) - mock_result = MagicMock() - - # Return messages for the first query (messages with workflow_run_id) - if "messages" in str(query) and "conversation_id" in str(query): - mock_result.all.return_value = mock_messages - # Return workflow runs for the batch query - elif "workflow_runs" in str(query): - mock_result.all.return_value = mock_workflow_runs - else: - mock_result.all.return_value = [] - - return mock_result - - # Act & Assert - with patch("models.model.db.session.scalars", side_effect=mock_scalars): - result = conversation.status_count - - # Verify only 2 database queries were made (not N+1) - assert len(calls_made) == 2, f"Expected 2 queries, got {len(calls_made)}: {calls_made}" - - # Verify the first query gets messages - assert "messages" in calls_made[0] - assert "conversation_id" in calls_made[0] - - # Verify the second query batch loads workflow runs with proper filtering - assert "workflow_runs" in calls_made[1] - assert "app_id" in calls_made[1] # Security filter applied - assert "IN" in calls_made[1] # Batch loading with IN clause - - # Verify correct status counts - assert result["success"] == 1 # One SUCCEEDED - assert result["failed"] == 1 # One FAILED - assert result["partial_success"] == 1 # One PARTIAL_SUCCEEDED - assert result["paused"] == 0 - - def test_status_count_app_id_filtering(self): - """Test that status_count filters workflow runs by app_id for security.""" - # Arrange - app_id = str(uuid4()) - other_app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_run_id = str(uuid4()) - - conversation = Conversation( - app_id=app_id, - mode=AppMode.CHAT, - name="Test Conversation", - status="normal", - from_source=ConversationFromSource.API, - ) - conversation.id = conversation_id - - # Mock message with workflow_run_id - mock_messages = [ - MagicMock( - conversation_id=conversation_id, - workflow_run_id=workflow_run_id, - ), - ] - - calls_made = [] - - def mock_scalars(query): - calls_made.append(str(query)) - mock_result = MagicMock() - - if "messages" in str(query): - mock_result.all.return_value = mock_messages - elif "workflow_runs" in str(query): - # Return empty list because no workflow run matches the correct app_id - mock_result.all.return_value = [] # Workflow run filtered out by app_id - else: - mock_result.all.return_value = [] - - return mock_result - - # Act - with patch("models.model.db.session.scalars", side_effect=mock_scalars): - result = conversation.status_count - - # Assert - query should include app_id filter - workflow_query = calls_made[1] - assert "app_id" in workflow_query - - # Since workflow run has wrong app_id, it shouldn't be included in counts - assert result["success"] == 0 - assert result["failed"] == 0 - assert result["partial_success"] == 0 - assert result["paused"] == 0 - - def test_status_count_handles_invalid_workflow_status(self): - """Test that status_count gracefully handles invalid workflow status values.""" - # Arrange - app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_run_id = str(uuid4()) - - conversation = Conversation( - app_id=app_id, - mode=AppMode.CHAT, - name="Test Conversation", - status="normal", - from_source=ConversationFromSource.API, - ) - conversation.id = conversation_id - - mock_messages = [ - MagicMock( - conversation_id=conversation_id, - workflow_run_id=workflow_run_id, - ), - ] - - # Mock workflow run with invalid status - mock_workflow_runs = [ - MagicMock( - id=workflow_run_id, - status="invalid_status", # Invalid status that should raise ValueError - app_id=app_id, - ), - ] - - with patch("models.model.db.session.scalars") as mock_scalars: - # Mock the messages query - def mock_scalars_side_effect(query): - mock_result = MagicMock() - if "messages" in str(query): - mock_result.all.return_value = mock_messages - elif "workflow_runs" in str(query): - mock_result.all.return_value = mock_workflow_runs - else: - mock_result.all.return_value = [] - return mock_result - - mock_scalars.side_effect = mock_scalars_side_effect - - # Act - should not raise exception - result = conversation.status_count - - # Assert - should handle invalid status gracefully - assert result["success"] == 0 - assert result["failed"] == 0 - assert result["partial_success"] == 0 - assert result["paused"] == 0 - - def test_status_count_paused(self): - """Test status_count includes paused workflow runs.""" - # Arrange - from graphon.enums import WorkflowExecutionStatus - - app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_run_id = str(uuid4()) - - conversation = Conversation( - app_id=app_id, - mode=AppMode.CHAT, - name="Test Conversation", - status="normal", - from_source=ConversationFromSource.API, - ) - conversation.id = conversation_id - - mock_messages = [ - MagicMock( - conversation_id=conversation_id, - workflow_run_id=workflow_run_id, - ), - ] - - mock_workflow_runs = [ - MagicMock( - id=workflow_run_id, - status=WorkflowExecutionStatus.PAUSED.value, - app_id=app_id, - ), - ] - - with patch("models.model.db.session.scalars") as mock_scalars: - - def mock_scalars_side_effect(query): - mock_result = MagicMock() - if "messages" in str(query): - mock_result.all.return_value = mock_messages - elif "workflow_runs" in str(query): - mock_result.all.return_value = mock_workflow_runs - else: - mock_result.all.return_value = [] - return mock_result - - mock_scalars.side_effect = mock_scalars_side_effect - - # Act - result = conversation.status_count - - # Assert - assert result["paused"] == 1 diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 6c8a91129b..51d95c4239 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -12,7 +12,7 @@ This test suite covers: import json import pickle from datetime import UTC, datetime -from unittest.mock import patch +from unittest.mock import Mock, patch from uuid import uuid4 from core.rag.index_processor.constant.index_type import IndexTechniqueType @@ -25,6 +25,7 @@ from models.dataset import ( Document, DocumentSegment, Embedding, + ExternalKnowledgeBindings, ) from models.enums import ( DataSourceType, @@ -180,6 +181,24 @@ class TestDatasetModelValidation: assert result["top_k"] == 2 assert result["score_threshold"] == 0.0 + def test_dataset_external_knowledge_info_returns_none_for_cross_tenant_template(self): + """Test external datasets fail closed when the bound template is outside the tenant.""" + dataset = Dataset( + tenant_id=str(uuid4()), + name="External Dataset", + data_source_type=DataSourceType.UPLOAD_FILE, + created_by=str(uuid4()), + provider="external", + ) + binding = Mock(spec=ExternalKnowledgeBindings) + binding.external_knowledge_id = "knowledge-1" + binding.external_knowledge_api_id = str(uuid4()) + + with patch("models.dataset.db") as mock_db: + mock_db.session.scalar.side_effect = [binding, None] + + assert dataset.external_knowledge_info is None + def test_dataset_retrieval_model_dict_property(self): """Test retrieval_model_dict property with default values.""" # Arrange diff --git a/api/tests/unit_tests/models/test_model.py b/api/tests/unit_tests/models/test_model.py index a5909f60a8..3f6d6bfbe3 100644 --- a/api/tests/unit_tests/models/test_model.py +++ b/api/tests/unit_tests/models/test_model.py @@ -101,118 +101,6 @@ def _build_local_file_mapping(record_id: str, *, tenant_id: str | None = None) - return mapping -@pytest.mark.parametrize("owner_cls", [Conversation, Message]) -def test_inputs_resolve_owner_tenant_for_single_file_mapping( - monkeypatch: pytest.MonkeyPatch, - owner_cls: type[Conversation] | type[Message], -): - model_module = importlib.import_module("models.model") - build_calls: list[tuple[dict[str, object], str]] = [] - - monkeypatch.setattr(model_module.db.session, "scalar", lambda _: "tenant-from-app") - - def fake_build_from_mapping(*, mapping, tenant_id, config=None, strict_type_validation=False, access_controller): - _ = config, strict_type_validation, access_controller - build_calls.append((dict(mapping), tenant_id)) - return {"tenant_id": tenant_id, "upload_file_id": mapping.get("upload_file_id")} - - monkeypatch.setattr("factories.file_factory.build_from_mapping", fake_build_from_mapping) - - owner = owner_cls(app_id="app-1") - owner.inputs = {"file": _build_local_file_mapping("upload-1")} - - restored_inputs = owner.inputs - - assert restored_inputs["file"] == {"tenant_id": "tenant-from-app", "upload_file_id": "upload-1"} - assert build_calls == [ - ( - { - **_build_local_file_mapping("upload-1"), - "upload_file_id": "upload-1", - }, - "tenant-from-app", - ) - ] - - -@pytest.mark.parametrize("owner_cls", [Conversation, Message]) -def test_inputs_resolve_owner_tenant_for_file_list_mapping( - monkeypatch: pytest.MonkeyPatch, - owner_cls: type[Conversation] | type[Message], -): - model_module = importlib.import_module("models.model") - build_calls: list[tuple[dict[str, object], str]] = [] - - monkeypatch.setattr(model_module.db.session, "scalar", lambda _: "tenant-from-app") - - def fake_build_from_mapping(*, mapping, tenant_id, config=None, strict_type_validation=False, access_controller): - _ = config, strict_type_validation, access_controller - build_calls.append((dict(mapping), tenant_id)) - return {"tenant_id": tenant_id, "upload_file_id": mapping.get("upload_file_id")} - - monkeypatch.setattr("factories.file_factory.build_from_mapping", fake_build_from_mapping) - - owner = owner_cls(app_id="app-1") - owner.inputs = { - "files": [ - _build_local_file_mapping("upload-1"), - _build_local_file_mapping("upload-2"), - ] - } - - restored_inputs = owner.inputs - - assert restored_inputs["files"] == [ - {"tenant_id": "tenant-from-app", "upload_file_id": "upload-1"}, - {"tenant_id": "tenant-from-app", "upload_file_id": "upload-2"}, - ] - assert build_calls == [ - ( - { - **_build_local_file_mapping("upload-1"), - "upload_file_id": "upload-1", - }, - "tenant-from-app", - ), - ( - { - **_build_local_file_mapping("upload-2"), - "upload_file_id": "upload-2", - }, - "tenant-from-app", - ), - ] - - -@pytest.mark.parametrize("owner_cls", [Conversation, Message]) -def test_inputs_prefer_serialized_tenant_id_when_present( - monkeypatch: pytest.MonkeyPatch, - owner_cls: type[Conversation] | type[Message], -): - model_module = importlib.import_module("models.model") - - def fail_if_called(_): - raise AssertionError("App tenant lookup should not run when tenant_id exists in the file mapping") - - monkeypatch.setattr(model_module.db.session, "scalar", fail_if_called) - - def fake_build_from_mapping(*, mapping, tenant_id, config=None, strict_type_validation=False, access_controller): - _ = config, strict_type_validation, access_controller - return {"tenant_id": tenant_id, "upload_file_id": mapping.get("upload_file_id")} - - monkeypatch.setattr("factories.file_factory.build_from_mapping", fake_build_from_mapping) - - owner = owner_cls(app_id="app-1") - owner.inputs = {"file": _build_local_file_mapping("upload-1", tenant_id="tenant-from-payload")} - - restored_inputs = owner.inputs - - assert restored_inputs["file"] == { - "tenant_id": "tenant-from-payload", - "upload_file_id": "upload-1", - } - - @pytest.mark.parametrize("owner_cls", [Conversation, Message]) def test_inputs_restore_external_remote_url_file_mappings(owner_cls: type[Conversation] | type[Message]) -> None: owner = owner_cls(app_id="app-1") diff --git a/api/tests/unit_tests/models/test_workflow_trigger_log.py b/api/tests/unit_tests/models/test_workflow_trigger_log.py deleted file mode 100644 index 7fdad92fb6..0000000000 --- a/api/tests/unit_tests/models/test_workflow_trigger_log.py +++ /dev/null @@ -1,188 +0,0 @@ -import types - -import pytest - -from models.engine import db -from models.enums import CreatorUserRole -from models.workflow import WorkflowNodeExecutionModel - - -@pytest.fixture -def fake_db_scalar(monkeypatch): - """Provide a controllable fake for db.session.scalar (SQLAlchemy 2.0 style).""" - calls = [] - - def _install(side_effect): - def _fake_scalar(statement): - calls.append(statement) - return side_effect(statement) - - # Patch the modern API used by the model implementation - monkeypatch.setattr(db.session, "scalar", _fake_scalar) - - # Backward-compatibility: if the implementation still uses db.session.get, - # make it delegate to the same side_effect so tests remain valid on older code. - if hasattr(db.session, "get"): - - def _fake_get(*_args, **_kwargs): - return side_effect(None) - - monkeypatch.setattr(db.session, "get", _fake_get) - - return calls - - return _install - - -def make_account(id_: str = "acc-1"): - # Use a simple object to avoid constructing a full SQLAlchemy model instance - # Python 3.12 forbids reassigning __class__ for SimpleNamespace; not needed here. - obj = types.SimpleNamespace() - obj.id = id_ - return obj - - -def make_end_user(id_: str = "user-1"): - # Lightweight stand-in object; no need to spoof class identity. - obj = types.SimpleNamespace() - obj.id = id_ - return obj - - -def test_created_by_account_returns_account_when_role_account(fake_db_scalar): - account = make_account("acc-1") - - # The implementation uses db.session.scalar(select(Account)...). We only need to - # return the expected object when called; the exact SQL is irrelevant for this unit test. - def side_effect(_statement): - return account - - fake_db_scalar(side_effect) - - log = WorkflowNodeExecutionModel( - tenant_id="t1", - app_id="a1", - workflow_id="w1", - triggered_from="workflow-run", - workflow_run_id=None, - index=1, - predecessor_node_id=None, - node_execution_id=None, - node_id="n1", - node_type="start", - title="Start", - inputs=None, - process_data=None, - outputs=None, - status="succeeded", - error=None, - elapsed_time=0.0, - execution_metadata=None, - created_by_role=CreatorUserRole.ACCOUNT.value, - created_by="acc-1", - ) - - assert log.created_by_account is account - - -def test_created_by_account_returns_none_when_role_not_account(fake_db_scalar): - # Even if an Account with matching id exists, property should return None when role is END_USER - account = make_account("acc-1") - - def side_effect(_statement): - return account - - fake_db_scalar(side_effect) - - log = WorkflowNodeExecutionModel( - tenant_id="t1", - app_id="a1", - workflow_id="w1", - triggered_from="workflow-run", - workflow_run_id=None, - index=1, - predecessor_node_id=None, - node_execution_id=None, - node_id="n1", - node_type="start", - title="Start", - inputs=None, - process_data=None, - outputs=None, - status="succeeded", - error=None, - elapsed_time=0.0, - execution_metadata=None, - created_by_role=CreatorUserRole.END_USER.value, - created_by="acc-1", - ) - - assert log.created_by_account is None - - -def test_created_by_end_user_returns_end_user_when_role_end_user(fake_db_scalar): - end_user = make_end_user("user-1") - - def side_effect(_statement): - return end_user - - fake_db_scalar(side_effect) - - log = WorkflowNodeExecutionModel( - tenant_id="t1", - app_id="a1", - workflow_id="w1", - triggered_from="workflow-run", - workflow_run_id=None, - index=1, - predecessor_node_id=None, - node_execution_id=None, - node_id="n1", - node_type="start", - title="Start", - inputs=None, - process_data=None, - outputs=None, - status="succeeded", - error=None, - elapsed_time=0.0, - execution_metadata=None, - created_by_role=CreatorUserRole.END_USER.value, - created_by="user-1", - ) - - assert log.created_by_end_user is end_user - - -def test_created_by_end_user_returns_none_when_role_not_end_user(fake_db_scalar): - end_user = make_end_user("user-1") - - def side_effect(_statement): - return end_user - - fake_db_scalar(side_effect) - - log = WorkflowNodeExecutionModel( - tenant_id="t1", - app_id="a1", - workflow_id="w1", - triggered_from="workflow-run", - workflow_run_id=None, - index=1, - predecessor_node_id=None, - node_execution_id=None, - node_id="n1", - node_type="start", - title="Start", - inputs=None, - process_data=None, - outputs=None, - status="succeeded", - error=None, - elapsed_time=0.0, - execution_metadata=None, - created_by_role=CreatorUserRole.ACCOUNT.value, - created_by="user-1", - ) - - assert log.created_by_end_user is None diff --git a/api/tests/unit_tests/repositories/workflow_node_execution/__init__.py b/api/tests/unit_tests/repositories/workflow_node_execution/__init__.py deleted file mode 100644 index 78815a8d1a..0000000000 --- a/api/tests/unit_tests/repositories/workflow_node_execution/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Unit tests for workflow_node_execution repositories. -""" diff --git a/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_repository.py b/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_repository.py deleted file mode 100644 index 10850970d8..0000000000 --- a/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_repository.py +++ /dev/null @@ -1,340 +0,0 @@ -""" -Unit tests for the SQLAlchemy implementation of WorkflowNodeExecutionRepository. -""" - -import json -import uuid -from datetime import datetime -from decimal import Decimal -from unittest.mock import MagicMock, PropertyMock - -import pytest -from graphon.entities import ( - WorkflowNodeExecution, -) -from graphon.enums import ( - BuiltinNodeTypes, - WorkflowNodeExecutionMetadataKey, - WorkflowNodeExecutionStatus, -) -from graphon.model_runtime.utils.encoders import jsonable_encoder -from pytest_mock import MockerFixture -from sqlalchemy.orm import Session, sessionmaker - -from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository -from core.repositories.factory import OrderConfig -from models.account import Account, Tenant -from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom - - -def configure_mock_execution(mock_execution): - """Configure a mock execution with proper JSON serializable values.""" - # Configure inputs, outputs, process_data, and execution_metadata to return JSON serializable values - type(mock_execution).inputs = PropertyMock(return_value='{"key": "value"}') - type(mock_execution).outputs = PropertyMock(return_value='{"result": "success"}') - type(mock_execution).process_data = PropertyMock(return_value='{"process": "data"}') - type(mock_execution).execution_metadata = PropertyMock(return_value='{"metadata": "info"}') - - # Configure status and triggered_from to be valid enum values - mock_execution.status = "running" - mock_execution.triggered_from = "workflow-run" - - return mock_execution - - -@pytest.fixture -def session(): - """Create a mock SQLAlchemy session.""" - session = MagicMock(spec=Session) - # Configure the session to be used as a context manager - session.__enter__ = MagicMock(return_value=session) - session.__exit__ = MagicMock(return_value=None) - - # Configure the session factory to return the session - session_factory = MagicMock(spec=sessionmaker) - session_factory.return_value = session - return session, session_factory - - -@pytest.fixture -def mock_user(): - """Create a user instance for testing.""" - user = Account(name="test", email="test@example.com") - user.id = "test-user-id" - - tenant = Tenant(name="Test Workspace") - tenant.id = "test-tenant" - user._current_tenant = MagicMock() - user._current_tenant.id = "test-tenant" - - return user - - -@pytest.fixture -def repository(session, mock_user): - """Create a repository instance with test data.""" - _, session_factory = session - app_id = "test-app" - return SQLAlchemyWorkflowNodeExecutionRepository( - session_factory=session_factory, - user=mock_user, - app_id=app_id, - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, - ) - - -def test_save(repository, session): - """Test save method.""" - session_obj, _ = session - # Create a mock execution - execution = MagicMock(spec=WorkflowNodeExecution) - execution.id = "test-id" - execution.node_execution_id = "test-node-execution-id" - execution.tenant_id = None - execution.app_id = None - execution.inputs = None - execution.process_data = None - execution.outputs = None - execution.metadata = None - execution.workflow_id = str(uuid.uuid4()) - - # Mock the to_db_model method to return the execution itself - # This simulates the behavior of setting tenant_id and app_id - db_model = MagicMock(spec=WorkflowNodeExecutionModel) - db_model.id = "test-id" - db_model.node_execution_id = "test-node-execution-id" - repository._to_db_model = MagicMock(return_value=db_model) - - # Mock session.get to return None (no existing record) - session_obj.get.return_value = None - - # Call save method - repository.save(execution) - - # Assert to_db_model was called with the execution - repository._to_db_model.assert_called_once_with(execution) - - # Assert session.get was called to check for existing record - session_obj.get.assert_called_once_with(WorkflowNodeExecutionModel, db_model.id) - - # Assert session.add was called for new record - session_obj.add.assert_called_once_with(db_model) - - # Assert session.commit was called - session_obj.commit.assert_called_once() - - -def test_save_with_existing_tenant_id(repository, session): - """Test save method with existing tenant_id.""" - session_obj, _ = session - # Create a mock execution with existing tenant_id - execution = MagicMock(spec=WorkflowNodeExecutionModel) - execution.id = "existing-id" - execution.node_execution_id = "existing-node-execution-id" - execution.tenant_id = "existing-tenant" - execution.app_id = None - execution.inputs = None - execution.process_data = None - execution.outputs = None - execution.metadata = None - - # Create a modified execution that will be returned by _to_db_model - modified_execution = MagicMock(spec=WorkflowNodeExecutionModel) - modified_execution.id = "existing-id" - modified_execution.node_execution_id = "existing-node-execution-id" - modified_execution.tenant_id = "existing-tenant" # Tenant ID should not change - modified_execution.app_id = repository._app_id # App ID should be set - # Create a dictionary to simulate __dict__ for updating attributes - modified_execution.__dict__ = { - "id": "existing-id", - "node_execution_id": "existing-node-execution-id", - "tenant_id": "existing-tenant", - "app_id": repository._app_id, - } - - # Mock the to_db_model method to return the modified execution - repository._to_db_model = MagicMock(return_value=modified_execution) - - # Mock session.get to return an existing record - existing_model = MagicMock(spec=WorkflowNodeExecutionModel) - session_obj.get.return_value = existing_model - - # Call save method - repository.save(execution) - - # Assert to_db_model was called with the execution - repository._to_db_model.assert_called_once_with(execution) - - # Assert session.get was called to check for existing record - session_obj.get.assert_called_once_with(WorkflowNodeExecutionModel, modified_execution.id) - - # Assert session.add was NOT called since we're updating existing - session_obj.add.assert_not_called() - - # Assert session.commit was called - session_obj.commit.assert_called_once() - - -def test_get_by_workflow_execution(repository, session, mocker: MockerFixture): - """Test get_by_workflow_execution method.""" - session_obj, _ = session - # Set up mock - mock_select = mocker.patch("core.repositories.sqlalchemy_workflow_node_execution_repository.select") - mock_asc = mocker.patch("core.repositories.sqlalchemy_workflow_node_execution_repository.asc") - mock_desc = mocker.patch("core.repositories.sqlalchemy_workflow_node_execution_repository.desc") - - mock_WorkflowNodeExecutionModel = mocker.patch( - "core.repositories.sqlalchemy_workflow_node_execution_repository.WorkflowNodeExecutionModel" - ) - mock_stmt = mocker.MagicMock() - mock_select.return_value = mock_stmt - mock_stmt.where.return_value = mock_stmt - mock_stmt.order_by.return_value = mock_stmt - mock_asc.return_value = mock_stmt - mock_desc.return_value = mock_stmt - mock_WorkflowNodeExecutionModel.preload_offload_data_and_files.return_value = mock_stmt - - # Create a properly configured mock execution - mock_execution = mocker.MagicMock(spec=WorkflowNodeExecutionModel) - configure_mock_execution(mock_execution) - session_obj.scalars.return_value.all.return_value = [mock_execution] - - # Create a mock domain model to be returned by _to_domain_model - mock_domain_model = mocker.MagicMock() - # Mock the _to_domain_model method to return our mock domain model - repository._to_domain_model = mocker.MagicMock(return_value=mock_domain_model) - - # Call method - order_config = OrderConfig(order_by=["index"], order_direction="desc") - result = repository.get_by_workflow_execution( - workflow_execution_id="test-workflow-run-id", - order_config=order_config, - ) - - # Assert select was called with correct parameters - mock_select.assert_called_once() - session_obj.scalars.assert_called_once_with(mock_stmt) - mock_WorkflowNodeExecutionModel.preload_offload_data_and_files.assert_called_once_with(mock_stmt) - # Assert _to_domain_model was called with the mock execution - repository._to_domain_model.assert_called_once_with(mock_execution) - # Assert the result contains our mock domain model - assert len(result) == 1 - assert result[0] is mock_domain_model - - -def test_to_db_model(repository): - """Test to_db_model method.""" - # Create a domain model - domain_model = WorkflowNodeExecution( - id="test-id", - workflow_id="test-workflow-id", - node_execution_id="test-node-execution-id", - workflow_execution_id="test-workflow-run-id", - index=1, - predecessor_node_id="test-predecessor-id", - node_id="test-node-id", - node_type=BuiltinNodeTypes.START, - title="Test Node", - inputs={"input_key": "input_value"}, - process_data={"process_key": "process_value"}, - outputs={"output_key": "output_value"}, - status=WorkflowNodeExecutionStatus.RUNNING, - error=None, - elapsed_time=1.5, - metadata={ - WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: 100, - WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: Decimal("0.0"), - }, - created_at=datetime.now(), - finished_at=None, - ) - - # Convert to DB model - db_model = repository._to_db_model(domain_model) - - # Assert DB model has correct values - assert isinstance(db_model, WorkflowNodeExecutionModel) - assert db_model.id == domain_model.id - assert db_model.tenant_id == repository._tenant_id - assert db_model.app_id == repository._app_id - assert db_model.workflow_id == domain_model.workflow_id - assert db_model.triggered_from == repository._triggered_from - assert db_model.workflow_run_id == domain_model.workflow_execution_id - assert db_model.index == domain_model.index - assert db_model.predecessor_node_id == domain_model.predecessor_node_id - assert db_model.node_execution_id == domain_model.node_execution_id - assert db_model.node_id == domain_model.node_id - assert db_model.node_type == domain_model.node_type - assert db_model.title == domain_model.title - - assert db_model.inputs_dict == domain_model.inputs - assert db_model.process_data_dict == domain_model.process_data - assert db_model.outputs_dict == domain_model.outputs - assert db_model.execution_metadata_dict == jsonable_encoder(domain_model.metadata) - - assert db_model.status == domain_model.status - assert db_model.error == domain_model.error - assert db_model.elapsed_time == domain_model.elapsed_time - assert db_model.created_at == domain_model.created_at - assert db_model.created_by_role == repository._creator_user_role - assert db_model.created_by == repository._creator_user_id - assert db_model.finished_at == domain_model.finished_at - - -def test_to_domain_model(repository): - """Test _to_domain_model method.""" - # Create input dictionaries - inputs_dict = {"input_key": "input_value"} - process_data_dict = {"process_key": "process_value"} - outputs_dict = {"output_key": "output_value"} - metadata_dict = {str(WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS): 100} - - # Create a DB model using our custom subclass - db_model = WorkflowNodeExecutionModel() - db_model.id = "test-id" - db_model.tenant_id = "test-tenant-id" - db_model.app_id = "test-app-id" - db_model.workflow_id = "test-workflow-id" - db_model.triggered_from = "workflow-run" - db_model.workflow_run_id = "test-workflow-run-id" - db_model.index = 1 - db_model.predecessor_node_id = "test-predecessor-id" - db_model.node_execution_id = "test-node-execution-id" - db_model.node_id = "test-node-id" - db_model.node_type = BuiltinNodeTypes.START - db_model.title = "Test Node" - db_model.inputs = json.dumps(inputs_dict) - db_model.process_data = json.dumps(process_data_dict) - db_model.outputs = json.dumps(outputs_dict) - db_model.status = WorkflowNodeExecutionStatus.RUNNING - db_model.error = None - db_model.elapsed_time = 1.5 - db_model.execution_metadata = json.dumps(metadata_dict) - db_model.created_at = datetime.now() - db_model.created_by_role = "account" - db_model.created_by = "test-user-id" - db_model.finished_at = None - - # Convert to domain model - domain_model = repository._to_domain_model(db_model) - - # Assert domain model has correct values - assert isinstance(domain_model, WorkflowNodeExecution) - assert domain_model.id == db_model.id - assert domain_model.workflow_id == db_model.workflow_id - assert domain_model.workflow_execution_id == db_model.workflow_run_id - assert domain_model.index == db_model.index - assert domain_model.predecessor_node_id == db_model.predecessor_node_id - assert domain_model.node_execution_id == db_model.node_execution_id - assert domain_model.node_id == db_model.node_id - assert domain_model.node_type == db_model.node_type - assert domain_model.title == db_model.title - assert domain_model.inputs == inputs_dict - assert domain_model.process_data == process_data_dict - assert domain_model.outputs == outputs_dict - assert domain_model.status == WorkflowNodeExecutionStatus(db_model.status) - assert domain_model.error == db_model.error - assert domain_model.elapsed_time == db_model.elapsed_time - assert domain_model.metadata == metadata_dict - assert domain_model.created_at == db_model.created_at - assert domain_model.finished_at == db_model.finished_at diff --git a/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_workflow_node_execution_repository.py b/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_workflow_node_execution_repository.py deleted file mode 100644 index 2322be9e80..0000000000 --- a/api/tests/unit_tests/repositories/workflow_node_execution/test_sqlalchemy_workflow_node_execution_repository.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Unit tests for SQLAlchemyWorkflowNodeExecutionRepository, focusing on process_data truncation functionality. -""" - -from datetime import datetime -from typing import Any -from unittest.mock import MagicMock, Mock - -from graphon.entities import WorkflowNodeExecution -from graphon.enums import BuiltinNodeTypes -from sqlalchemy.orm import sessionmaker - -from core.repositories.sqlalchemy_workflow_node_execution_repository import ( - SQLAlchemyWorkflowNodeExecutionRepository, -) -from models import Account, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom - - -class TestSQLAlchemyWorkflowNodeExecutionRepositoryProcessData: - """Test process_data truncation functionality in SQLAlchemyWorkflowNodeExecutionRepository.""" - - def create_mock_account(self) -> Account: - """Create a mock Account for testing.""" - account = Mock(spec=Account) - account.id = "test-user-id" - account.tenant_id = "test-tenant-id" - return account - - def create_mock_session_factory(self) -> sessionmaker: - """Create a mock session factory for testing.""" - mock_session = MagicMock() - mock_session_factory = MagicMock(spec=sessionmaker) - mock_session_factory.return_value.__enter__.return_value = mock_session - mock_session_factory.return_value.__exit__.return_value = None - return mock_session_factory - - def create_repository(self, mock_file_service=None) -> SQLAlchemyWorkflowNodeExecutionRepository: - """Create a repository instance for testing.""" - mock_account = self.create_mock_account() - mock_session_factory = self.create_mock_session_factory() - - repository = SQLAlchemyWorkflowNodeExecutionRepository( - session_factory=mock_session_factory, - user=mock_account, - app_id="test-app-id", - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, - ) - - if mock_file_service: - repository._file_service = mock_file_service - - return repository - - def create_workflow_node_execution( - self, - process_data: dict[str, Any] | None = None, - execution_id: str = "test-execution-id", - ) -> WorkflowNodeExecution: - """Create a WorkflowNodeExecution instance for testing.""" - return WorkflowNodeExecution( - id=execution_id, - workflow_id="test-workflow-id", - index=1, - node_id="test-node-id", - node_type=BuiltinNodeTypes.LLM, - title="Test Node", - process_data=process_data, - created_at=datetime.now(), - ) - - def test_to_domain_model_without_offload_data(self): - """Test _to_domain_model without offload data.""" - repository = self.create_repository() - - # Create mock database model without offload data - db_model = Mock(spec=WorkflowNodeExecutionModel) - db_model.id = "test-execution-id" - db_model.node_execution_id = "test-node-execution-id" - db_model.workflow_id = "test-workflow-id" - db_model.workflow_run_id = None - db_model.index = 1 - db_model.predecessor_node_id = None - db_model.node_id = "test-node-id" - db_model.node_type = "llm" - db_model.title = "Test Node" - db_model.status = "succeeded" - db_model.error = None - db_model.elapsed_time = 1.5 - db_model.created_at = datetime.now() - db_model.finished_at = None - - process_data = {"normal": "data"} - db_model.process_data_dict = process_data - db_model.inputs_dict = None - db_model.outputs_dict = None - db_model.execution_metadata_dict = {} - db_model.offload_data = None - - domain_model = repository._to_domain_model(db_model) - - # Domain model should have the data from database - assert domain_model.process_data == process_data - - # Should not be truncated - assert domain_model.process_data_truncated is False - assert domain_model.get_truncated_process_data() is None diff --git a/api/tests/unit_tests/services/dataset_metadata.py b/api/tests/unit_tests/services/dataset_metadata.py deleted file mode 100644 index b825a8686a..0000000000 --- a/api/tests/unit_tests/services/dataset_metadata.py +++ /dev/null @@ -1,1014 +0,0 @@ -""" -Comprehensive unit tests for MetadataService. - -This module contains extensive unit tests for the MetadataService class, -which handles dataset metadata CRUD operations and filtering/querying functionality. - -The MetadataService provides methods for: -- Creating, reading, updating, and deleting metadata fields -- Managing built-in metadata fields -- Updating document metadata values -- Metadata filtering and querying operations -- Lock management for concurrent metadata operations - -Metadata in Dify allows users to add custom fields to datasets and documents, -enabling rich filtering and search capabilities. Metadata can be of various -types (string, number, date, boolean, etc.) and can be used to categorize -and filter documents within a dataset. - -This test suite ensures: -- Correct creation of metadata fields with validation -- Proper updating of metadata names and values -- Accurate deletion of metadata fields -- Built-in field management (enable/disable) -- Document metadata updates (partial and full) -- Lock management for concurrent operations -- Metadata querying and filtering functionality - -================================================================================ -ARCHITECTURE OVERVIEW -================================================================================ - -The MetadataService is a critical component in the Dify platform's metadata -management system. It serves as the primary interface for all metadata-related -operations, including field definitions and document-level metadata values. - -Key Concepts: -1. DatasetMetadata: Defines a metadata field for a dataset. Each metadata - field has a name, type, and is associated with a specific dataset. - -2. DatasetMetadataBinding: Links metadata fields to documents. This allows - tracking which documents have which metadata fields assigned. - -3. Document Metadata: The actual metadata values stored on documents. This - is stored as a JSON object in the document's doc_metadata field. - -4. Built-in Fields: System-defined metadata fields that are automatically - available when enabled (document_name, uploader, upload_date, etc.). - -5. Lock Management: Redis-based locking to prevent concurrent metadata - operations that could cause data corruption. - -================================================================================ -TESTING STRATEGY -================================================================================ - -This test suite follows a comprehensive testing strategy that covers: - -1. CRUD Operations: - - Creating metadata fields with validation - - Reading/retrieving metadata fields - - Updating metadata field names - - Deleting metadata fields - -2. Built-in Field Management: - - Enabling built-in fields - - Disabling built-in fields - - Getting built-in field definitions - -3. Document Metadata Operations: - - Updating document metadata (partial and full) - - Managing metadata bindings - - Handling built-in field updates - -4. Lock Management: - - Acquiring locks for dataset operations - - Acquiring locks for document operations - - Handling lock conflicts - -5. Error Handling: - - Validation errors (name length, duplicates) - - Not found errors - - Lock conflict errors - -================================================================================ -""" - -from unittest.mock import Mock, patch - -import pytest - -from core.rag.index_processor.constant.built_in_field import BuiltInField -from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding -from services.entities.knowledge_entities.knowledge_entities import ( - MetadataArgs, - MetadataValue, -) -from services.metadata_service import MetadataService - -# ============================================================================ -# Test Data Factory -# ============================================================================ -# The Test Data Factory pattern is used here to centralize the creation of -# test objects and mock instances. This approach provides several benefits: -# -# 1. Consistency: All test objects are created using the same factory methods, -# ensuring consistent structure across all tests. -# -# 2. Maintainability: If the structure of models changes, we only need to -# update the factory methods rather than every individual test. -# -# 3. Reusability: Factory methods can be reused across multiple test classes, -# reducing code duplication. -# -# 4. Readability: Tests become more readable when they use descriptive factory -# method calls instead of complex object construction logic. -# -# ============================================================================ - - -class MetadataTestDataFactory: - """ - Factory class for creating test data and mock objects for metadata service tests. - - This factory provides static methods to create mock objects for: - - DatasetMetadata instances - - DatasetMetadataBinding instances - - Dataset instances - - Document instances - - MetadataArgs and MetadataOperationData entities - - User and tenant context - - The factory methods help maintain consistency across tests and reduce - code duplication when setting up test scenarios. - """ - - @staticmethod - def create_metadata_mock( - metadata_id: str = "metadata-123", - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - name: str = "category", - metadata_type: str = "string", - created_by: str = "user-123", - **kwargs, - ) -> Mock: - """ - Create a mock DatasetMetadata with specified attributes. - - Args: - metadata_id: Unique identifier for the metadata field - dataset_id: ID of the dataset this metadata belongs to - tenant_id: Tenant identifier - name: Name of the metadata field - metadata_type: Type of metadata (string, number, date, etc.) - created_by: ID of the user who created the metadata - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a DatasetMetadata instance - """ - metadata = Mock(spec=DatasetMetadata) - metadata.id = metadata_id - metadata.dataset_id = dataset_id - metadata.tenant_id = tenant_id - metadata.name = name - metadata.type = metadata_type - metadata.created_by = created_by - metadata.updated_by = None - metadata.updated_at = None - for key, value in kwargs.items(): - setattr(metadata, key, value) - return metadata - - @staticmethod - def create_metadata_binding_mock( - binding_id: str = "binding-123", - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - metadata_id: str = "metadata-123", - document_id: str = "document-123", - created_by: str = "user-123", - **kwargs, - ) -> Mock: - """ - Create a mock DatasetMetadataBinding with specified attributes. - - Args: - binding_id: Unique identifier for the binding - dataset_id: ID of the dataset - tenant_id: Tenant identifier - metadata_id: ID of the metadata field - document_id: ID of the document - created_by: ID of the user who created the binding - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a DatasetMetadataBinding instance - """ - binding = Mock(spec=DatasetMetadataBinding) - binding.id = binding_id - binding.dataset_id = dataset_id - binding.tenant_id = tenant_id - binding.metadata_id = metadata_id - binding.document_id = document_id - binding.created_by = created_by - for key, value in kwargs.items(): - setattr(binding, key, value) - return binding - - @staticmethod - def create_dataset_mock( - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - built_in_field_enabled: bool = False, - doc_metadata: list | None = None, - **kwargs, - ) -> Mock: - """ - Create a mock Dataset with specified attributes. - - Args: - dataset_id: Unique identifier for the dataset - tenant_id: Tenant identifier - built_in_field_enabled: Whether built-in fields are enabled - doc_metadata: List of metadata field definitions - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a Dataset instance - """ - dataset = Mock(spec=Dataset) - dataset.id = dataset_id - dataset.tenant_id = tenant_id - dataset.built_in_field_enabled = built_in_field_enabled - dataset.doc_metadata = doc_metadata or [] - for key, value in kwargs.items(): - setattr(dataset, key, value) - return dataset - - @staticmethod - def create_document_mock( - document_id: str = "document-123", - dataset_id: str = "dataset-123", - name: str = "Test Document", - doc_metadata: dict | None = None, - uploader: str = "user-123", - data_source_type: str = "upload_file", - **kwargs, - ) -> Mock: - """ - Create a mock Document with specified attributes. - - Args: - document_id: Unique identifier for the document - dataset_id: ID of the dataset this document belongs to - name: Name of the document - doc_metadata: Dictionary of metadata values - uploader: ID of the user who uploaded the document - data_source_type: Type of data source - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a Document instance - """ - document = Mock() - document.id = document_id - document.dataset_id = dataset_id - document.name = name - document.doc_metadata = doc_metadata or {} - document.uploader = uploader - document.data_source_type = data_source_type - - # Mock datetime objects for upload_date and last_update_date - - document.upload_date = Mock() - document.upload_date.timestamp.return_value = 1234567890.0 - document.last_update_date = Mock() - document.last_update_date.timestamp.return_value = 1234567890.0 - - for key, value in kwargs.items(): - setattr(document, key, value) - return document - - @staticmethod - def create_metadata_args_mock( - name: str = "category", - metadata_type: str = "string", - ) -> Mock: - """ - Create a mock MetadataArgs entity. - - Args: - name: Name of the metadata field - metadata_type: Type of metadata - - Returns: - Mock object configured as a MetadataArgs instance - """ - metadata_args = Mock(spec=MetadataArgs) - metadata_args.name = name - metadata_args.type = metadata_type - return metadata_args - - @staticmethod - def create_metadata_value_mock( - metadata_id: str = "metadata-123", - name: str = "category", - value: str = "test", - ) -> Mock: - """ - Create a mock MetadataValue entity. - - Args: - metadata_id: ID of the metadata field - name: Name of the metadata field - value: Value of the metadata - - Returns: - Mock object configured as a MetadataValue instance - """ - metadata_value = Mock(spec=MetadataValue) - metadata_value.id = metadata_id - metadata_value.name = name - metadata_value.value = value - return metadata_value - - -# ============================================================================ -# Tests for create_metadata -# ============================================================================ - - -class TestMetadataServiceCreateMetadata: - """ - Comprehensive unit tests for MetadataService.create_metadata method. - - This test class covers the metadata field creation functionality, - including validation, duplicate checking, and database operations. - - The create_metadata method: - 1. Validates metadata name length (max 255 characters) - 2. Checks for duplicate metadata names within the dataset - 3. Checks for conflicts with built-in field names - 4. Creates a new DatasetMetadata instance - 5. Adds it to the database session and commits - 6. Returns the created metadata - - Test scenarios include: - - Successful creation with valid data - - Name length validation - - Duplicate name detection - - Built-in field name conflicts - - Database transaction handling - """ - - @pytest.fixture - def mock_db_session(self): - """ - Mock database session for testing database operations. - - Provides a mocked database session that can be used to verify: - - Query construction and execution - - Add operations for new metadata - - Commit operations for transaction completion - """ - with patch("services.metadata_service.db.session") as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """ - Mock current user and tenant context. - - Provides mocked current_account_with_tenant function that returns - a user and tenant ID for testing authentication and authorization. - """ - with patch("services.metadata_service.current_account_with_tenant") as mock_get_user: - mock_user = Mock() - mock_user.id = "user-123" - mock_tenant_id = "tenant-123" - mock_get_user.return_value = (mock_user, mock_tenant_id) - yield mock_get_user - - def test_create_metadata_success(self, mock_db_session, mock_current_user): - """ - Test successful creation of a metadata field. - - Verifies that when all validation passes, a new metadata field - is created and persisted to the database. - - This test ensures: - - Metadata name validation passes - - No duplicate name exists - - No built-in field conflict - - New metadata is added to database - - Transaction is committed - - Created metadata is returned - """ - # Arrange - dataset_id = "dataset-123" - metadata_args = MetadataTestDataFactory.create_metadata_args_mock(name="category", metadata_type="string") - - # Mock query to return None (no existing metadata with same name) - mock_db_session.scalar.return_value = None - - # Mock BuiltInField enum iteration - with patch("services.metadata_service.BuiltInField") as mock_builtin: - mock_builtin.__iter__ = Mock(return_value=iter([])) - - # Act - result = MetadataService.create_metadata(dataset_id, metadata_args) - - # Assert - assert result is not None - assert isinstance(result, DatasetMetadata) - - # Verify metadata was added and committed - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() - - def test_create_metadata_name_too_long_error(self, mock_db_session, mock_current_user): - """ - Test error handling when metadata name exceeds 255 characters. - - Verifies that when a metadata name is longer than 255 characters, - a ValueError is raised with an appropriate message. - - This test ensures: - - Name length validation is enforced - - Error message is clear and descriptive - - No database operations are performed - """ - # Arrange - dataset_id = "dataset-123" - long_name = "a" * 256 # 256 characters (exceeds limit) - metadata_args = MetadataTestDataFactory.create_metadata_args_mock(name=long_name, metadata_type="string") - - # Act & Assert - with pytest.raises(ValueError, match="Metadata name cannot exceed 255 characters"): - MetadataService.create_metadata(dataset_id, metadata_args) - - # Verify no database operations were performed - mock_db_session.add.assert_not_called() - mock_db_session.commit.assert_not_called() - - def test_create_metadata_duplicate_name_error(self, mock_db_session, mock_current_user): - """ - Test error handling when metadata name already exists. - - Verifies that when a metadata field with the same name already exists - in the dataset, a ValueError is raised. - - This test ensures: - - Duplicate name detection works correctly - - Error message is clear - - No new metadata is created - """ - # Arrange - dataset_id = "dataset-123" - metadata_args = MetadataTestDataFactory.create_metadata_args_mock(name="category", metadata_type="string") - - # Mock existing metadata with same name - existing_metadata = MetadataTestDataFactory.create_metadata_mock(name="category") - mock_db_session.scalar.return_value = existing_metadata - - # Act & Assert - with pytest.raises(ValueError, match="Metadata name already exists"): - MetadataService.create_metadata(dataset_id, metadata_args) - - # Verify no new metadata was added - mock_db_session.add.assert_not_called() - mock_db_session.commit.assert_not_called() - - def test_create_metadata_builtin_field_conflict_error(self, mock_db_session, mock_current_user): - """ - Test error handling when metadata name conflicts with built-in field. - - Verifies that when a metadata name matches a built-in field name, - a ValueError is raised. - - This test ensures: - - Built-in field name conflicts are detected - - Error message is clear - - No new metadata is created - """ - # Arrange - dataset_id = "dataset-123" - metadata_args = MetadataTestDataFactory.create_metadata_args_mock( - name=BuiltInField.document_name, metadata_type="string" - ) - - # Mock query to return None (no duplicate in database) - mock_db_session.scalar.return_value = None - - # Mock BuiltInField to include the conflicting name - with patch("services.metadata_service.BuiltInField") as mock_builtin: - mock_field = Mock() - mock_field.value = BuiltInField.document_name - mock_builtin.__iter__ = Mock(return_value=iter([mock_field])) - - # Act & Assert - with pytest.raises(ValueError, match="Metadata name already exists in Built-in fields"): - MetadataService.create_metadata(dataset_id, metadata_args) - - # Verify no new metadata was added - mock_db_session.add.assert_not_called() - mock_db_session.commit.assert_not_called() - - -# ============================================================================ -# Tests for update_metadata_name -# ============================================================================ - - -class TestMetadataServiceUpdateMetadataName: - """ - Comprehensive unit tests for MetadataService.update_metadata_name method. - - This test class covers the metadata field name update functionality, - including validation, duplicate checking, and document metadata updates. - - The update_metadata_name method: - 1. Validates new name length (max 255 characters) - 2. Checks for duplicate names - 3. Checks for built-in field conflicts - 4. Acquires a lock for the dataset - 5. Updates the metadata name - 6. Updates all related document metadata - 7. Releases the lock - 8. Returns the updated metadata - - Test scenarios include: - - Successful name update - - Name length validation - - Duplicate name detection - - Built-in field conflicts - - Lock management - - Document metadata updates - """ - - @pytest.fixture - def mock_db_session(self): - """Mock database session for testing.""" - with patch("services.metadata_service.db.session") as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current user and tenant context.""" - with patch("services.metadata_service.current_account_with_tenant") as mock_get_user: - mock_user = Mock() - mock_user.id = "user-123" - mock_tenant_id = "tenant-123" - mock_get_user.return_value = (mock_user, mock_tenant_id) - yield mock_get_user - - @pytest.fixture - def mock_redis_client(self): - """Mock Redis client for lock management.""" - with patch("services.metadata_service.redis_client") as mock_redis: - mock_redis.get.return_value = None # No existing lock - mock_redis.set.return_value = True - mock_redis.delete.return_value = True - yield mock_redis - - def test_update_metadata_name_success(self, mock_db_session, mock_current_user, mock_redis_client): - """ - Test successful update of metadata field name. - - Verifies that when all validation passes, the metadata name is - updated and all related document metadata is updated accordingly. - - This test ensures: - - Name validation passes - - Lock is acquired and released - - Metadata name is updated - - Related document metadata is updated - - Transaction is committed - """ - # Arrange - dataset_id = "dataset-123" - metadata_id = "metadata-123" - new_name = "updated_category" - - existing_metadata = MetadataTestDataFactory.create_metadata_mock(metadata_id=metadata_id, name="category") - - # Mock scalar calls: first for duplicate check (None), second for metadata retrieval - mock_db_session.scalar.side_effect = [None, existing_metadata] - - # Mock no metadata bindings (no documents to update) - mock_db_session.scalars.return_value.all.return_value = [] - - # Mock BuiltInField enum - with patch("services.metadata_service.BuiltInField") as mock_builtin: - mock_builtin.__iter__ = Mock(return_value=iter([])) - - # Act - result = MetadataService.update_metadata_name(dataset_id, metadata_id, new_name) - - # Assert - assert result is not None - assert result.name == new_name - - # Verify lock was acquired and released - mock_redis_client.get.assert_called() - mock_redis_client.set.assert_called() - mock_redis_client.delete.assert_called() - - # Verify metadata was updated and committed - mock_db_session.commit.assert_called() - - def test_update_metadata_name_not_found_error(self, mock_db_session, mock_current_user, mock_redis_client): - """ - Test error handling when metadata is not found. - - Verifies that when the metadata ID doesn't exist, a ValueError - is raised with an appropriate message. - - This test ensures: - - Not found error is handled correctly - - Lock is properly released even on error - - No updates are committed - """ - # Arrange - dataset_id = "dataset-123" - metadata_id = "non-existent-metadata" - new_name = "updated_category" - - # Mock scalar calls: first for duplicate check (None), second for metadata retrieval (None = not found) - mock_db_session.scalar.side_effect = [None, None] - - # Mock BuiltInField enum - with patch("services.metadata_service.BuiltInField") as mock_builtin: - mock_builtin.__iter__ = Mock(return_value=iter([])) - - # Act & Assert - with pytest.raises(ValueError, match="Metadata not found"): - MetadataService.update_metadata_name(dataset_id, metadata_id, new_name) - - # Verify lock was released - mock_redis_client.delete.assert_called() - - -# ============================================================================ -# Tests for delete_metadata -# ============================================================================ - - -class TestMetadataServiceDeleteMetadata: - """ - Comprehensive unit tests for MetadataService.delete_metadata method. - - This test class covers the metadata field deletion functionality, - including document metadata cleanup and lock management. - - The delete_metadata method: - 1. Acquires a lock for the dataset - 2. Retrieves the metadata to delete - 3. Deletes the metadata from the database - 4. Removes metadata from all related documents - 5. Releases the lock - 6. Returns the deleted metadata - - Test scenarios include: - - Successful deletion - - Not found error handling - - Document metadata cleanup - - Lock management - """ - - @pytest.fixture - def mock_db_session(self): - """Mock database session for testing.""" - with patch("services.metadata_service.db.session") as mock_db: - yield mock_db - - @pytest.fixture - def mock_redis_client(self): - """Mock Redis client for lock management.""" - with patch("services.metadata_service.redis_client") as mock_redis: - mock_redis.get.return_value = None - mock_redis.set.return_value = True - mock_redis.delete.return_value = True - yield mock_redis - - def test_delete_metadata_success(self, mock_db_session, mock_redis_client): - """ - Test successful deletion of a metadata field. - - Verifies that when the metadata exists, it is deleted and all - related document metadata is cleaned up. - - This test ensures: - - Lock is acquired and released - - Metadata is deleted from database - - Related document metadata is removed - - Transaction is committed - """ - # Arrange - dataset_id = "dataset-123" - metadata_id = "metadata-123" - - existing_metadata = MetadataTestDataFactory.create_metadata_mock(metadata_id=metadata_id, name="category") - - # Mock metadata retrieval - mock_db_session.scalar.return_value = existing_metadata - - # Mock no metadata bindings (no documents to update) - mock_db_session.scalars.return_value.all.return_value = [] - - # Act - result = MetadataService.delete_metadata(dataset_id, metadata_id) - - # Assert - assert result == existing_metadata - - # Verify lock was acquired and released - mock_redis_client.get.assert_called() - mock_redis_client.set.assert_called() - mock_redis_client.delete.assert_called() - - # Verify metadata was deleted and committed - mock_db_session.delete.assert_called_once_with(existing_metadata) - mock_db_session.commit.assert_called() - - def test_delete_metadata_not_found_error(self, mock_db_session, mock_redis_client): - """ - Test error handling when metadata is not found. - - Verifies that when the metadata ID doesn't exist, a ValueError - is raised and the lock is properly released. - - This test ensures: - - Not found error is handled correctly - - Lock is released even on error - - No deletion is performed - """ - # Arrange - dataset_id = "dataset-123" - metadata_id = "non-existent-metadata" - - # Mock metadata retrieval to return None - mock_db_session.scalar.return_value = None - - # Act & Assert - with pytest.raises(ValueError, match="Metadata not found"): - MetadataService.delete_metadata(dataset_id, metadata_id) - - # Verify lock was released - mock_redis_client.delete.assert_called() - - # Verify no deletion was performed - mock_db_session.delete.assert_not_called() - - -# ============================================================================ -# Tests for get_built_in_fields -# ============================================================================ - - -class TestMetadataServiceGetBuiltInFields: - """ - Comprehensive unit tests for MetadataService.get_built_in_fields method. - - This test class covers the built-in field retrieval functionality. - - The get_built_in_fields method: - 1. Returns a list of built-in field definitions - 2. Each definition includes name and type - - Test scenarios include: - - Successful retrieval of built-in fields - - Correct field definitions - """ - - def test_get_built_in_fields_success(self): - """ - Test successful retrieval of built-in fields. - - Verifies that the method returns the correct list of built-in - field definitions with proper structure. - - This test ensures: - - All built-in fields are returned - - Each field has name and type - - Field definitions are correct - """ - # Act - result = MetadataService.get_built_in_fields() - - # Assert - assert isinstance(result, list) - assert len(result) > 0 - - # Verify each field has required properties - for field in result: - assert "name" in field - assert "type" in field - assert isinstance(field["name"], str) - assert isinstance(field["type"], str) - - # Verify specific built-in fields are present - field_names = [field["name"] for field in result] - assert BuiltInField.document_name in field_names - assert BuiltInField.uploader in field_names - - -# ============================================================================ -# Tests for knowledge_base_metadata_lock_check -# ============================================================================ - - -class TestMetadataServiceLockCheck: - """ - Comprehensive unit tests for MetadataService.knowledge_base_metadata_lock_check method. - - This test class covers the lock management functionality for preventing - concurrent metadata operations. - - The knowledge_base_metadata_lock_check method: - 1. Checks if a lock exists for the dataset or document - 2. Raises ValueError if lock exists (operation in progress) - 3. Sets a lock with expiration time (3600 seconds) - 4. Supports both dataset-level and document-level locks - - Test scenarios include: - - Successful lock acquisition - - Lock conflict detection - - Dataset-level locks - - Document-level locks - """ - - @pytest.fixture - def mock_redis_client(self): - """Mock Redis client for lock management.""" - with patch("services.metadata_service.redis_client") as mock_redis: - yield mock_redis - - def test_lock_check_dataset_success(self, mock_redis_client): - """ - Test successful lock acquisition for dataset operations. - - Verifies that when no lock exists, a new lock is acquired - for the dataset. - - This test ensures: - - Lock check passes when no lock exists - - Lock is set with correct key and expiration - - No error is raised - """ - # Arrange - dataset_id = "dataset-123" - mock_redis_client.get.return_value = None # No existing lock - - # Act (should not raise) - MetadataService.knowledge_base_metadata_lock_check(dataset_id, None) - - # Assert - mock_redis_client.get.assert_called_once_with(f"dataset_metadata_lock_{dataset_id}") - mock_redis_client.set.assert_called_once_with(f"dataset_metadata_lock_{dataset_id}", 1, ex=3600) - - def test_lock_check_dataset_conflict_error(self, mock_redis_client): - """ - Test error handling when dataset lock already exists. - - Verifies that when a lock exists for the dataset, a ValueError - is raised with an appropriate message. - - This test ensures: - - Lock conflict is detected - - Error message is clear - - No new lock is set - """ - # Arrange - dataset_id = "dataset-123" - mock_redis_client.get.return_value = "1" # Lock exists - - # Act & Assert - with pytest.raises(ValueError, match="Another knowledge base metadata operation is running"): - MetadataService.knowledge_base_metadata_lock_check(dataset_id, None) - - # Verify lock was checked but not set - mock_redis_client.get.assert_called_once() - mock_redis_client.set.assert_not_called() - - def test_lock_check_document_success(self, mock_redis_client): - """ - Test successful lock acquisition for document operations. - - Verifies that when no lock exists, a new lock is acquired - for the document. - - This test ensures: - - Lock check passes when no lock exists - - Lock is set with correct key and expiration - - No error is raised - """ - # Arrange - document_id = "document-123" - mock_redis_client.get.return_value = None # No existing lock - - # Act (should not raise) - MetadataService.knowledge_base_metadata_lock_check(None, document_id) - - # Assert - mock_redis_client.get.assert_called_once_with(f"document_metadata_lock_{document_id}") - mock_redis_client.set.assert_called_once_with(f"document_metadata_lock_{document_id}", 1, ex=3600) - - -# ============================================================================ -# Tests for get_dataset_metadatas -# ============================================================================ - - -class TestMetadataServiceGetDatasetMetadatas: - """ - Comprehensive unit tests for MetadataService.get_dataset_metadatas method. - - This test class covers the metadata retrieval functionality for datasets. - - The get_dataset_metadatas method: - 1. Retrieves all metadata fields for a dataset - 2. Excludes built-in fields from the list - 3. Includes usage count for each metadata field - 4. Returns built-in field enabled status - - Test scenarios include: - - Successful retrieval with metadata fields - - Empty metadata list - - Built-in field filtering - - Usage count calculation - """ - - @pytest.fixture - def mock_db_session(self): - """Mock database session for testing.""" - with patch("services.metadata_service.db.session") as mock_db: - yield mock_db - - def test_get_dataset_metadatas_success(self, mock_db_session): - """ - Test successful retrieval of dataset metadata fields. - - Verifies that all metadata fields are returned with correct - structure and usage counts. - - This test ensures: - - All metadata fields are included - - Built-in fields are excluded - - Usage counts are calculated correctly - - Built-in field status is included - """ - # Arrange - dataset = MetadataTestDataFactory.create_dataset_mock( - dataset_id="dataset-123", - built_in_field_enabled=True, - doc_metadata=[ - {"id": "metadata-1", "name": "category", "type": "string"}, - {"id": "metadata-2", "name": "priority", "type": "number"}, - {"id": "built-in", "name": "document_name", "type": "string"}, - ], - ) - - # Mock usage count queries - mock_db_session.scalar.return_value = 5 # 5 documents use this metadata - - # Act - result = MetadataService.get_dataset_metadatas(dataset) - - # Assert - assert "doc_metadata" in result - assert "built_in_field_enabled" in result - assert result["built_in_field_enabled"] is True - - # Verify built-in fields are excluded - metadata_ids = [meta["id"] for meta in result["doc_metadata"]] - assert "built-in" not in metadata_ids - - # Verify all custom metadata fields are included - assert len(result["doc_metadata"]) == 2 - - # Verify usage counts are included - for meta in result["doc_metadata"]: - assert "count" in meta - assert meta["count"] == 5 - - -# ============================================================================ -# Additional Documentation and Notes -# ============================================================================ -# -# This test suite covers the core metadata CRUD operations and basic -# filtering functionality. Additional test scenarios that could be added: -# -# 1. enable_built_in_field / disable_built_in_field: -# - Testing built-in field enablement -# - Testing built-in field disablement -# - Testing document metadata updates when enabling/disabling -# -# 2. update_documents_metadata: -# - Testing partial updates -# - Testing full updates -# - Testing metadata binding creation -# - Testing built-in field updates -# -# 3. Metadata Filtering and Querying: -# - Testing metadata-based document filtering -# - Testing complex metadata queries -# - Testing metadata value retrieval -# -# These scenarios are not currently implemented but could be added if needed -# based on real-world usage patterns or discovered edge cases. -# -# ============================================================================ diff --git a/api/tests/unit_tests/services/dataset_permission_service.py b/api/tests/unit_tests/services/dataset_permission_service.py deleted file mode 100644 index e098e90455..0000000000 --- a/api/tests/unit_tests/services/dataset_permission_service.py +++ /dev/null @@ -1,825 +0,0 @@ -""" -Comprehensive unit tests for DatasetPermissionService and DatasetService permission methods. - -This module contains extensive unit tests for dataset permission management, -including partial member list operations, permission validation, and permission -enum handling. - -The DatasetPermissionService provides methods for: -- Retrieving partial member permissions (get_dataset_partial_member_list) -- Updating partial member lists (update_partial_member_list) -- Validating permissions before operations (check_permission) -- Clearing partial member lists (clear_partial_member_list) - -The DatasetService provides permission checking methods: -- check_dataset_permission - validates user access to dataset -- check_dataset_operator_permission - validates operator permissions - -These operations are critical for dataset access control and security, ensuring -that users can only access datasets they have permission to view or modify. - -This test suite ensures: -- Correct retrieval of partial member lists -- Proper update of partial member permissions -- Accurate permission validation logic -- Proper handling of permission enums (only_me, all_team_members, partial_members) -- Security boundaries are maintained -- Error conditions are handled correctly - -================================================================================ -ARCHITECTURE OVERVIEW -================================================================================ - -The Dataset permission system is a multi-layered access control mechanism -that provides fine-grained control over who can access and modify datasets. - -1. Permission Levels: - - only_me: Only the dataset creator can access - - all_team_members: All members of the tenant can access - - partial_members: Only specific users listed in DatasetPermission can access - -2. Permission Storage: - - Dataset.permission: Stores the permission level enum - - DatasetPermission: Stores individual user permissions for partial_members - - Each DatasetPermission record links a dataset to a user account - -3. Permission Validation: - - Tenant-level checks: Users must be in the same tenant - - Role-based checks: OWNER role bypasses some restrictions - - Explicit permission checks: For partial_members, explicit DatasetPermission - records are required - -4. Permission Operations: - - Partial member list management: Add/remove users from partial access - - Permission validation: Check before allowing operations - - Permission clearing: Remove all partial members when changing permission level - -================================================================================ -TESTING STRATEGY -================================================================================ - -This test suite follows a comprehensive testing strategy that covers: - -1. Partial Member List Operations: - - Retrieving member lists - - Adding new members - - Updating existing members - - Removing members - - Empty list handling - -2. Permission Validation: - - Dataset editor permissions - - Dataset operator restrictions - - Permission enum validation - - Partial member list validation - - Tenant isolation - -3. Permission Enum Handling: - - only_me permission behavior - - all_team_members permission behavior - - partial_members permission behavior - - Permission transitions - - Edge cases for each enum value - -4. Security and Access Control: - - Tenant boundary enforcement - - Role-based access control - - Creator privilege validation - - Explicit permission requirement - -5. Error Handling: - - Invalid permission changes - - Missing required data - - Database transaction failures - - Permission denial scenarios - -================================================================================ -""" - -from unittest.mock import Mock, create_autospec, patch - -import pytest - -from models import Account, TenantAccountRole -from models.dataset import ( - Dataset, - DatasetPermission, - DatasetPermissionEnum, -) -from services.dataset_service import DatasetPermissionService, DatasetService -from services.errors.account import NoPermissionError - -# ============================================================================ -# Test Data Factory -# ============================================================================ -# The Test Data Factory pattern is used here to centralize the creation of -# test objects and mock instances. This approach provides several benefits: -# -# 1. Consistency: All test objects are created using the same factory methods, -# ensuring consistent structure across all tests. -# -# 2. Maintainability: If the structure of models or services changes, we only -# need to update the factory methods rather than every individual test. -# -# 3. Reusability: Factory methods can be reused across multiple test classes, -# reducing code duplication. -# -# 4. Readability: Tests become more readable when they use descriptive factory -# method calls instead of complex object construction logic. -# -# ============================================================================ - - -class DatasetPermissionTestDataFactory: - """ - Factory class for creating test data and mock objects for dataset permission tests. - - This factory provides static methods to create mock objects for: - - Dataset instances with various permission configurations - - User/Account instances with different roles and permissions - - DatasetPermission instances - - Permission enum values - - Database query results - - The factory methods help maintain consistency across tests and reduce - code duplication when setting up test scenarios. - """ - - @staticmethod - def create_dataset_mock( - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME, - created_by: str = "user-123", - name: str = "Test Dataset", - **kwargs, - ) -> Mock: - """ - Create a mock Dataset with specified attributes. - - Args: - dataset_id: Unique identifier for the dataset - tenant_id: Tenant identifier - permission: Permission level enum - created_by: ID of user who created the dataset - name: Dataset name - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a Dataset instance - """ - dataset = Mock(spec=Dataset) - dataset.id = dataset_id - dataset.tenant_id = tenant_id - dataset.permission = permission - dataset.created_by = created_by - dataset.name = name - for key, value in kwargs.items(): - setattr(dataset, key, value) - return dataset - - @staticmethod - def create_user_mock( - user_id: str = "user-123", - tenant_id: str = "tenant-123", - role: TenantAccountRole = TenantAccountRole.NORMAL, - is_dataset_editor: bool = True, - is_dataset_operator: bool = False, - **kwargs, - ) -> Mock: - """ - Create a mock user (Account) with specified attributes. - - Args: - user_id: Unique identifier for the user - tenant_id: Tenant identifier - role: User role (OWNER, ADMIN, NORMAL, DATASET_OPERATOR, etc.) - is_dataset_editor: Whether user has dataset editor permissions - is_dataset_operator: Whether user is a dataset operator - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as an Account instance - """ - user = create_autospec(Account, instance=True) - user.id = user_id - user.current_tenant_id = tenant_id - user.current_role = role - user.is_dataset_editor = is_dataset_editor - user.is_dataset_operator = is_dataset_operator - for key, value in kwargs.items(): - setattr(user, key, value) - return user - - @staticmethod - def create_dataset_permission_mock( - permission_id: str = "permission-123", - dataset_id: str = "dataset-123", - account_id: str = "user-456", - tenant_id: str = "tenant-123", - has_permission: bool = True, - **kwargs, - ) -> Mock: - """ - Create a mock DatasetPermission instance. - - Args: - permission_id: Unique identifier for the permission - dataset_id: Dataset ID - account_id: User account ID - tenant_id: Tenant identifier - has_permission: Whether permission is granted - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a DatasetPermission instance - """ - permission = Mock(spec=DatasetPermission) - permission.id = permission_id - permission.dataset_id = dataset_id - permission.account_id = account_id - permission.tenant_id = tenant_id - permission.has_permission = has_permission - for key, value in kwargs.items(): - setattr(permission, key, value) - return permission - - @staticmethod - def create_user_list_mock(user_ids: list[str]) -> list[dict[str, str]]: - """ - Create a list of user dictionaries for partial member list operations. - - Args: - user_ids: List of user IDs to include - - Returns: - List of user dictionaries with "user_id" keys - """ - return [{"user_id": user_id} for user_id in user_ids] - - -# ============================================================================ -# Tests for check_permission -# ============================================================================ - - -class TestDatasetPermissionServiceCheckPermission: - """ - Comprehensive unit tests for DatasetPermissionService.check_permission method. - - This test class covers the permission validation logic that ensures - users have the appropriate permissions to modify dataset permissions. - - The check_permission method: - 1. Validates user is a dataset editor - 2. Checks if dataset operator is trying to change permissions - 3. Validates partial member list when setting to partial_members - 4. Ensures dataset operators cannot change permission levels - 5. Ensures dataset operators cannot modify partial member lists - - Test scenarios include: - - Valid permission changes by dataset editors - - Dataset operator restrictions - - Partial member list validation - - Missing dataset editor permissions - - Invalid permission changes - """ - - @pytest.fixture - def mock_get_partial_member_list(self): - """ - Mock get_dataset_partial_member_list method. - - Provides a mocked version of the get_dataset_partial_member_list - method for testing permission validation logic. - """ - with patch.object(DatasetPermissionService, "get_dataset_partial_member_list") as mock_get_list: - yield mock_get_list - - def test_check_permission_dataset_editor_success(self, mock_get_partial_member_list): - """ - Test successful permission check for dataset editor. - - Verifies that when a dataset editor (not operator) tries to - change permissions, the check passes. - - This test ensures: - - Dataset editors can change permissions - - No errors are raised for valid changes - - Partial member list validation is skipped for non-operators - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(is_dataset_editor=True, is_dataset_operator=False) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.ONLY_ME) - requested_permission = DatasetPermissionEnum.ALL_TEAM - requested_partial_member_list = None - - # Act (should not raise) - DatasetPermissionService.check_permission(user, dataset, requested_permission, requested_partial_member_list) - - # Assert - # Verify get_partial_member_list was not called (not needed for non-operators) - mock_get_partial_member_list.assert_not_called() - - def test_check_permission_not_dataset_editor_error(self): - """ - Test error when user is not a dataset editor. - - Verifies that when a user without dataset editor permissions - tries to change permissions, a NoPermissionError is raised. - - This test ensures: - - Non-editors cannot change permissions - - Error message is clear - - Error type is correct - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(is_dataset_editor=False) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock() - requested_permission = DatasetPermissionEnum.ALL_TEAM - requested_partial_member_list = None - - # Act & Assert - with pytest.raises(NoPermissionError, match="User does not have permission to edit this dataset"): - DatasetPermissionService.check_permission( - user, dataset, requested_permission, requested_partial_member_list - ) - - def test_check_permission_operator_cannot_change_permission_error(self): - """ - Test error when dataset operator tries to change permission level. - - Verifies that when a dataset operator tries to change the permission - level, a NoPermissionError is raised. - - This test ensures: - - Dataset operators cannot change permission levels - - Error message is clear - - Current permission is preserved - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(is_dataset_editor=True, is_dataset_operator=True) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.ONLY_ME) - requested_permission = DatasetPermissionEnum.ALL_TEAM # Trying to change - requested_partial_member_list = None - - # Act & Assert - with pytest.raises(NoPermissionError, match="Dataset operators cannot change the dataset permissions"): - DatasetPermissionService.check_permission( - user, dataset, requested_permission, requested_partial_member_list - ) - - def test_check_permission_operator_partial_members_missing_list_error(self, mock_get_partial_member_list): - """ - Test error when operator sets partial_members without providing list. - - Verifies that when a dataset operator tries to set permission to - partial_members without providing a member list, a ValueError is raised. - - This test ensures: - - Partial member list is required for partial_members permission - - Error message is clear - - Error type is correct - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(is_dataset_editor=True, is_dataset_operator=True) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM) - requested_permission = "partial_members" - requested_partial_member_list = None # Missing list - - # Act & Assert - with pytest.raises(ValueError, match="Partial member list is required when setting to partial members"): - DatasetPermissionService.check_permission( - user, dataset, requested_permission, requested_partial_member_list - ) - - def test_check_permission_operator_cannot_modify_partial_list_error(self, mock_get_partial_member_list): - """ - Test error when operator tries to modify partial member list. - - Verifies that when a dataset operator tries to change the partial - member list, a ValueError is raised. - - This test ensures: - - Dataset operators cannot modify partial member lists - - Error message is clear - - Current member list is preserved - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(is_dataset_editor=True, is_dataset_operator=True) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM) - requested_permission = "partial_members" - - # Current member list - current_member_list = ["user-456", "user-789"] - mock_get_partial_member_list.return_value = current_member_list - - # Requested member list (different from current) - requested_partial_member_list = DatasetPermissionTestDataFactory.create_user_list_mock( - ["user-456", "user-999"] # Different list - ) - - # Act & Assert - with pytest.raises(ValueError, match="Dataset operators cannot change the dataset permissions"): - DatasetPermissionService.check_permission( - user, dataset, requested_permission, requested_partial_member_list - ) - - def test_check_permission_operator_can_keep_same_partial_list(self, mock_get_partial_member_list): - """ - Test that operator can keep the same partial member list. - - Verifies that when a dataset operator keeps the same partial member - list, the check passes. - - This test ensures: - - Operators can keep existing partial member lists - - No errors are raised for unchanged lists - - Permission validation works correctly - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(is_dataset_editor=True, is_dataset_operator=True) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM) - requested_permission = "partial_members" - - # Current member list - current_member_list = ["user-456", "user-789"] - mock_get_partial_member_list.return_value = current_member_list - - # Requested member list (same as current) - requested_partial_member_list = DatasetPermissionTestDataFactory.create_user_list_mock( - ["user-456", "user-789"] # Same list - ) - - # Act (should not raise) - DatasetPermissionService.check_permission(user, dataset, requested_permission, requested_partial_member_list) - - # Assert - # Verify get_partial_member_list was called to compare lists - mock_get_partial_member_list.assert_called_once_with(dataset.id) - - -# ============================================================================ -# Tests for DatasetService.check_dataset_permission -# ============================================================================ - - -class TestDatasetServiceCheckDatasetPermission: - """ - Comprehensive unit tests for DatasetService.check_dataset_permission method. - - This test class covers the dataset permission checking logic that validates - whether a user has access to a dataset based on permission enums. - - The check_dataset_permission method: - 1. Validates tenant match - 2. Checks OWNER role (bypasses some restrictions) - 3. Validates only_me permission (creator only) - 4. Validates partial_members permission (explicit permission required) - 5. Validates all_team_members permission (all tenant members) - - Test scenarios include: - - Tenant boundary enforcement - - OWNER role bypass - - only_me permission validation - - partial_members permission validation - - all_team_members permission validation - - Permission denial scenarios - """ - - @pytest.fixture - def mock_db_session(self): - """ - Mock database session for testing. - - Provides a mocked database session that can be used to verify - database queries for permission checks. - """ - with patch("services.dataset_service.db.session") as mock_db: - yield mock_db - - def test_check_dataset_permission_owner_bypass(self, mock_db_session): - """ - Test that OWNER role bypasses permission checks. - - Verifies that when a user has OWNER role, they can access any - dataset in their tenant regardless of permission level. - - This test ensures: - - OWNER role bypasses permission restrictions - - No database queries are needed for OWNER - - Access is granted automatically - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(role=TenantAccountRole.OWNER, tenant_id="tenant-123") - dataset = DatasetPermissionTestDataFactory.create_dataset_mock( - tenant_id="tenant-123", - permission=DatasetPermissionEnum.ONLY_ME, - created_by="other-user-123", # Not the current user - ) - - # Act (should not raise) - DatasetService.check_dataset_permission(dataset, user) - - # Assert - # Verify no permission queries were made (OWNER bypasses) - mock_db_session.query.assert_not_called() - - def test_check_dataset_permission_tenant_mismatch_error(self): - """ - Test error when user and dataset are in different tenants. - - Verifies that when a user tries to access a dataset from a different - tenant, a NoPermissionError is raised. - - This test ensures: - - Tenant boundary is enforced - - Error message is clear - - Error type is correct - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(tenant_id="tenant-123") - dataset = DatasetPermissionTestDataFactory.create_dataset_mock(tenant_id="tenant-456") # Different tenant - - # Act & Assert - with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset"): - DatasetService.check_dataset_permission(dataset, user) - - def test_check_dataset_permission_only_me_creator_success(self): - """ - Test that creator can access only_me dataset. - - Verifies that when a user is the creator of an only_me dataset, - they can access it successfully. - - This test ensures: - - Creators can access their own only_me datasets - - No explicit permission record is needed - - Access is granted correctly - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(user_id="user-123", role=TenantAccountRole.NORMAL) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock( - tenant_id="tenant-123", - permission=DatasetPermissionEnum.ONLY_ME, - created_by="user-123", # User is the creator - ) - - # Act (should not raise) - DatasetService.check_dataset_permission(dataset, user) - - def test_check_dataset_permission_only_me_non_creator_error(self): - """ - Test error when non-creator tries to access only_me dataset. - - Verifies that when a user who is not the creator tries to access - an only_me dataset, a NoPermissionError is raised. - - This test ensures: - - Non-creators cannot access only_me datasets - - Error message is clear - - Error type is correct - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(user_id="user-123", role=TenantAccountRole.NORMAL) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock( - tenant_id="tenant-123", - permission=DatasetPermissionEnum.ONLY_ME, - created_by="other-user-456", # Different creator - ) - - # Act & Assert - with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset"): - DatasetService.check_dataset_permission(dataset, user) - - def test_check_dataset_permission_partial_members_creator_success(self, mock_db_session): - """ - Test that creator can access partial_members dataset without explicit permission. - - Verifies that when a user is the creator of a partial_members dataset, - they can access it even without an explicit DatasetPermission record. - - This test ensures: - - Creators can access their own datasets - - No explicit permission record is needed for creators - - Access is granted correctly - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(user_id="user-123", role=TenantAccountRole.NORMAL) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock( - tenant_id="tenant-123", - permission=DatasetPermissionEnum.PARTIAL_TEAM, - created_by="user-123", # User is the creator - ) - - # Act (should not raise) - DatasetService.check_dataset_permission(dataset, user) - - # Assert - # Verify permission query was not executed (creator bypasses) - mock_db_session.query.assert_not_called() - - def test_check_dataset_permission_all_team_members_success(self): - """ - Test that any tenant member can access all_team_members dataset. - - Verifies that when a dataset has all_team_members permission, any - user in the same tenant can access it. - - This test ensures: - - All team members can access - - No explicit permission record is needed - - Access is granted correctly - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(user_id="user-123", role=TenantAccountRole.NORMAL) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock( - tenant_id="tenant-123", - permission=DatasetPermissionEnum.ALL_TEAM, - created_by="other-user-456", # Not the creator - ) - - # Act (should not raise) - DatasetService.check_dataset_permission(dataset, user) - - -# ============================================================================ -# Tests for DatasetService.check_dataset_operator_permission -# ============================================================================ - - -class TestDatasetServiceCheckDatasetOperatorPermission: - """ - Comprehensive unit tests for DatasetService.check_dataset_operator_permission method. - - This test class covers the dataset operator permission checking logic, - which validates whether a dataset operator has access to a dataset. - - The check_dataset_operator_permission method: - 1. Validates dataset exists - 2. Validates user exists - 3. Checks OWNER role (bypasses restrictions) - 4. Validates only_me permission (creator only) - 5. Validates partial_members permission (explicit permission required) - - Test scenarios include: - - Dataset not found error - - User not found error - - OWNER role bypass - - only_me permission validation - - partial_members permission validation - - Permission denial scenarios - """ - - @pytest.fixture - def mock_db_session(self): - """ - Mock database session for testing. - - Provides a mocked database session that can be used to verify - database queries for permission checks. - """ - with patch("services.dataset_service.db.session") as mock_db: - yield mock_db - - def test_check_dataset_operator_permission_dataset_not_found_error(self): - """ - Test error when dataset is None. - - Verifies that when dataset is None, a ValueError is raised. - - This test ensures: - - Dataset existence is validated - - Error message is clear - - Error type is correct - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock() - dataset = None - - # Act & Assert - with pytest.raises(ValueError, match="Dataset not found"): - DatasetService.check_dataset_operator_permission(user=user, dataset=dataset) - - def test_check_dataset_operator_permission_user_not_found_error(self): - """ - Test error when user is None. - - Verifies that when user is None, a ValueError is raised. - - This test ensures: - - User existence is validated - - Error message is clear - - Error type is correct - """ - # Arrange - user = None - dataset = DatasetPermissionTestDataFactory.create_dataset_mock() - - # Act & Assert - with pytest.raises(ValueError, match="User not found"): - DatasetService.check_dataset_operator_permission(user=user, dataset=dataset) - - def test_check_dataset_operator_permission_owner_bypass(self): - """ - Test that OWNER role bypasses permission checks. - - Verifies that when a user has OWNER role, they can access any - dataset in their tenant regardless of permission level. - - This test ensures: - - OWNER role bypasses permission restrictions - - No database queries are needed for OWNER - - Access is granted automatically - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(role=TenantAccountRole.OWNER, tenant_id="tenant-123") - dataset = DatasetPermissionTestDataFactory.create_dataset_mock( - tenant_id="tenant-123", - permission=DatasetPermissionEnum.ONLY_ME, - created_by="other-user-123", # Not the current user - ) - - # Act (should not raise) - DatasetService.check_dataset_operator_permission(user=user, dataset=dataset) - - def test_check_dataset_operator_permission_only_me_creator_success(self): - """ - Test that creator can access only_me dataset. - - Verifies that when a user is the creator of an only_me dataset, - they can access it successfully. - - This test ensures: - - Creators can access their own only_me datasets - - No explicit permission record is needed - - Access is granted correctly - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(user_id="user-123", role=TenantAccountRole.NORMAL) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock( - tenant_id="tenant-123", - permission=DatasetPermissionEnum.ONLY_ME, - created_by="user-123", # User is the creator - ) - - # Act (should not raise) - DatasetService.check_dataset_operator_permission(user=user, dataset=dataset) - - def test_check_dataset_operator_permission_only_me_non_creator_error(self): - """ - Test error when non-creator tries to access only_me dataset. - - Verifies that when a user who is not the creator tries to access - an only_me dataset, a NoPermissionError is raised. - - This test ensures: - - Non-creators cannot access only_me datasets - - Error message is clear - - Error type is correct - """ - # Arrange - user = DatasetPermissionTestDataFactory.create_user_mock(user_id="user-123", role=TenantAccountRole.NORMAL) - dataset = DatasetPermissionTestDataFactory.create_dataset_mock( - tenant_id="tenant-123", - permission=DatasetPermissionEnum.ONLY_ME, - created_by="other-user-456", # Different creator - ) - - # Act & Assert - with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset"): - DatasetService.check_dataset_operator_permission(user=user, dataset=dataset) - - -# ============================================================================ -# Additional Documentation and Notes -# ============================================================================ -# -# This test suite covers the core permission management operations for datasets. -# Additional test scenarios that could be added: -# -# 1. Permission Enum Transitions: -# - Testing transitions between permission levels -# - Testing validation during transitions -# - Testing partial member list updates during transitions -# -# 2. Bulk Operations: -# - Testing bulk permission updates -# - Testing bulk partial member list updates -# - Testing performance with large member lists -# -# 3. Edge Cases: -# - Testing with very large partial member lists -# - Testing with special characters in user IDs -# - Testing with deleted users -# - Testing with inactive permissions -# -# 4. Integration Scenarios: -# - Testing permission changes followed by access attempts -# - Testing concurrent permission updates -# - Testing permission inheritance -# -# These scenarios are not currently implemented but could be added if needed -# based on real-world usage patterns or discovered edge cases. -# -# ============================================================================ diff --git a/api/tests/unit_tests/services/dataset_service_update_delete.py b/api/tests/unit_tests/services/dataset_service_update_delete.py deleted file mode 100644 index 62c39f96d3..0000000000 --- a/api/tests/unit_tests/services/dataset_service_update_delete.py +++ /dev/null @@ -1,818 +0,0 @@ -""" -Comprehensive unit tests for DatasetService update and delete operations. - -This module contains extensive unit tests for the DatasetService class, -specifically focusing on update and delete operations for datasets. - -The DatasetService provides methods for: -- Updating dataset configuration and settings (update_dataset) -- Deleting datasets with proper cleanup (delete_dataset) -- Updating RAG pipeline dataset settings (update_rag_pipeline_dataset_settings) -- Checking if dataset is in use (dataset_use_check) -- Updating dataset API access status (update_dataset_api_status) - -These operations are critical for dataset lifecycle management and require -careful handling of permissions, dependencies, and data integrity. - -This test suite ensures: -- Correct update of dataset properties -- Proper permission validation before updates/deletes -- Cascade deletion handling -- Event signaling for cleanup operations -- RAG pipeline dataset configuration updates -- API status management -- Use check validation - -================================================================================ -ARCHITECTURE OVERVIEW -================================================================================ - -The DatasetService update and delete operations are part of the dataset -lifecycle management system. These operations interact with multiple -components: - -1. Permission System: All update/delete operations require proper - permission validation to ensure users can only modify datasets they - have access to. - -2. Event System: Dataset deletion triggers the dataset_was_deleted event, - which notifies other components to clean up related data (documents, - segments, vector indices, etc.). - -3. Dependency Checking: Before deletion, the system checks if the dataset - is in use by any applications (via AppDatasetJoin). - -4. RAG Pipeline Integration: RAG pipeline datasets have special update - logic that handles chunk structure, indexing techniques, and embedding - model configuration. - -5. API Status Management: Datasets can have their API access enabled or - disabled, which affects whether they can be accessed via the API. - -================================================================================ -TESTING STRATEGY -================================================================================ - -This test suite follows a comprehensive testing strategy that covers: - -1. Update Operations: - - Internal dataset updates - - External dataset updates - - RAG pipeline dataset updates - - Permission validation - - Name duplicate checking - - Configuration validation - -2. Delete Operations: - - Successful deletion - - Permission validation - - Event signaling - - Database cleanup - - Not found handling - -3. Use Check Operations: - - Dataset in use detection - - Dataset not in use detection - - AppDatasetJoin query validation - -4. API Status Operations: - - Enable API access - - Disable API access - - Permission validation - - Current user validation - -5. RAG Pipeline Operations: - - Unpublished dataset updates - - Published dataset updates - - Chunk structure validation - - Indexing technique changes - - Embedding model configuration - -================================================================================ -""" - -import datetime -from unittest.mock import Mock, create_autospec, patch - -import pytest -from sqlalchemy.orm import Session - -from core.rag.index_processor.constant.index_type import IndexTechniqueType -from models import Account, TenantAccountRole -from models.dataset import ( - AppDatasetJoin, - Dataset, - DatasetPermissionEnum, -) -from services.dataset_service import DatasetService -from services.errors.account import NoPermissionError - -# ============================================================================ -# Test Data Factory -# ============================================================================ -# The Test Data Factory pattern is used here to centralize the creation of -# test objects and mock instances. This approach provides several benefits: -# -# 1. Consistency: All test objects are created using the same factory methods, -# ensuring consistent structure across all tests. -# -# 2. Maintainability: If the structure of models or services changes, we only -# need to update the factory methods rather than every individual test. -# -# 3. Reusability: Factory methods can be reused across multiple test classes, -# reducing code duplication. -# -# 4. Readability: Tests become more readable when they use descriptive factory -# method calls instead of complex object construction logic. -# -# ============================================================================ - - -class DatasetUpdateDeleteTestDataFactory: - """ - Factory class for creating test data and mock objects for dataset update/delete tests. - - This factory provides static methods to create mock objects for: - - Dataset instances with various configurations - - User/Account instances with different roles - - Knowledge configuration objects - - Database session mocks - - Event signal mocks - - The factory methods help maintain consistency across tests and reduce - code duplication when setting up test scenarios. - """ - - @staticmethod - def create_dataset_mock( - dataset_id: str = "dataset-123", - provider: str = "vendor", - name: str = "Test Dataset", - description: str = "Test description", - tenant_id: str = "tenant-123", - indexing_technique: str = IndexTechniqueType.HIGH_QUALITY, - embedding_model_provider: str | None = "openai", - embedding_model: str | None = "text-embedding-ada-002", - collection_binding_id: str | None = "binding-123", - enable_api: bool = True, - permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME, - created_by: str = "user-123", - chunk_structure: str | None = None, - runtime_mode: str = "general", - **kwargs, - ) -> Mock: - """ - Create a mock Dataset with specified attributes. - - Args: - dataset_id: Unique identifier for the dataset - provider: Dataset provider (vendor, external) - name: Dataset name - description: Dataset description - tenant_id: Tenant identifier - indexing_technique: Indexing technique (high_quality, economy) - embedding_model_provider: Embedding model provider - embedding_model: Embedding model name - collection_binding_id: Collection binding ID - enable_api: Whether API access is enabled - permission: Dataset permission level - created_by: ID of user who created the dataset - chunk_structure: Chunk structure for RAG pipeline datasets - runtime_mode: Runtime mode (general, rag_pipeline) - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a Dataset instance - """ - dataset = Mock(spec=Dataset) - dataset.id = dataset_id - dataset.provider = provider - dataset.name = name - dataset.description = description - dataset.tenant_id = tenant_id - dataset.indexing_technique = indexing_technique - dataset.embedding_model_provider = embedding_model_provider - dataset.embedding_model = embedding_model - dataset.collection_binding_id = collection_binding_id - dataset.enable_api = enable_api - dataset.permission = permission - dataset.created_by = created_by - dataset.chunk_structure = chunk_structure - dataset.runtime_mode = runtime_mode - dataset.retrieval_model = {} - dataset.keyword_number = 10 - for key, value in kwargs.items(): - setattr(dataset, key, value) - return dataset - - @staticmethod - def create_user_mock( - user_id: str = "user-123", - tenant_id: str = "tenant-123", - role: TenantAccountRole = TenantAccountRole.NORMAL, - is_dataset_editor: bool = True, - **kwargs, - ) -> Mock: - """ - Create a mock user (Account) with specified attributes. - - Args: - user_id: Unique identifier for the user - tenant_id: Tenant identifier - role: User role (OWNER, ADMIN, NORMAL, etc.) - is_dataset_editor: Whether user has dataset editor permissions - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as an Account instance - """ - user = create_autospec(Account, instance=True) - user.id = user_id - user.current_tenant_id = tenant_id - user.current_role = role - user.is_dataset_editor = is_dataset_editor - for key, value in kwargs.items(): - setattr(user, key, value) - return user - - @staticmethod - def create_knowledge_configuration_mock( - chunk_structure: str = "tree", - indexing_technique: str = IndexTechniqueType.HIGH_QUALITY, - embedding_model_provider: str = "openai", - embedding_model: str = "text-embedding-ada-002", - keyword_number: int = 10, - retrieval_model: dict | None = None, - **kwargs, - ) -> Mock: - """ - Create a mock KnowledgeConfiguration entity. - - Args: - chunk_structure: Chunk structure type - indexing_technique: Indexing technique - embedding_model_provider: Embedding model provider - embedding_model: Embedding model name - keyword_number: Keyword number for economy indexing - retrieval_model: Retrieval model configuration - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a KnowledgeConfiguration instance - """ - config = Mock() - config.chunk_structure = chunk_structure - config.indexing_technique = indexing_technique - config.embedding_model_provider = embedding_model_provider - config.embedding_model = embedding_model - config.keyword_number = keyword_number - config.retrieval_model = Mock() - config.retrieval_model.model_dump.return_value = retrieval_model or { - "search_method": "semantic_search", - "top_k": 2, - } - for key, value in kwargs.items(): - setattr(config, key, value) - return config - - @staticmethod - def create_app_dataset_join_mock( - app_id: str = "app-123", - dataset_id: str = "dataset-123", - **kwargs, - ) -> Mock: - """ - Create a mock AppDatasetJoin instance. - - Args: - app_id: Application ID - dataset_id: Dataset ID - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as an AppDatasetJoin instance - """ - join = Mock(spec=AppDatasetJoin) - join.app_id = app_id - join.dataset_id = dataset_id - for key, value in kwargs.items(): - setattr(join, key, value) - return join - - -# ============================================================================ -# Tests for update_dataset -# ============================================================================ - - -class TestDatasetServiceUpdateDataset: - """ - Comprehensive unit tests for DatasetService.update_dataset method. - - This test class covers the dataset update functionality, including - internal and external dataset updates, permission validation, and - name duplicate checking. - - The update_dataset method: - 1. Retrieves the dataset by ID - 2. Validates dataset exists - 3. Checks for duplicate names - 4. Validates user permissions - 5. Routes to appropriate update handler (internal or external) - 6. Returns the updated dataset - - Test scenarios include: - - Successful internal dataset updates - - Successful external dataset updates - - Permission validation - - Duplicate name detection - - Dataset not found errors - """ - - @pytest.fixture - def mock_dataset_service_dependencies(self): - """ - Mock dataset service dependencies for testing. - - Provides mocked dependencies including: - - get_dataset method - - check_dataset_permission method - - _has_dataset_same_name method - - Database session - - Current time utilities - """ - with ( - patch("services.dataset_service.DatasetService.get_dataset") as mock_get_dataset, - patch("services.dataset_service.DatasetService.check_dataset_permission") as mock_check_perm, - patch("services.dataset_service.DatasetService._has_dataset_same_name") as mock_has_same_name, - patch("extensions.ext_database.db.session") as mock_db, - patch("services.dataset_service.naive_utc_now") as mock_naive_utc_now, - ): - current_time = datetime.datetime(2023, 1, 1, 12, 0, 0) - mock_naive_utc_now.return_value = current_time - - yield { - "get_dataset": mock_get_dataset, - "check_permission": mock_check_perm, - "has_same_name": mock_has_same_name, - "db_session": mock_db, - "naive_utc_now": mock_naive_utc_now, - "current_time": current_time, - } - - def test_update_dataset_internal_success(self, mock_dataset_service_dependencies): - """ - Test successful update of an internal dataset. - - Verifies that when all validation passes, an internal dataset - is updated correctly through the _update_internal_dataset method. - - This test ensures: - - Dataset is retrieved correctly - - Permission is checked - - Name duplicate check is performed - - Internal update handler is called - - Updated dataset is returned - """ - # Arrange - dataset_id = "dataset-123" - dataset = DatasetUpdateDeleteTestDataFactory.create_dataset_mock( - dataset_id=dataset_id, provider="vendor", name="Old Name" - ) - user = DatasetUpdateDeleteTestDataFactory.create_user_mock() - - update_data = { - "name": "New Name", - "description": "New Description", - } - - mock_dataset_service_dependencies["get_dataset"].return_value = dataset - mock_dataset_service_dependencies["has_same_name"].return_value = False - - with patch("services.dataset_service.DatasetService._update_internal_dataset") as mock_update_internal: - mock_update_internal.return_value = dataset - - # Act - result = DatasetService.update_dataset(dataset_id, update_data, user) - - # Assert - assert result == dataset - - # Verify dataset was retrieved - mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset_id) - - # Verify permission was checked - mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user) - - # Verify name duplicate check was performed - mock_dataset_service_dependencies["has_same_name"].assert_called_once() - - # Verify internal update handler was called - mock_update_internal.assert_called_once() - - def test_update_dataset_external_success(self, mock_dataset_service_dependencies): - """ - Test successful update of an external dataset. - - Verifies that when all validation passes, an external dataset - is updated correctly through the _update_external_dataset method. - - This test ensures: - - Dataset is retrieved correctly - - Permission is checked - - Name duplicate check is performed - - External update handler is called - - Updated dataset is returned - """ - # Arrange - dataset_id = "dataset-123" - dataset = DatasetUpdateDeleteTestDataFactory.create_dataset_mock( - dataset_id=dataset_id, provider="external", name="Old Name" - ) - user = DatasetUpdateDeleteTestDataFactory.create_user_mock() - - update_data = { - "name": "New Name", - "external_knowledge_id": "new-knowledge-id", - } - - mock_dataset_service_dependencies["get_dataset"].return_value = dataset - mock_dataset_service_dependencies["has_same_name"].return_value = False - - with patch("services.dataset_service.DatasetService._update_external_dataset") as mock_update_external: - mock_update_external.return_value = dataset - - # Act - result = DatasetService.update_dataset(dataset_id, update_data, user) - - # Assert - assert result == dataset - - # Verify external update handler was called - mock_update_external.assert_called_once() - - def test_update_dataset_not_found_error(self, mock_dataset_service_dependencies): - """ - Test error handling when dataset is not found. - - Verifies that when the dataset ID doesn't exist, a ValueError - is raised with an appropriate message. - - This test ensures: - - Dataset not found error is handled correctly - - No update operations are performed - - Error message is clear - """ - # Arrange - dataset_id = "non-existent-dataset" - user = DatasetUpdateDeleteTestDataFactory.create_user_mock() - - update_data = {"name": "New Name"} - - mock_dataset_service_dependencies["get_dataset"].return_value = None - - # Act & Assert - with pytest.raises(ValueError, match="Dataset not found"): - DatasetService.update_dataset(dataset_id, update_data, user) - - # Verify no update operations were attempted - mock_dataset_service_dependencies["check_permission"].assert_not_called() - mock_dataset_service_dependencies["has_same_name"].assert_not_called() - - def test_update_dataset_duplicate_name_error(self, mock_dataset_service_dependencies): - """ - Test error handling when dataset name already exists. - - Verifies that when a dataset with the same name already exists - in the tenant, a ValueError is raised. - - This test ensures: - - Duplicate name detection works correctly - - Error message is clear - - No update operations are performed - """ - # Arrange - dataset_id = "dataset-123" - dataset = DatasetUpdateDeleteTestDataFactory.create_dataset_mock(dataset_id=dataset_id) - user = DatasetUpdateDeleteTestDataFactory.create_user_mock() - - update_data = {"name": "Existing Name"} - - mock_dataset_service_dependencies["get_dataset"].return_value = dataset - mock_dataset_service_dependencies["has_same_name"].return_value = True # Duplicate exists - - # Act & Assert - with pytest.raises(ValueError, match="Dataset name already exists"): - DatasetService.update_dataset(dataset_id, update_data, user) - - # Verify permission check was not called (fails before that) - mock_dataset_service_dependencies["check_permission"].assert_not_called() - - def test_update_dataset_permission_denied_error(self, mock_dataset_service_dependencies): - """ - Test error handling when user lacks permission. - - Verifies that when the user doesn't have permission to update - the dataset, a NoPermissionError is raised. - - This test ensures: - - Permission validation works correctly - - Error is raised before any updates - - Error type is correct - """ - # Arrange - dataset_id = "dataset-123" - dataset = DatasetUpdateDeleteTestDataFactory.create_dataset_mock(dataset_id=dataset_id) - user = DatasetUpdateDeleteTestDataFactory.create_user_mock() - - update_data = {"name": "New Name"} - - mock_dataset_service_dependencies["get_dataset"].return_value = dataset - mock_dataset_service_dependencies["has_same_name"].return_value = False - mock_dataset_service_dependencies["check_permission"].side_effect = NoPermissionError("No permission") - - # Act & Assert - with pytest.raises(NoPermissionError): - DatasetService.update_dataset(dataset_id, update_data, user) - - -# ============================================================================ -# Tests for update_rag_pipeline_dataset_settings -# ============================================================================ - - -class TestDatasetServiceUpdateRagPipelineDatasetSettings: - """ - Comprehensive unit tests for DatasetService.update_rag_pipeline_dataset_settings method. - - This test class covers the RAG pipeline dataset settings update functionality, - including chunk structure, indexing technique, and embedding model configuration. - - The update_rag_pipeline_dataset_settings method: - 1. Validates current_user and tenant - 2. Merges dataset into session - 3. Handles unpublished vs published datasets differently - 4. Updates chunk structure, indexing technique, and retrieval model - 5. Configures embedding model for high_quality indexing - 6. Updates keyword_number for economy indexing - 7. Commits transaction - 8. Triggers index update tasks if needed - - Test scenarios include: - - Unpublished dataset updates - - Published dataset updates - - Chunk structure validation - - Indexing technique changes - - Embedding model configuration - - Error handling - """ - - @pytest.fixture - def mock_session(self): - """ - Mock database session for testing. - - Provides a mocked SQLAlchemy session for testing session operations. - """ - return Mock(spec=Session) - - @pytest.fixture - def mock_dataset_service_dependencies(self): - """ - Mock dataset service dependencies for testing. - - Provides mocked dependencies including: - - current_user context - - ModelManager - - DatasetCollectionBindingService - - Database session operations - - Task scheduling - """ - with ( - patch( - "services.dataset_service.current_user", create_autospec(Account, instance=True) - ) as mock_current_user, - patch("services.dataset_service.ModelManager.for_tenant") as mock_model_manager, - patch( - "services.dataset_service.DatasetCollectionBindingService.get_dataset_collection_binding" - ) as mock_get_binding, - patch("services.dataset_service.deal_dataset_index_update_task") as mock_task, - ): - mock_current_user.current_tenant_id = "tenant-123" - mock_current_user.id = "user-123" - - yield { - "current_user": mock_current_user, - "model_manager": mock_model_manager, - "get_binding": mock_get_binding, - "task": mock_task, - } - - def test_update_rag_pipeline_dataset_settings_unpublished_success( - self, mock_session, mock_dataset_service_dependencies - ): - """ - Test successful update of unpublished RAG pipeline dataset. - - Verifies that when a dataset is not published, all settings can - be updated including chunk structure and indexing technique. - - This test ensures: - - Current user validation passes - - Dataset is merged into session - - Chunk structure is updated - - Indexing technique is updated - - Embedding model is configured for high_quality - - Retrieval model is updated - - Dataset is added to session - """ - # Arrange - dataset = DatasetUpdateDeleteTestDataFactory.create_dataset_mock( - dataset_id="dataset-123", - runtime_mode="rag_pipeline", - chunk_structure="tree", - indexing_technique=IndexTechniqueType.HIGH_QUALITY, - ) - - knowledge_config = DatasetUpdateDeleteTestDataFactory.create_knowledge_configuration_mock( - chunk_structure="list", - indexing_technique=IndexTechniqueType.HIGH_QUALITY, - embedding_model_provider="openai", - embedding_model="text-embedding-ada-002", - ) - - # Mock embedding model - mock_embedding_model = Mock() - mock_embedding_model.model_name = "text-embedding-ada-002" - mock_embedding_model.provider = "openai" - mock_embedding_model.credentials = {} - - mock_model_schema = Mock() - mock_model_schema.features = [] - - mock_text_embedding_model = Mock() - mock_text_embedding_model.get_model_schema.return_value = mock_model_schema - mock_embedding_model.model_type_instance = mock_text_embedding_model - - mock_model_instance = Mock() - mock_model_instance.get_model_instance.return_value = mock_embedding_model - mock_dataset_service_dependencies["model_manager"].return_value = mock_model_instance - - # Mock collection binding - mock_binding = Mock() - mock_binding.id = "binding-123" - mock_dataset_service_dependencies["get_binding"].return_value = mock_binding - - mock_session.merge.return_value = dataset - - # Act - DatasetService.update_rag_pipeline_dataset_settings( - mock_session, dataset, knowledge_config, has_published=False - ) - - # Assert - assert dataset.chunk_structure == "list" - assert dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY - assert dataset.embedding_model == "text-embedding-ada-002" - assert dataset.embedding_model_provider == "openai" - assert dataset.collection_binding_id == "binding-123" - - # Verify dataset was added to session - mock_session.add.assert_called_once_with(dataset) - - def test_update_rag_pipeline_dataset_settings_published_chunk_structure_error( - self, mock_session, mock_dataset_service_dependencies - ): - """ - Test error handling when trying to update chunk structure of published dataset. - - Verifies that when a dataset is published and has an existing chunk structure, - attempting to change it raises a ValueError. - - This test ensures: - - Chunk structure change is detected - - ValueError is raised with appropriate message - - No updates are committed - """ - # Arrange - dataset = DatasetUpdateDeleteTestDataFactory.create_dataset_mock( - dataset_id="dataset-123", - runtime_mode="rag_pipeline", - chunk_structure="tree", # Existing structure - indexing_technique=IndexTechniqueType.HIGH_QUALITY, - ) - - knowledge_config = DatasetUpdateDeleteTestDataFactory.create_knowledge_configuration_mock( - chunk_structure="list", # Different structure - indexing_technique=IndexTechniqueType.HIGH_QUALITY, - ) - - mock_session.merge.return_value = dataset - - # Act & Assert - with pytest.raises(ValueError, match="Chunk structure is not allowed to be updated"): - DatasetService.update_rag_pipeline_dataset_settings( - mock_session, dataset, knowledge_config, has_published=True - ) - - # Verify no commit was attempted - mock_session.commit.assert_not_called() - - def test_update_rag_pipeline_dataset_settings_published_economy_error( - self, mock_session, mock_dataset_service_dependencies - ): - """ - Test error handling when trying to change to economy indexing on published dataset. - - Verifies that when a dataset is published, changing indexing technique to - economy is not allowed and raises a ValueError. - - This test ensures: - - Economy indexing change is detected - - ValueError is raised with appropriate message - - No updates are committed - """ - # Arrange - dataset = DatasetUpdateDeleteTestDataFactory.create_dataset_mock( - dataset_id="dataset-123", - runtime_mode="rag_pipeline", - indexing_technique=IndexTechniqueType.HIGH_QUALITY, # Current technique - ) - - knowledge_config = DatasetUpdateDeleteTestDataFactory.create_knowledge_configuration_mock( - indexing_technique=IndexTechniqueType.ECONOMY, # Trying to change to economy - ) - - mock_session.merge.return_value = dataset - - # Act & Assert - with pytest.raises( - ValueError, match="Knowledge base indexing technique is not allowed to be updated to economy" - ): - DatasetService.update_rag_pipeline_dataset_settings( - mock_session, dataset, knowledge_config, has_published=True - ) - - def test_update_rag_pipeline_dataset_settings_missing_current_user_error( - self, mock_session, mock_dataset_service_dependencies - ): - """ - Test error handling when current_user is missing. - - Verifies that when current_user is None or has no tenant ID, a ValueError - is raised. - - This test ensures: - - Current user validation works correctly - - Error message is clear - - No updates are performed - """ - # Arrange - dataset = DatasetUpdateDeleteTestDataFactory.create_dataset_mock() - knowledge_config = DatasetUpdateDeleteTestDataFactory.create_knowledge_configuration_mock() - - mock_dataset_service_dependencies["current_user"].current_tenant_id = None # Missing tenant - - # Act & Assert - with pytest.raises(ValueError, match="Current user or current tenant not found"): - DatasetService.update_rag_pipeline_dataset_settings( - mock_session, dataset, knowledge_config, has_published=False - ) - - -# ============================================================================ -# Additional Documentation and Notes -# ============================================================================ -# -# This test suite covers the core update and delete operations for datasets. -# Additional test scenarios that could be added: -# -# 1. Update Operations: -# - Testing with different indexing techniques -# - Testing embedding model provider changes -# - Testing retrieval model updates -# - Testing icon_info updates -# - Testing partial_member_list updates -# -# 2. Delete Operations: -# - Testing cascade deletion of related data -# - Testing event handler execution -# - Testing with datasets that have documents -# - Testing with datasets that have segments -# -# 3. RAG Pipeline Operations: -# - Testing economy indexing technique updates -# - Testing embedding model provider errors -# - Testing keyword_number updates -# - Testing index update task triggering -# -# 4. Integration Scenarios: -# - Testing update followed by delete -# - Testing multiple updates in sequence -# - Testing concurrent update attempts -# - Testing with different user roles -# -# These scenarios are not currently implemented but could be added if needed -# based on real-world usage patterns or discovered edge cases. -# -# ============================================================================ diff --git a/api/tests/unit_tests/services/external_dataset_service.py b/api/tests/unit_tests/services/external_dataset_service.py index 5848603ab8..dd41c0c97e 100644 --- a/api/tests/unit_tests/services/external_dataset_service.py +++ b/api/tests/unit_tests/services/external_dataset_service.py @@ -396,10 +396,11 @@ class TestExternalDatasetServiceUsageAndBindings: mock_db_session.scalar.return_value = 3 - in_use, count = ExternalDatasetService.external_knowledge_api_use_check("api-1") + in_use, count = ExternalDatasetService.external_knowledge_api_use_check("api-1", "tenant-1") assert in_use is True assert count == 3 + assert "tenant_id" in str(mock_db_session.scalar.call_args.args[0]) def test_external_knowledge_api_use_check_not_in_use(self, mock_db_session: MagicMock): """ @@ -408,7 +409,7 @@ class TestExternalDatasetServiceUsageAndBindings: mock_db_session.scalar.return_value = 0 - in_use, count = ExternalDatasetService.external_knowledge_api_use_check("api-1") + in_use, count = ExternalDatasetService.external_knowledge_api_use_check("api-1", "tenant-1") assert in_use is False assert count == 0 diff --git a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py index edb50d09a6..021bebceff 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py @@ -6,23 +6,23 @@ MODULE = "services.plugin.plugin_auto_upgrade_service" def _patched_session(): - """Patch Session(db.engine) to return a mock session as context manager.""" + """Patch session_factory.create_session() to return a mock session as context manager.""" session = MagicMock() - session_cls = MagicMock() - session_cls.return_value.__enter__ = MagicMock(return_value=session) - session_cls.return_value.__exit__ = MagicMock(return_value=False) - patcher = patch(f"{MODULE}.Session", session_cls) - db_patcher = patch(f"{MODULE}.db") - return patcher, db_patcher, session + session.__enter__ = MagicMock(return_value=session) + session.__exit__ = MagicMock(return_value=False) + mock_factory = MagicMock() + mock_factory.create_session.return_value = session + patcher = patch(f"{MODULE}.session_factory", mock_factory) + return patcher, session class TestGetStrategy: def test_returns_strategy_when_found(self): - p1, p2, session = _patched_session() + p1, session = _patched_session() strategy = MagicMock() - session.query.return_value.where.return_value.first.return_value = strategy + session.scalar.return_value = strategy - with p1, p2: + with p1: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService result = PluginAutoUpgradeService.get_strategy("t1") @@ -30,10 +30,10 @@ class TestGetStrategy: assert result is strategy def test_returns_none_when_not_found(self): - p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + p1, session = _patched_session() + session.scalar.return_value = None - with p1, p2: + with p1: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService result = PluginAutoUpgradeService.get_strategy("t1") @@ -43,10 +43,10 @@ class TestGetStrategy: class TestChangeStrategy: def test_creates_new_strategy(self): - p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + p1, session = _patched_session() + session.scalar.return_value = None - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.return_value = MagicMock() from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService @@ -61,14 +61,13 @@ class TestChangeStrategy: assert result is True session.add.assert_called_once() - session.commit.assert_called_once() def test_updates_existing_strategy(self): - p1, p2, session = _patched_session() + p1, session = _patched_session() existing = MagicMock() - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2: + with p1: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService result = PluginAutoUpgradeService.change_strategy( @@ -86,17 +85,16 @@ class TestChangeStrategy: assert existing.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL assert existing.exclude_plugins == ["p1"] assert existing.include_plugins == ["p2"] - session.commit.assert_called_once() class TestExcludePlugin: def test_creates_default_strategy_when_none_exists(self): - p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + p1, session = _patched_session() + session.scalar.return_value = None with ( p1, - p2, + patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls, patch(f"{MODULE}.PluginAutoUpgradeService.change_strategy") as cs, ): @@ -111,13 +109,13 @@ class TestExcludePlugin: cs.assert_called_once() def test_appends_to_exclude_list_in_exclude_mode(self): - p1, p2, session = _patched_session() + p1, session = _patched_session() existing = MagicMock() existing.upgrade_mode = "exclude" existing.exclude_plugins = ["p-existing"] - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.UpgradeMode.EXCLUDE = "exclude" strat_cls.UpgradeMode.PARTIAL = "partial" strat_cls.UpgradeMode.ALL = "all" @@ -127,16 +125,15 @@ class TestExcludePlugin: assert result is True assert existing.exclude_plugins == ["p-existing", "p-new"] - session.commit.assert_called_once() def test_removes_from_include_list_in_partial_mode(self): - p1, p2, session = _patched_session() + p1, session = _patched_session() existing = MagicMock() existing.upgrade_mode = "partial" existing.include_plugins = ["p1", "p2"] - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.UpgradeMode.EXCLUDE = "exclude" strat_cls.UpgradeMode.PARTIAL = "partial" strat_cls.UpgradeMode.ALL = "all" @@ -148,12 +145,12 @@ class TestExcludePlugin: assert existing.include_plugins == ["p2"] def test_switches_to_exclude_mode_from_all(self): - p1, p2, session = _patched_session() + p1, session = _patched_session() existing = MagicMock() existing.upgrade_mode = "all" - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.UpgradeMode.EXCLUDE = "exclude" strat_cls.UpgradeMode.PARTIAL = "partial" strat_cls.UpgradeMode.ALL = "all" @@ -166,13 +163,13 @@ class TestExcludePlugin: assert existing.exclude_plugins == ["p1"] def test_no_duplicate_in_exclude_list(self): - p1, p2, session = _patched_session() + p1, session = _patched_session() existing = MagicMock() existing.upgrade_mode = "exclude" existing.exclude_plugins = ["p1"] - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2, patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: + with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: strat_cls.UpgradeMode.EXCLUDE = "exclude" strat_cls.UpgradeMode.PARTIAL = "partial" strat_cls.UpgradeMode.ALL = "all" diff --git a/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py b/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py index 69091110db..53a9e6210c 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py @@ -6,23 +6,25 @@ MODULE = "services.plugin.plugin_permission_service" def _patched_session(): - """Patch Session(db.engine) to return a mock session as context manager.""" + """Patch session_factory.create_session() to return a mock session as context manager.""" session = MagicMock() - session_cls = MagicMock() - session_cls.return_value.__enter__ = MagicMock(return_value=session) - session_cls.return_value.__exit__ = MagicMock(return_value=False) - patcher = patch(f"{MODULE}.Session", session_cls) - db_patcher = patch(f"{MODULE}.db") - return patcher, db_patcher, session + session.__enter__ = MagicMock(return_value=session) + session.__exit__ = MagicMock(return_value=False) + session.begin.return_value.__enter__ = MagicMock(return_value=session) + session.begin.return_value.__exit__ = MagicMock(return_value=False) + mock_factory = MagicMock() + mock_factory.create_session.return_value = session + patcher = patch(f"{MODULE}.session_factory", mock_factory) + return patcher, session class TestGetPermission: def test_returns_permission_when_found(self): - p1, p2, session = _patched_session() + p1, session = _patched_session() permission = MagicMock() - session.query.return_value.where.return_value.first.return_value = permission + session.scalar.return_value = permission - with p1, p2: + with p1: from services.plugin.plugin_permission_service import PluginPermissionService result = PluginPermissionService.get_permission("t1") @@ -30,10 +32,10 @@ class TestGetPermission: assert result is permission def test_returns_none_when_not_found(self): - p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + p1, session = _patched_session() + session.scalar.return_value = None - with p1, p2: + with p1: from services.plugin.plugin_permission_service import PluginPermissionService result = PluginPermissionService.get_permission("t1") @@ -43,10 +45,10 @@ class TestGetPermission: class TestChangePermission: def test_creates_new_permission_when_not_exists(self): - p1, p2, session = _patched_session() - session.query.return_value.where.return_value.first.return_value = None + p1, session = _patched_session() + session.scalar.return_value = None - with p1, p2, patch(f"{MODULE}.TenantPluginPermission") as perm_cls: + with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginPermission") as perm_cls: perm_cls.return_value = MagicMock() from services.plugin.plugin_permission_service import PluginPermissionService @@ -54,22 +56,24 @@ class TestChangePermission: "t1", TenantPluginPermission.InstallPermission.EVERYONE, TenantPluginPermission.DebugPermission.EVERYONE ) + assert result is True + session.begin.assert_called_once() session.add.assert_called_once() - session.commit.assert_called_once() def test_updates_existing_permission(self): - p1, p2, session = _patched_session() + p1, session = _patched_session() existing = MagicMock() - session.query.return_value.where.return_value.first.return_value = existing + session.scalar.return_value = existing - with p1, p2: + with p1: from services.plugin.plugin_permission_service import PluginPermissionService result = PluginPermissionService.change_permission( "t1", TenantPluginPermission.InstallPermission.ADMINS, TenantPluginPermission.DebugPermission.ADMINS ) + assert result is True + session.begin.assert_called_once() assert existing.install_permission == TenantPluginPermission.InstallPermission.ADMINS assert existing.debug_permission == TenantPluginPermission.DebugPermission.ADMINS - session.commit.assert_called_once() session.add.assert_not_called() diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py index f4fdac5f9f..6813a1bf2a 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py @@ -247,10 +247,11 @@ workflow: dataset_mock = Mock() dataset_mock.id = "d1" mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Dataset", return_value=dataset_mock) + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock()) session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) - session.query.return_value.filter_by.return_value.all.return_value = [] + session.scalars.return_value.all.return_value = [] account = Mock(current_tenant_id="t1") result = service.import_rag_pipeline(account=account, import_mode="yaml-content", yaml_content=yaml_content) @@ -320,6 +321,7 @@ workflow: dataset_mock.id = "d1" mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Dataset", return_value=dataset_mock) mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.DatasetCollectionBinding", return_value=Mock(id="b1")) + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock()) service = RagPipelineDslService(session=Mock()) # Mocking self._session.scalar for the pipeline lookup @@ -406,12 +408,14 @@ def test_create_or_update_pipeline_create_new(mocker) -> None: mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.current_user", SimpleNamespace(id="u1")) mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Workflow", return_value=Mock()) + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock()) pipeline_cls = mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Pipeline") pipeline_instance = pipeline_cls.return_value pipeline_instance.tenant_id = "t1" pipeline_instance.id = "p1" pipeline_instance.name = "P" pipeline_instance.is_published = False + session.scalar.return_value = None result = service._create_or_update_pipeline(pipeline=None, data=data, account=account, dependencies=[]) @@ -447,8 +451,7 @@ def test_export_rag_pipeline_dsl_with_workflow(mocker) -> None: workflow.rag_pipeline_variables = [] workflow.to_dict.return_value = {"graph": {"nodes": []}} - # Mocking single .where() call - session.query.return_value.where.return_value.first.return_value = workflow + session.scalar.return_value = workflow mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.generate_dependencies", return_value=[], @@ -550,7 +553,7 @@ def test_append_workflow_export_data_filters_credentials(mocker) -> None: ] } } - session.query.return_value.where.return_value.first.return_value = workflow + session.scalar.return_value = workflow mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.generate_dependencies", return_value=[], @@ -568,7 +571,7 @@ def test_append_workflow_export_data_filters_credentials(mocker) -> None: def test_create_rag_pipeline_dataset_raises_when_name_conflicts(mocker) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) - session.query.return_value.filter_by.return_value.first.return_value = Mock() + session.scalar.return_value = Mock() create_entity = RagPipelineDatasetCreateEntity( name="Existing Name", description="", @@ -584,8 +587,8 @@ def test_create_rag_pipeline_dataset_raises_when_name_conflicts(mocker) -> None: def test_create_rag_pipeline_dataset_generates_name_when_missing(mocker) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) - session.query.return_value.filter_by.return_value.first.return_value = None - session.query.return_value.filter_by.return_value.all.return_value = [Mock(name="Untitled")] + session.scalar.return_value = None + session.scalars.return_value.all.return_value = [Mock(name="Untitled")] mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.generate_incremental_name", return_value="Untitled 2") mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.current_user", Mock(id="u1", current_tenant_id="t1")) mocker.patch.object( @@ -632,7 +635,7 @@ def test_append_workflow_export_data_encrypts_knowledge_retrieval_dataset_ids(mo ] } } - session.query.return_value.where.return_value.first.return_value = workflow + session.scalar.return_value = workflow mocker.patch.object(service, "encrypt_dataset_id", side_effect=lambda dataset_id, tenant_id: f"enc-{dataset_id}") mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.generate_dependencies", @@ -727,7 +730,7 @@ def test_create_or_update_pipeline_decrypts_knowledge_retrieval_dataset_ids(mock }, } draft_workflow = Mock(id="wf1") - session.query.return_value.where.return_value.first.return_value = draft_workflow + session.scalar.return_value = draft_workflow mocker.patch.object(service, "decrypt_dataset_id", side_effect=["d1", None]) result = service._create_or_update_pipeline(pipeline=pipeline, data=data, account=account) @@ -743,7 +746,8 @@ def test_create_or_update_pipeline_creates_draft_when_missing(mocker) -> None: account = Mock(id="u1", current_tenant_id="t1") pipeline = Mock(id="p1", tenant_id="t1", name="N", description="D") data = {"rag_pipeline": {"name": "N2", "description": "D2"}, "workflow": {"graph": {"nodes": []}}} - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock()) workflow_cls = mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Workflow") workflow_cls.return_value.id = "wf-new" @@ -817,7 +821,7 @@ def test_import_rag_pipeline_fails_for_non_string_version_type() -> None: def test_append_workflow_export_data_raises_when_draft_workflow_missing() -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None with pytest.raises(ValueError, match="Missing draft workflow configuration"): service._append_workflow_export_data(export_data={}, pipeline=Mock(tenant_id="t1"), include_secret=False) @@ -841,7 +845,7 @@ def test_append_workflow_export_data_keeps_secret_fields_when_include_secret_tru ] } } - session.query.return_value.where.return_value.first.return_value = workflow + session.scalar.return_value = workflow mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.generate_dependencies", return_value=[], @@ -1003,7 +1007,8 @@ def test_import_rag_pipeline_sets_default_version_and_kind(mocker) -> None: ) dataset = Mock(id="d1") mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Dataset", return_value=dataset) - session.query.return_value.filter_by.return_value.all.return_value = [] + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock()) + session.scalars.return_value.all.return_value = [] mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.generate_incremental_name", return_value="P") result = service.import_rag_pipeline( @@ -1061,7 +1066,7 @@ def test_append_workflow_export_data_skips_empty_node_data(mocker) -> None: workflow = Mock() workflow.graph_dict = {"nodes": []} workflow.to_dict.return_value = {"graph": {"nodes": [{"data": {}}, {}]}} - session.query.return_value.where.return_value.first.return_value = workflow + session.scalar.return_value = workflow mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.generate_dependencies", return_value=[], @@ -1246,11 +1251,12 @@ def test_create_or_update_pipeline_saves_dependencies_to_redis(mocker) -> None: account = Mock(id="u1", current_tenant_id="t1") mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.current_user", SimpleNamespace(id="u1")) mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Workflow", return_value=Mock(id="wf-1")) + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock()) pipeline_cls = mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Pipeline") pipeline = pipeline_cls.return_value pipeline.tenant_id = "t1" pipeline.id = "p1" - session.query.return_value.where.return_value.first.return_value = None + session.scalar.return_value = None setex = mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.redis_client.setex") dependency = PluginDependency( type=PluginDependency.Type.Marketplace, diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py index f270ee0fde..941a665308 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py @@ -116,81 +116,6 @@ def test_get_all_published_workflow_applies_limit_and_has_more(rag_pipeline_serv assert has_more is True -def test_get_pipeline_raises_when_dataset_not_found(mocker, rag_pipeline_service) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None) - - with pytest.raises(ValueError, match="Dataset not found"): - rag_pipeline_service.get_pipeline("tenant-1", "dataset-1") - - -# --- update_customized_pipeline_template --- - - -def test_update_customized_pipeline_template_success(mocker) -> None: - template = SimpleNamespace(name="old", description="old", icon={}, updated_by=None) - - # First scalar finds the template, second scalar (duplicate check) returns None - mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[template, None]) - mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit") - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) - - info = PipelineTemplateInfoEntity( - name="new", - description="new desc", - icon_info=IconInfo(icon="🔥"), - ) - result = RagPipelineService.update_customized_pipeline_template("tpl-1", info) - - assert result.name == "new" - assert result.description == "new desc" - - -def test_update_customized_pipeline_template_not_found(mocker) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None) - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) - - info = PipelineTemplateInfoEntity(name="x", description="d", icon_info=IconInfo(icon="i")) - with pytest.raises(ValueError, match="Customized pipeline template not found"): - RagPipelineService.update_customized_pipeline_template("tpl-missing", info) - - -def test_update_customized_pipeline_template_duplicate_name(mocker) -> None: - template = SimpleNamespace(name="old", description="old", icon={}, updated_by=None) - duplicate = SimpleNamespace(name="dup") - - mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[template, duplicate]) - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) - - info = PipelineTemplateInfoEntity(name="dup", description="d", icon_info=IconInfo(icon="i")) - with pytest.raises(ValueError, match="Template name is already exists"): - RagPipelineService.update_customized_pipeline_template("tpl-1", info) - - -# --- delete_customized_pipeline_template --- - - -def test_delete_customized_pipeline_template_success(mocker) -> None: - template = SimpleNamespace(id="tpl-1") - mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=template) - delete_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.delete") - commit_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit") - - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) - - RagPipelineService.delete_customized_pipeline_template("tpl-1") - - delete_mock.assert_called_once_with(template) - commit_mock.assert_called_once() - - -def test_delete_customized_pipeline_template_not_found(mocker) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None) - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) - - with pytest.raises(ValueError, match="Customized pipeline template not found"): - RagPipelineService.delete_customized_pipeline_template("tpl-missing") - - # --- sync_draft_workflow --- diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 041929c5fa..eeb5d178ec 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -1427,16 +1427,9 @@ class TestRegisterService: mock_tenant.name = "Test Workspace" mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-123", name="Inviter") - # Mock database queries - need to mock the Session query - mock_session = MagicMock() - mock_session.query.return_value.filter_by.return_value.first.return_value = None # No existing account - with ( - patch("services.account_service.Session") as mock_session_class, patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, ): - mock_session_class.return_value.__enter__.return_value = mock_session - mock_session_class.return_value.__exit__.return_value = None mock_lookup.return_value = None # Mock RegisterService.register @@ -1473,7 +1466,7 @@ class TestRegisterService: status=AccountStatus.PENDING, is_setup=True, ) - mock_lookup.assert_called_once_with("newuser@example.com", session=mock_session) + mock_lookup.assert_called_once_with("newuser@example.com") def test_invite_new_member_normalizes_new_account_email( self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies @@ -1484,13 +1477,9 @@ class TestRegisterService: mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-123", name="Inviter") mixed_email = "Invitee@Example.com" - mock_session = MagicMock() with ( - patch("services.account_service.Session") as mock_session_class, patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, ): - mock_session_class.return_value.__enter__.return_value = mock_session - mock_session_class.return_value.__exit__.return_value = None mock_lookup.return_value = None mock_new_account = TestAccountAssociatedDataFactory.create_account_mock( @@ -1521,7 +1510,7 @@ class TestRegisterService: status=AccountStatus.PENDING, is_setup=True, ) - mock_lookup.assert_called_once_with(mixed_email, session=mock_session) + mock_lookup.assert_called_once_with(mixed_email) mock_check_permission.assert_called_once_with(mock_tenant, mock_inviter, None, "add") mock_create_member.assert_called_once_with(mock_tenant, mock_new_account, "normal") mock_switch_tenant.assert_called_once_with(mock_new_account, mock_tenant.id) @@ -1541,16 +1530,9 @@ class TestRegisterService: account_id="existing-user-456", email="existing@example.com", status="pending" ) - # Mock database queries - need to mock the Session query - mock_session = MagicMock() - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_existing_account - with ( - patch("services.account_service.Session") as mock_session_class, patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, ): - mock_session_class.return_value.__enter__.return_value = mock_session - mock_session_class.return_value.__exit__.return_value = None mock_lookup.return_value = mock_existing_account # Mock scalar for TenantAccountJoin lookup - no existing member @@ -1578,7 +1560,7 @@ class TestRegisterService: mock_create_member.assert_called_once_with(mock_tenant, mock_existing_account, "normal") mock_generate_token.assert_called_once_with(mock_tenant, mock_existing_account) mock_task_dependencies.delay.assert_called_once() - mock_lookup.assert_called_once_with("existing@example.com", session=mock_session) + mock_lookup.assert_called_once_with("existing@example.com") def test_invite_new_member_already_in_tenant(self, mock_db_dependencies, mock_redis_dependencies): """Test inviting a member who is already in the tenant.""" diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py deleted file mode 100644 index b2a2a1f685..0000000000 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ /dev/null @@ -1,970 +0,0 @@ -import base64 -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest -import yaml -from graphon.enums import BuiltinNodeTypes - -from core.trigger.constants import ( - TRIGGER_PLUGIN_NODE_TYPE, - TRIGGER_SCHEDULE_NODE_TYPE, - TRIGGER_WEBHOOK_NODE_TYPE, -) -from models import Account, App, AppMode -from models.model import IconType -from services import app_dsl_service -from services.app_dsl_service import ( - AppDslService, - CheckDependenciesPendingData, - ImportMode, - ImportStatus, - PendingData, - _check_version_compatibility, -) - - -class _FakeHttpResponse: - def __init__(self, content: bytes, *, raises: Exception | None = None): - self.content = content - self._raises = raises - - def raise_for_status(self) -> None: - if self._raises is not None: - raise self._raises - - -def _account_mock(*, tenant_id: str = "tenant-1", account_id: str = "account-1") -> MagicMock: - account = MagicMock(spec=Account) - account.current_tenant_id = tenant_id - account.id = account_id - return account - - -def _app_mock(**kwargs: object) -> MagicMock: - """Create a MagicMock with spec=App for type-safe test doubles.""" - app = MagicMock(spec=App) - for key, value in kwargs.items(): - setattr(app, key, value) - return app - - -def _yaml_dump(data: dict) -> str: - return yaml.safe_dump(data, allow_unicode=True) - - -def _workflow_yaml(*, version: str = app_dsl_service.CURRENT_DSL_VERSION) -> str: - return _yaml_dump( - { - "version": version, - "kind": "app", - "app": {"name": "My App", "mode": AppMode.WORKFLOW.value}, - "workflow": {"graph": {"nodes": []}, "features": {}}, - } - ) - - -def test_check_version_compatibility_invalid_version_returns_failed(): - assert _check_version_compatibility("not-a-version") == ImportStatus.FAILED - - -def test_check_version_compatibility_newer_version_returns_pending(): - assert _check_version_compatibility("99.0.0") == ImportStatus.PENDING - - -def test_check_version_compatibility_major_older_returns_pending(monkeypatch): - monkeypatch.setattr(app_dsl_service, "CURRENT_DSL_VERSION", "1.0.0") - assert _check_version_compatibility("0.9.9") == ImportStatus.PENDING - - -def test_check_version_compatibility_minor_older_returns_completed_with_warnings(): - assert _check_version_compatibility("0.5.0") == ImportStatus.COMPLETED_WITH_WARNINGS - - -def test_check_version_compatibility_equal_returns_completed(): - assert _check_version_compatibility(app_dsl_service.CURRENT_DSL_VERSION) == ImportStatus.COMPLETED - - -def test_import_app_invalid_import_mode_raises_value_error(): - service = AppDslService(MagicMock()) - with pytest.raises(ValueError, match="Invalid import_mode"): - service.import_app(account=_account_mock(), import_mode="invalid-mode", yaml_content="version: '0.1.0'") - - -def test_import_app_yaml_url_requires_url(): - service = AppDslService(MagicMock()) - result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_URL, yaml_url=None) - assert result.status == ImportStatus.FAILED - assert "yaml_url is required" in result.error - - -def test_import_app_yaml_content_requires_content(): - service = AppDslService(MagicMock()) - result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content=None) - assert result.status == ImportStatus.FAILED - assert "yaml_content is required" in result.error - - -def test_import_app_yaml_url_fetch_error_returns_failed(monkeypatch): - def fake_get(_url: str, **_kwargs): - raise RuntimeError("boom") - - monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get) - - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), import_mode=ImportMode.YAML_URL, yaml_url="https://example.com/a.yml" - ) - assert result.status == ImportStatus.FAILED - assert "Error fetching YAML from URL: boom" in result.error - - -def test_import_app_yaml_url_empty_content_returns_failed(monkeypatch): - def fake_get(_url: str, **_kwargs): - return _FakeHttpResponse(b"") - - monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get) - - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), import_mode=ImportMode.YAML_URL, yaml_url="https://example.com/a.yml" - ) - assert result.status == ImportStatus.FAILED - assert "Empty content" in result.error - - -def test_import_app_yaml_url_file_too_large_returns_failed(monkeypatch): - def fake_get(_url: str, **_kwargs): - return _FakeHttpResponse(b"x" * (app_dsl_service.DSL_MAX_SIZE + 1)) - - monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get) - - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), import_mode=ImportMode.YAML_URL, yaml_url="https://example.com/a.yml" - ) - assert result.status == ImportStatus.FAILED - assert "File size exceeds" in result.error - - -def test_import_app_yaml_not_mapping_returns_failed(): - service = AppDslService(MagicMock()) - result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content="[]") - assert result.status == ImportStatus.FAILED - assert "content must be a mapping" in result.error - - -def test_import_app_version_not_str_returns_failed(): - service = AppDslService(MagicMock()) - yaml_content = _yaml_dump({"version": 1, "kind": "app", "app": {"name": "x", "mode": "workflow"}}) - result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content=yaml_content) - assert result.status == ImportStatus.FAILED - assert "Invalid version type" in result.error - - -def test_import_app_missing_app_data_returns_failed(): - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), - import_mode=ImportMode.YAML_CONTENT, - yaml_content=_yaml_dump({"version": "0.6.0", "kind": "app"}), - ) - assert result.status == ImportStatus.FAILED - assert "Missing app data" in result.error - - -def test_import_app_app_id_not_found_returns_failed(monkeypatch): - def fake_select(_model): - stmt = MagicMock() - stmt.where.return_value = stmt - return stmt - - monkeypatch.setattr(app_dsl_service, "select", fake_select) - - session = MagicMock() - session.scalar.return_value = None - service = AppDslService(session) - result = service.import_app( - account=_account_mock(), - import_mode=ImportMode.YAML_CONTENT, - yaml_content=_workflow_yaml(), - app_id="missing-app", - ) - assert result.status == ImportStatus.FAILED - assert result.error == "App not found" - - -def test_import_app_overwrite_only_allows_workflow_and_advanced_chat(monkeypatch): - def fake_select(_model): - stmt = MagicMock() - stmt.where.return_value = stmt - return stmt - - monkeypatch.setattr(app_dsl_service, "select", fake_select) - - existing_app = _app_mock(id="app-1", tenant_id="tenant-1", mode=AppMode.CHAT.value) - - session = MagicMock() - session.scalar.return_value = existing_app - service = AppDslService(session) - result = service.import_app( - account=_account_mock(), - import_mode=ImportMode.YAML_CONTENT, - yaml_content=_workflow_yaml(), - app_id="app-1", - ) - assert result.status == ImportStatus.FAILED - assert "Only workflow or advanced chat apps" in result.error - - -def test_import_app_pending_stores_import_info_in_redis(): - service = AppDslService(MagicMock()) - app_dsl_service.redis_client.setex.reset_mock() - result = service.import_app( - account=_account_mock(), - import_mode=ImportMode.YAML_CONTENT, - yaml_content=_workflow_yaml(version="99.0.0"), - name="n", - description="d", - icon_type="emoji", - icon="i", - icon_background="#000000", - ) - assert result.status == ImportStatus.PENDING - assert result.imported_dsl_version == "99.0.0" - - app_dsl_service.redis_client.setex.assert_called_once() - call = app_dsl_service.redis_client.setex.call_args - redis_key = call.args[0] - assert redis_key.startswith(app_dsl_service.IMPORT_INFO_REDIS_KEY_PREFIX) - - -def test_import_app_completed_uses_declared_dependencies(monkeypatch): - dependencies_payload = [{"id": "langgenius/google", "version": "1.0.0"}] - - plugin_deps = [SimpleNamespace(model_dump=lambda: dependencies_payload[0])] - monkeypatch.setattr( - app_dsl_service.PluginDependency, - "model_validate", - lambda d: plugin_deps[0], - ) - - created_app = _app_mock(id="app-new", mode=AppMode.WORKFLOW.value, tenant_id="tenant-1") - monkeypatch.setattr(AppDslService, "_create_or_update_app", lambda *_args, **_kwargs: created_app) - - draft_var_service = MagicMock() - monkeypatch.setattr(app_dsl_service, "WorkflowDraftVariableService", lambda *args, **kwargs: draft_var_service) - - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), - import_mode=ImportMode.YAML_CONTENT, - yaml_content=_yaml_dump( - { - "version": app_dsl_service.CURRENT_DSL_VERSION, - "kind": "app", - "app": {"name": "My App", "mode": AppMode.WORKFLOW.value}, - "workflow": {"graph": {"nodes": []}, "features": {}}, - "dependencies": dependencies_payload, - } - ), - ) - - assert result.status == ImportStatus.COMPLETED - assert result.app_id == "app-new" - draft_var_service.delete_app_workflow_variables.assert_called_once_with(app_id="app-new") - - -@pytest.mark.parametrize("has_workflow", [True, False]) -def test_import_app_legacy_versions_extract_dependencies(monkeypatch, has_workflow: bool): - monkeypatch.setattr( - AppDslService, - "_extract_dependencies_from_workflow_graph", - lambda *_args, **_kwargs: ["from-workflow"], - ) - monkeypatch.setattr( - AppDslService, - "_extract_dependencies_from_model_config", - lambda *_args, **_kwargs: ["from-model-config"], - ) - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "generate_latest_dependencies", - lambda deps: [SimpleNamespace(model_dump=lambda: {"dep": deps[0]})], - ) - - created_app = _app_mock(id="app-legacy", mode=AppMode.WORKFLOW.value, tenant_id="tenant-1") - monkeypatch.setattr(AppDslService, "_create_or_update_app", lambda *_args, **_kwargs: created_app) - - draft_var_service = MagicMock() - monkeypatch.setattr(app_dsl_service, "WorkflowDraftVariableService", lambda *args, **kwargs: draft_var_service) - - data: dict = { - "version": "0.1.5", - "kind": "app", - "app": {"name": "Legacy", "mode": AppMode.WORKFLOW.value}, - } - if has_workflow: - data["workflow"] = {"graph": {"nodes": []}, "features": {}} - else: - data["model_config"] = {"model": {"provider": "openai"}} - - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content=_yaml_dump(data) - ) - assert result.status == ImportStatus.COMPLETED_WITH_WARNINGS - draft_var_service.delete_app_workflow_variables.assert_called_once_with(app_id="app-legacy") - - -def test_import_app_yaml_error_returns_failed(monkeypatch): - def bad_safe_load(_content: str): - raise yaml.YAMLError("bad") - - monkeypatch.setattr(app_dsl_service.yaml, "safe_load", bad_safe_load) - - service = AppDslService(MagicMock()) - result = service.import_app(account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content="x: y") - assert result.status == ImportStatus.FAILED - assert result.error.startswith("Invalid YAML format:") - - -def test_import_app_unexpected_error_returns_failed(monkeypatch): - monkeypatch.setattr( - AppDslService, "_create_or_update_app", lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("oops")) - ) - - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), import_mode=ImportMode.YAML_CONTENT, yaml_content=_workflow_yaml() - ) - assert result.status == ImportStatus.FAILED - assert result.error == "oops" - - -def test_confirm_import_expired_returns_failed(): - service = AppDslService(MagicMock()) - result = service.confirm_import(import_id="import-1", account=_account_mock()) - assert result.status == ImportStatus.FAILED - assert "expired" in result.error - - -def test_confirm_import_invalid_pending_data_type_returns_failed(): - app_dsl_service.redis_client.get.return_value = 123 - service = AppDslService(MagicMock()) - result = service.confirm_import(import_id="import-1", account=_account_mock()) - assert result.status == ImportStatus.FAILED - assert "Invalid import information" in result.error - - -def test_confirm_import_success_deletes_redis_key(monkeypatch): - def fake_select(_model): - stmt = MagicMock() - stmt.where.return_value = stmt - return stmt - - monkeypatch.setattr(app_dsl_service, "select", fake_select) - - session = MagicMock() - session.scalar.return_value = None - service = AppDslService(session) - - pending = PendingData( - import_mode=ImportMode.YAML_CONTENT, - yaml_content=_workflow_yaml(), - name="name", - description="desc", - icon_type="emoji", - icon="🤖", - icon_background="#fff", - app_id=None, - ) - app_dsl_service.redis_client.get.return_value = pending.model_dump_json() - - created_app = _app_mock(id="confirmed-app", mode=AppMode.WORKFLOW.value, tenant_id="tenant-1") - monkeypatch.setattr(AppDslService, "_create_or_update_app", lambda *_args, **_kwargs: created_app) - - app_dsl_service.redis_client.delete.reset_mock() - result = service.confirm_import(import_id="import-1", account=_account_mock()) - assert result.status == ImportStatus.COMPLETED - assert result.app_id == "confirmed-app" - app_dsl_service.redis_client.delete.assert_called_once_with( - f"{app_dsl_service.IMPORT_INFO_REDIS_KEY_PREFIX}import-1" - ) - - -def test_confirm_import_exception_returns_failed(monkeypatch): - app_dsl_service.redis_client.get.return_value = "not-json" - monkeypatch.setattr( - PendingData, "model_validate_json", lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("bad")) - ) - - service = AppDslService(MagicMock()) - result = service.confirm_import(import_id="import-1", account=_account_mock()) - assert result.status == ImportStatus.FAILED - assert result.error == "bad" - - -def test_check_dependencies_returns_empty_when_no_redis_data(): - service = AppDslService(MagicMock()) - result = service.check_dependencies(app_model=_app_mock(id="app-1", tenant_id="tenant-1")) - assert result.leaked_dependencies == [] - - -def test_check_dependencies_calls_analysis_service(monkeypatch): - pending = CheckDependenciesPendingData(dependencies=[], app_id="app-1").model_dump_json() - app_dsl_service.redis_client.get.return_value = pending - dep = app_dsl_service.PluginDependency.model_validate( - {"type": "package", "value": {"plugin_unique_identifier": "acme/foo", "version": "1.0.0"}} - ) - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "get_leaked_dependencies", - lambda *, tenant_id, dependencies: [dep], - ) - - service = AppDslService(MagicMock()) - result = service.check_dependencies(app_model=_app_mock(id="app-1", tenant_id="tenant-1")) - assert len(result.leaked_dependencies) == 1 - - -def test_create_or_update_app_missing_mode_raises(): - service = AppDslService(MagicMock()) - with pytest.raises(ValueError, match="loss app mode"): - service._create_or_update_app(app=None, data={"app": {}}, account=_account_mock()) - - -def test_create_or_update_app_existing_app_updates_fields(monkeypatch): - fixed_now = object() - monkeypatch.setattr(app_dsl_service, "naive_utc_now", lambda: fixed_now) - - workflow_service = MagicMock() - workflow_service.get_draft_workflow.return_value = None - monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service) - monkeypatch.setattr( - app_dsl_service.variable_factory, - "build_environment_variable_from_mapping", - lambda _m: SimpleNamespace(kind="env"), - ) - monkeypatch.setattr( - app_dsl_service.variable_factory, - "build_conversation_variable_from_mapping", - lambda _m: SimpleNamespace(kind="conv"), - ) - - app = _app_mock( - id="app-1", - tenant_id="tenant-1", - mode=AppMode.WORKFLOW.value, - name="old", - description="old-desc", - icon_type=IconType.EMOJI, - icon="old-icon", - icon_background="#111111", - updated_by=None, - updated_at=None, - app_model_config=None, - ) - service = AppDslService(MagicMock()) - updated = service._create_or_update_app( - app=app, - data={ - "app": {"mode": AppMode.WORKFLOW.value, "name": "yaml-name", "icon_type": IconType.IMAGE, "icon": "X"}, - "workflow": {"graph": {"nodes": []}, "features": {}}, - }, - account=_account_mock(), - name="override-name", - description=None, - icon_background="#222222", - ) - assert updated is app - assert app.name == "override-name" - assert app.icon_type == IconType.IMAGE - assert app.icon == "X" - assert app.icon_background == "#222222" - assert app.updated_at is fixed_now - - -def test_create_or_update_app_new_app_requires_tenant(): - account = _account_mock() - account.current_tenant_id = None - service = AppDslService(MagicMock()) - with pytest.raises(ValueError, match="Current tenant is not set"): - service._create_or_update_app( - app=None, - data={"app": {"mode": AppMode.WORKFLOW.value, "name": "n"}}, - account=account, - ) - - -def test_create_or_update_app_creates_workflow_app_and_saves_dependencies(monkeypatch): - class DummyApp(SimpleNamespace): - pass - - monkeypatch.setattr(app_dsl_service, "App", DummyApp) - - sent: list[tuple[str, object]] = [] - monkeypatch.setattr(app_dsl_service.app_was_created, "send", lambda app, account: sent.append((app.id, account.id))) - - workflow_service = MagicMock() - workflow_service.get_draft_workflow.return_value = SimpleNamespace(unique_hash="uh") - monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service) - - monkeypatch.setattr( - app_dsl_service.variable_factory, - "build_environment_variable_from_mapping", - lambda _m: SimpleNamespace(kind="env"), - ) - monkeypatch.setattr( - app_dsl_service.variable_factory, - "build_conversation_variable_from_mapping", - lambda _m: SimpleNamespace(kind="conv"), - ) - - monkeypatch.setattr( - AppDslService, "decrypt_dataset_id", lambda *_args, **_kwargs: "00000000-0000-0000-0000-000000000000" - ) - - session = MagicMock() - service = AppDslService(session) - deps = [ - app_dsl_service.PluginDependency.model_validate( - {"type": "package", "value": {"plugin_unique_identifier": "acme/foo", "version": "1.0.0"}} - ) - ] - data = { - "app": {"mode": AppMode.WORKFLOW.value, "name": "n"}, - "workflow": { - "environment_variables": [{"x": 1}], - "conversation_variables": [{"y": 2}], - "graph": { - "nodes": [ - {"data": {"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL, "dataset_ids": ["enc-1", "enc-2"]}}, - ] - }, - "features": {}, - }, - } - - app = service._create_or_update_app(app=None, data=data, account=_account_mock(), dependencies=deps) - - assert app.tenant_id == "tenant-1" - assert sent == [(app.id, "account-1")] - app_dsl_service.redis_client.setex.assert_called() - workflow_service.sync_draft_workflow.assert_called_once() - - passed_graph = workflow_service.sync_draft_workflow.call_args.kwargs["graph"] - dataset_ids = passed_graph["nodes"][0]["data"]["dataset_ids"] - assert dataset_ids == ["00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000000"] - - -def test_create_or_update_app_workflow_missing_workflow_data_raises(): - service = AppDslService(MagicMock()) - with pytest.raises(ValueError, match="Missing workflow data"): - service._create_or_update_app( - app=_app_mock( - id="a", - tenant_id="t", - mode=AppMode.WORKFLOW.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), - data={"app": {"mode": AppMode.WORKFLOW.value}}, - account=_account_mock(), - ) - - -def test_create_or_update_app_chat_requires_model_config(): - service = AppDslService(MagicMock()) - with pytest.raises(ValueError, match="Missing model_config"): - service._create_or_update_app( - app=_app_mock( - id="a", - tenant_id="t", - mode=AppMode.CHAT.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), - data={"app": {"mode": AppMode.CHAT.value}}, - account=_account_mock(), - ) - - -def test_create_or_update_app_chat_creates_model_config_and_sends_event(monkeypatch): - class DummyModelConfig(SimpleNamespace): - def from_model_config_dict(self, _cfg: dict): - return self - - monkeypatch.setattr(app_dsl_service, "AppModelConfig", DummyModelConfig) - - sent: list[str] = [] - monkeypatch.setattr( - app_dsl_service.app_model_config_was_updated, "send", lambda app, app_model_config: sent.append(app.id) - ) - - session = MagicMock() - service = AppDslService(session) - - app = _app_mock( - id="app-1", - tenant_id="tenant-1", - mode=AppMode.CHAT.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ) - service._create_or_update_app( - app=app, - data={"app": {"mode": AppMode.CHAT.value}, "model_config": {"model": {"provider": "openai"}}}, - account=_account_mock(), - ) - - assert app.app_model_config_id is not None - assert sent == ["app-1"] - session.add.assert_called() - - -def test_create_or_update_app_invalid_mode_raises(): - service = AppDslService(MagicMock()) - with pytest.raises(ValueError, match="Invalid app mode"): - service._create_or_update_app( - app=_app_mock( - id="a", - tenant_id="t", - mode=AppMode.RAG_PIPELINE.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), - data={"app": {"mode": AppMode.RAG_PIPELINE.value}}, - account=_account_mock(), - ) - - -def test_export_dsl_delegates_by_mode(monkeypatch): - workflow_calls: list[bool] = [] - model_calls: list[bool] = [] - monkeypatch.setattr(AppDslService, "_append_workflow_export_data", lambda **_kwargs: workflow_calls.append(True)) - monkeypatch.setattr( - AppDslService, "_append_model_config_export_data", lambda *_args, **_kwargs: model_calls.append(True) - ) - - workflow_app = _app_mock( - mode=AppMode.WORKFLOW.value, - tenant_id="tenant-1", - name="n", - icon="i", - icon_type="emoji", - icon_background="#fff", - description="d", - use_icon_as_answer_icon=False, - app_model_config=None, - ) - AppDslService.export_dsl(workflow_app) - assert workflow_calls == [True] - - chat_app = _app_mock( - mode=AppMode.CHAT.value, - tenant_id="tenant-1", - name="n", - icon="i", - icon_type="emoji", - icon_background="#fff", - description="d", - use_icon_as_answer_icon=False, - app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}), - ) - AppDslService.export_dsl(chat_app) - assert model_calls == [True] - - -def test_export_dsl_preserves_icon_and_icon_type(monkeypatch): - monkeypatch.setattr(AppDslService, "_append_workflow_export_data", lambda **_kwargs: None) - - emoji_app = _app_mock( - mode=AppMode.WORKFLOW.value, - tenant_id="tenant-1", - name="Emoji App", - icon="🎨", - icon_type=IconType.EMOJI, - icon_background="#FF5733", - description="App with emoji icon", - use_icon_as_answer_icon=True, - app_model_config=None, - ) - yaml_output = AppDslService.export_dsl(emoji_app) - data = yaml.safe_load(yaml_output) - assert data["app"]["icon"] == "🎨" - assert data["app"]["icon_type"] == "emoji" - assert data["app"]["icon_background"] == "#FF5733" - - image_app = _app_mock( - mode=AppMode.WORKFLOW.value, - tenant_id="tenant-1", - name="Image App", - icon="https://example.com/icon.png", - icon_type=IconType.IMAGE, - icon_background="#FFEAD5", - description="App with image icon", - use_icon_as_answer_icon=False, - app_model_config=None, - ) - yaml_output = AppDslService.export_dsl(image_app) - data = yaml.safe_load(yaml_output) - assert data["app"]["icon"] == "https://example.com/icon.png" - assert data["app"]["icon_type"] == "image" - assert data["app"]["icon_background"] == "#FFEAD5" - - -def test_append_workflow_export_data_filters_and_overrides(monkeypatch): - workflow_dict = { - "graph": { - "nodes": [ - {"data": {"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL, "dataset_ids": ["d1", "d2"]}}, - {"data": {"type": BuiltinNodeTypes.TOOL, "credential_id": "secret"}}, - { - "data": { - "type": BuiltinNodeTypes.AGENT, - "agent_parameters": {"tools": {"value": [{"credential_id": "secret"}]}}, - } - }, - {"data": {"type": TRIGGER_SCHEDULE_NODE_TYPE, "config": {"x": 1}}}, - {"data": {"type": TRIGGER_WEBHOOK_NODE_TYPE, "webhook_url": "x", "webhook_debug_url": "y"}}, - {"data": {"type": TRIGGER_PLUGIN_NODE_TYPE, "subscription_id": "s"}}, - ] - } - } - - workflow = SimpleNamespace(to_dict=lambda *, include_secret: workflow_dict) - workflow_service = MagicMock() - workflow_service.get_draft_workflow.return_value = workflow - monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service) - - monkeypatch.setattr( - AppDslService, "encrypt_dataset_id", lambda *, dataset_id, tenant_id: f"enc:{tenant_id}:{dataset_id}" - ) - monkeypatch.setattr( - TriggerScheduleNode := app_dsl_service.TriggerScheduleNode, - "get_default_config", - lambda: {"config": {"default": True}}, - ) - monkeypatch.setattr(AppDslService, "_extract_dependencies_from_workflow", lambda *_args, **_kwargs: ["dep-1"]) - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "generate_dependencies", - lambda *, tenant_id, dependencies: [ - SimpleNamespace(model_dump=lambda: {"tenant": tenant_id, "dep": dependencies[0]}) - ], - ) - monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x) - - export_data: dict = {} - AppDslService._append_workflow_export_data( - export_data=export_data, - app_model=_app_mock(tenant_id="tenant-1"), - include_secret=False, - workflow_id=None, - ) - - nodes = export_data["workflow"]["graph"]["nodes"] - assert nodes[0]["data"]["dataset_ids"] == ["enc:tenant-1:d1", "enc:tenant-1:d2"] - assert "credential_id" not in nodes[1]["data"] - assert "credential_id" not in nodes[2]["data"]["agent_parameters"]["tools"]["value"][0] - assert nodes[3]["data"]["config"] == {"default": True} - assert nodes[4]["data"]["webhook_url"] == "" - assert nodes[4]["data"]["webhook_debug_url"] == "" - assert nodes[5]["data"]["subscription_id"] == "" - assert export_data["dependencies"] == [{"tenant": "tenant-1", "dep": "dep-1"}] - - -def test_append_workflow_export_data_missing_workflow_raises(monkeypatch): - workflow_service = MagicMock() - workflow_service.get_draft_workflow.return_value = None - monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service) - - with pytest.raises(ValueError, match="Missing draft workflow configuration"): - AppDslService._append_workflow_export_data( - export_data={}, - app_model=_app_mock(tenant_id="tenant-1"), - include_secret=False, - workflow_id=None, - ) - - -def test_append_model_config_export_data_filters_credential_id(monkeypatch): - monkeypatch.setattr(AppDslService, "_extract_dependencies_from_model_config", lambda *_args, **_kwargs: ["dep-1"]) - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "generate_dependencies", - lambda *, tenant_id, dependencies: [ - SimpleNamespace(model_dump=lambda: {"tenant": tenant_id, "dep": dependencies[0]}) - ], - ) - monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x) - - app_model_config = SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": [{"credential_id": "secret"}]}}) - app_model = _app_mock(tenant_id="tenant-1", app_model_config=app_model_config) - export_data: dict = {} - - AppDslService._append_model_config_export_data(export_data, app_model) - assert export_data["model_config"]["agent_mode"]["tools"] == [{}] - assert export_data["dependencies"] == [{"tenant": "tenant-1", "dep": "dep-1"}] - - -def test_append_model_config_export_data_requires_app_config(): - with pytest.raises(ValueError, match="Missing app configuration"): - AppDslService._append_model_config_export_data({}, _app_mock(app_model_config=None)) - - -def test_extract_dependencies_from_workflow_graph_covers_all_node_types(monkeypatch): - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "analyze_tool_dependency", - lambda provider_id: f"tool:{provider_id}", - ) - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "analyze_model_provider_dependency", - lambda provider: f"model:{provider}", - ) - - monkeypatch.setattr(app_dsl_service.ToolNodeData, "model_validate", lambda _d: SimpleNamespace(provider_id="p1")) - monkeypatch.setattr( - app_dsl_service.LLMNodeData, "model_validate", lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m1")) - ) - monkeypatch.setattr( - app_dsl_service.QuestionClassifierNodeData, - "model_validate", - lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m2")), - ) - monkeypatch.setattr( - app_dsl_service.ParameterExtractorNodeData, - "model_validate", - lambda _d: SimpleNamespace(model=SimpleNamespace(provider="m3")), - ) - - def kr_validate(_d): - return SimpleNamespace( - retrieval_mode="multiple", - multiple_retrieval_config=SimpleNamespace( - reranking_mode="weighted_score", - weights=SimpleNamespace(vector_setting=SimpleNamespace(embedding_provider_name="m4")), - reranking_model=None, - ), - single_retrieval_config=None, - ) - - monkeypatch.setattr(app_dsl_service.KnowledgeRetrievalNodeData, "model_validate", kr_validate) - - graph = { - "nodes": [ - {"data": {"type": BuiltinNodeTypes.TOOL}}, - {"data": {"type": BuiltinNodeTypes.LLM}}, - {"data": {"type": BuiltinNodeTypes.QUESTION_CLASSIFIER}}, - {"data": {"type": BuiltinNodeTypes.PARAMETER_EXTRACTOR}}, - {"data": {"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL}}, - {"data": {"type": "unknown"}}, - ] - } - - deps = AppDslService._extract_dependencies_from_workflow_graph(graph) - assert deps == ["tool:p1", "model:m1", "model:m2", "model:m3", "model:m4"] - - -def test_extract_dependencies_from_workflow_graph_handles_exceptions(monkeypatch): - monkeypatch.setattr( - app_dsl_service.ToolNodeData, "model_validate", lambda _d: (_ for _ in ()).throw(ValueError("bad")) - ) - deps = AppDslService._extract_dependencies_from_workflow_graph( - {"nodes": [{"data": {"type": BuiltinNodeTypes.TOOL}}]} - ) - assert deps == [] - - -def test_extract_dependencies_from_model_config_parses_providers(monkeypatch): - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "analyze_model_provider_dependency", - lambda provider: f"model:{provider}", - ) - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "analyze_tool_dependency", - lambda provider_id: f"tool:{provider_id}", - ) - - deps = AppDslService._extract_dependencies_from_model_config( - { - "model": {"provider": "p1"}, - "dataset_configs": { - "datasets": {"datasets": [{"reranking_model": {"reranking_provider_name": {"provider": "p2"}}}]} - }, - "agent_mode": {"tools": [{"provider_id": "t1"}]}, - } - ) - assert deps == ["model:p1", "model:p2", "tool:t1"] - - -def test_extract_dependencies_from_model_config_handles_exceptions(monkeypatch): - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "analyze_model_provider_dependency", - lambda _p: (_ for _ in ()).throw(ValueError("bad")), - ) - deps = AppDslService._extract_dependencies_from_model_config({"model": {"provider": "p1"}}) - assert deps == [] - - -def test_get_leaked_dependencies_empty_returns_empty(): - assert AppDslService.get_leaked_dependencies("tenant-1", []) == [] - - -def test_get_leaked_dependencies_delegates(monkeypatch): - monkeypatch.setattr( - app_dsl_service.DependenciesAnalysisService, - "get_leaked_dependencies", - lambda *, tenant_id, dependencies: [SimpleNamespace(tenant_id=tenant_id, deps=dependencies)], - ) - res = AppDslService.get_leaked_dependencies("tenant-1", [SimpleNamespace(id="x")]) - assert len(res) == 1 - - -def test_encrypt_decrypt_dataset_id_respects_config(monkeypatch): - tenant_id = "tenant-1" - dataset_uuid = "00000000-0000-0000-0000-000000000000" - - monkeypatch.setattr(app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", False) - assert AppDslService.encrypt_dataset_id(dataset_id=dataset_uuid, tenant_id=tenant_id) == dataset_uuid - - monkeypatch.setattr(app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", True) - encrypted = AppDslService.encrypt_dataset_id(dataset_id=dataset_uuid, tenant_id=tenant_id) - assert encrypted != dataset_uuid - assert base64.b64decode(encrypted.encode()) - assert AppDslService.decrypt_dataset_id(encrypted_data=encrypted, tenant_id=tenant_id) == dataset_uuid - - -def test_decrypt_dataset_id_returns_plain_uuid_unchanged(): - value = "00000000-0000-0000-0000-000000000000" - assert AppDslService.decrypt_dataset_id(encrypted_data=value, tenant_id="tenant-1") == value - - -def test_decrypt_dataset_id_returns_none_on_invalid_data(monkeypatch): - monkeypatch.setattr(app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", True) - assert AppDslService.decrypt_dataset_id(encrypted_data="not-base64", tenant_id="tenant-1") is None - - -def test_decrypt_dataset_id_returns_none_when_decrypted_is_not_uuid(monkeypatch): - monkeypatch.setattr(app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", True) - encrypted = AppDslService.encrypt_dataset_id(dataset_id="not-a-uuid", tenant_id="tenant-1") - assert AppDslService.decrypt_dataset_id(encrypted_data=encrypted, tenant_id="tenant-1") is None - - -def test_is_valid_uuid_handles_bad_inputs(): - assert AppDslService._is_valid_uuid("00000000-0000-0000-0000-000000000000") is True - assert AppDslService._is_valid_uuid("nope") is False diff --git a/api/tests/unit_tests/services/test_app_dsl_service_import_yaml_url.py b/api/tests/unit_tests/services/test_app_dsl_service_import_yaml_url.py deleted file mode 100644 index 41c1d0ea2a..0000000000 --- a/api/tests/unit_tests/services/test_app_dsl_service_import_yaml_url.py +++ /dev/null @@ -1,71 +0,0 @@ -from unittest.mock import MagicMock - -import httpx - -from models import Account -from services import app_dsl_service -from services.app_dsl_service import AppDslService, ImportMode, ImportStatus - - -def _build_response(url: str, status_code: int, content: bytes = b"") -> httpx.Response: - request = httpx.Request("GET", url) - return httpx.Response(status_code=status_code, request=request, content=content) - - -def _pending_yaml_content(version: str = "99.0.0") -> bytes: - return (f'version: "{version}"\nkind: app\napp:\n name: Loop Test\n mode: workflow\n').encode() - - -def _account_mock() -> MagicMock: - account = MagicMock(spec=Account) - account.current_tenant_id = "tenant-1" - return account - - -def test_import_app_yaml_url_user_attachments_keeps_original_url(monkeypatch): - yaml_url = "https://github.com/user-attachments/files/24290802/loop-test.yml" - raw_url = "https://raw.githubusercontent.com/user-attachments/files/24290802/loop-test.yml" - yaml_bytes = _pending_yaml_content() - - def fake_get(url: str, **kwargs): - if url == raw_url: - return _build_response(url, status_code=404) - assert url == yaml_url - return _build_response(url, status_code=200, content=yaml_bytes) - - monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get) - - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), - import_mode=ImportMode.YAML_URL, - yaml_url=yaml_url, - ) - - assert result.status == ImportStatus.PENDING - assert result.imported_dsl_version == "99.0.0" - - -def test_import_app_yaml_url_github_blob_rewrites_to_raw(monkeypatch): - yaml_url = "https://github.com/acme/repo/blob/main/app.yml" - raw_url = "https://raw.githubusercontent.com/acme/repo/main/app.yml" - yaml_bytes = _pending_yaml_content() - - requested_urls: list[str] = [] - - def fake_get(url: str, **kwargs): - requested_urls.append(url) - assert url == raw_url - return _build_response(url, status_code=200, content=yaml_bytes) - - monkeypatch.setattr(app_dsl_service.ssrf_proxy, "get", fake_get) - - service = AppDslService(MagicMock()) - result = service.import_app( - account=_account_mock(), - import_mode=ImportMode.YAML_URL, - yaml_url=yaml_url, - ) - - assert result.status == ImportStatus.PENDING - assert requested_urls == [raw_url] diff --git a/api/tests/unit_tests/services/test_async_workflow_service.py b/api/tests/unit_tests/services/test_async_workflow_service.py index 639e091041..ca6ff9dc63 100644 --- a/api/tests/unit_tests/services/test_async_workflow_service.py +++ b/api/tests/unit_tests/services/test_async_workflow_service.py @@ -73,11 +73,6 @@ class TestAsyncWorkflowService: mock_dispatcher = MagicMock() quota_workflow = MagicMock() - mock_get_workflow = MagicMock() - - mock_professional_task = MagicMock() - mock_team_task = MagicMock() - mock_sandbox_task = MagicMock() with ( patch.object( @@ -357,11 +352,12 @@ class TestAsyncWorkflowService: mock_session_context.__enter__.return_value = mock_session mock_session_context.__exit__.return_value = None + mock_sessionmaker = MagicMock() + mock_sessionmaker.return_value.begin.return_value = mock_session_context + with ( patch.object(async_workflow_service_module, "db", new=SimpleNamespace(engine=fake_engine)), - patch.object( - async_workflow_service_module, "Session", return_value=mock_session_context - ) as mock_session_class, + patch.object(async_workflow_service_module, "sessionmaker", mock_sessionmaker), patch.object( async_workflow_service_module, "SQLAlchemyWorkflowTriggerLogRepository", @@ -373,7 +369,7 @@ class TestAsyncWorkflowService: # Assert assert result == expected - mock_session_class.assert_called_once_with(fake_engine) + mock_sessionmaker.assert_called_once_with(fake_engine) mock_repo.get_by_id.assert_called_once_with("trigger-log-123", "tenant-123") def test_should_return_recent_logs_as_dict_list(self): @@ -391,9 +387,12 @@ class TestAsyncWorkflowService: mock_session_context.__enter__.return_value = mock_session mock_session_context.__exit__.return_value = None + mock_sessionmaker = MagicMock() + mock_sessionmaker.return_value.begin.return_value = mock_session_context + with ( patch.object(async_workflow_service_module, "db", new=SimpleNamespace(engine=MagicMock())), - patch.object(async_workflow_service_module, "Session", return_value=mock_session_context), + patch.object(async_workflow_service_module, "sessionmaker", mock_sessionmaker), patch.object( async_workflow_service_module, "SQLAlchemyWorkflowTriggerLogRepository", @@ -432,9 +431,12 @@ class TestAsyncWorkflowService: mock_session_context.__enter__.return_value = mock_session mock_session_context.__exit__.return_value = None + mock_sessionmaker = MagicMock() + mock_sessionmaker.return_value.begin.return_value = mock_session_context + with ( patch.object(async_workflow_service_module, "db", new=SimpleNamespace(engine=MagicMock())), - patch.object(async_workflow_service_module, "Session", return_value=mock_session_context), + patch.object(async_workflow_service_module, "sessionmaker", mock_sessionmaker), patch.object( async_workflow_service_module, "SQLAlchemyWorkflowTriggerLogRepository", diff --git a/api/tests/unit_tests/services/test_audio_service.py b/api/tests/unit_tests/services/test_audio_service.py index cede6671ce..af8fc1e84f 100644 --- a/api/tests/unit_tests/services/test_audio_service.py +++ b/api/tests/unit_tests/services/test_audio_service.py @@ -403,43 +403,6 @@ class TestAudioServiceTTS: voice="en-US-Neural", ) - @patch("services.audio_service.db.session", autospec=True) - @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_with_message_id_success(self, mock_model_manager_class, mock_db_session, factory): - """Test successful TTS with message ID.""" - # Arrange - app_model_config = factory.create_app_model_config_mock( - text_to_speech_dict={"enabled": True, "voice": "en-US-Neural"} - ) - app = factory.create_app_mock( - mode=AppMode.CHAT, - app_model_config=app_model_config, - ) - - message = factory.create_message_mock( - message_id="550e8400-e29b-41d4-a716-446655440000", - answer="Message answer text", - ) - - # Mock database lookup - mock_db_session.get.return_value = message - - # Mock ModelManager - mock_model_manager = mock_model_manager_class.return_value - mock_model_instance = MagicMock() - mock_model_instance.invoke_tts.return_value = b"audio from message" - mock_model_manager.get_default_model_instance.return_value = mock_model_instance - - # Act - result = AudioService.transcript_tts( - app_model=app, - message_id="550e8400-e29b-41d4-a716-446655440000", - ) - - # Assert - assert result == b"audio from message" - mock_model_instance.invoke_tts.assert_called_once() - @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_with_default_voice(self, mock_model_manager_class, factory): """Test TTS uses default voice when none specified.""" @@ -544,62 +507,6 @@ class TestAudioServiceTTS: with pytest.raises(ValueError, match="Text is required"): AudioService.transcript_tts(app_model=app, text=None) - @patch("services.audio_service.db.session") - def test_transcript_tts_returns_none_for_invalid_message_id(self, mock_db_session, factory): - """Test that TTS returns None for invalid message ID format.""" - # Arrange - app = factory.create_app_mock() - - # Act - result = AudioService.transcript_tts( - app_model=app, - message_id="invalid-uuid", - ) - - # Assert - assert result is None - - @patch("services.audio_service.db.session") - def test_transcript_tts_returns_none_for_nonexistent_message(self, mock_db_session, factory): - """Test that TTS returns None when message doesn't exist.""" - # Arrange - app = factory.create_app_mock() - - # Mock database lookup returning None - mock_db_session.get.return_value = None - - # Act - result = AudioService.transcript_tts( - app_model=app, - message_id="550e8400-e29b-41d4-a716-446655440000", - ) - - # Assert - assert result is None - - @patch("services.audio_service.db.session") - def test_transcript_tts_returns_none_for_empty_message_answer(self, mock_db_session, factory): - """Test that TTS returns None when message answer is empty.""" - # Arrange - app = factory.create_app_mock() - - message = factory.create_message_mock( - answer="", - status=MessageStatus.NORMAL, - ) - - # Mock database lookup - mock_db_session.get.return_value = message - - # Act - result = AudioService.transcript_tts( - app_model=app, - message_id="550e8400-e29b-41d4-a716-446655440000", - ) - - # Assert - assert result is None - @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_raises_error_when_no_voices_available(self, mock_model_manager_class, factory): """Test that TTS raises error when no voices are available.""" diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 168ab6cf0d..9ab0171eac 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -290,9 +290,19 @@ class TestBillingServiceSubscriptionInfo: # Arrange tenant_id = "tenant-123" expected_response = { - "subscription_plan": "professional", - "billing_cycle": "monthly", - "status": "active", + "enabled": True, + "subscription": {"plan": "professional", "interval": "month", "education": False}, + "members": {"size": 1, "limit": 50}, + "apps": {"size": 1, "limit": 200}, + "vector_space": {"size": 0.0, "limit": 20480}, + "knowledge_rate_limit": {"limit": 1000}, + "documents_upload_quota": {"size": 0, "limit": 1000}, + "annotation_quota_limit": {"size": 0, "limit": 5000}, + "docs_processing": "top-priority", + "can_replace_logo": True, + "model_load_balancing_enabled": True, + "knowledge_pipeline_publish_enabled": True, + "next_credit_reset_date": 1775952000, } mock_send_request.return_value = expected_response @@ -995,17 +1005,14 @@ class TestBillingServiceEdgeCases: yield mock def test_get_info_empty_response(self, mock_send_request): - """Test handling of empty billing info response.""" - # Arrange + """Empty response from billing API should raise ValidationError due to missing required fields.""" + from pydantic import ValidationError + tenant_id = "tenant-empty" mock_send_request.return_value = {} - # Act - result = BillingService.get_info(tenant_id) - - # Assert - assert result == {} - mock_send_request.assert_called_once() + with pytest.raises(ValidationError): + BillingService.get_info(tenant_id) def test_update_tenant_feature_plan_usage_zero_delta(self, mock_send_request): """Test updating tenant feature usage with zero delta (no change).""" @@ -1110,42 +1117,6 @@ class TestBillingServiceEdgeCases: # Assert assert result["history_id"] == history_id - def test_is_tenant_owner_or_admin_editor_role_raises_error(self): - """Test tenant owner/admin check raises error for editor role.""" - # Arrange - current_user = MagicMock(spec=Account) - current_user.id = "account-123" - current_user.current_tenant_id = "tenant-456" - - mock_join = MagicMock(spec=TenantAccountJoin) - mock_join.role = TenantAccountRole.EDITOR # Editor is not privileged - - with patch("services.billing_service.db.session") as mock_session: - mock_session.scalar.return_value = mock_join - - # Act & Assert - with pytest.raises(ValueError) as exc_info: - BillingService.is_tenant_owner_or_admin(current_user) - assert "Only team owner or team admin can perform this action" in str(exc_info.value) - - def test_is_tenant_owner_or_admin_dataset_operator_raises_error(self): - """Test tenant owner/admin check raises error for dataset operator role.""" - # Arrange - current_user = MagicMock(spec=Account) - current_user.id = "account-123" - current_user.current_tenant_id = "tenant-456" - - mock_join = MagicMock(spec=TenantAccountJoin) - mock_join.role = TenantAccountRole.DATASET_OPERATOR # Dataset operator is not privileged - - with patch("services.billing_service.db.session") as mock_session: - mock_session.scalar.return_value = mock_join - - # Act & Assert - with pytest.raises(ValueError) as exc_info: - BillingService.is_tenant_owner_or_admin(current_user) - assert "Only team owner or team admin can perform this action" in str(exc_info.value) - class TestBillingServiceSubscriptionOperations: """Unit tests for subscription operations in BillingService. @@ -1416,12 +1387,21 @@ class TestBillingServiceIntegrationScenarios: # Step 1: Get current billing info mock_send_request.return_value = { - "subscription_plan": "sandbox", - "billing_cycle": "monthly", - "status": "active", + "enabled": True, + "subscription": {"plan": "sandbox", "interval": "", "education": False}, + "members": {"size": 0, "limit": 1}, + "apps": {"size": 0, "limit": 5}, + "vector_space": {"size": 0.0, "limit": 50}, + "knowledge_rate_limit": {"limit": 10}, + "documents_upload_quota": {"size": 0, "limit": 50}, + "annotation_quota_limit": {"size": 0, "limit": 10}, + "docs_processing": "standard", + "can_replace_logo": False, + "model_load_balancing_enabled": False, + "knowledge_pipeline_publish_enabled": False, } current_info = BillingService.get_info(tenant_id) - assert current_info["subscription_plan"] == "sandbox" + assert current_info["subscription"]["plan"] == "sandbox" # Step 2: Get payment link for upgrade mock_send_request.return_value = {"payment_link": "https://payment.example.com/upgrade"} @@ -1535,3 +1515,140 @@ class TestBillingServiceIntegrationScenarios: mock_send_request.return_value = {"result": "success", "activated": True} activate_result = BillingService.EducationIdentity.activate(account, "token-123", "MIT", "student") assert activate_result["activated"] is True + + +class TestBillingServiceSubscriptionInfoDataType: + """Unit tests for data type coercion in BillingService.get_info + + 1. Verifies the get_info returns correct Python types for numeric fields + 2. Ensure the compatibility regardless of what results the upstream billing API returns + """ + + @pytest.fixture + def mock_send_request(self): + with patch.object(BillingService, "_send_request") as mock: + yield mock + + @pytest.fixture + def normal_billing_response(self) -> dict: + return { + "enabled": True, + "subscription": { + "plan": "team", + "interval": "year", + "education": False, + }, + "members": {"size": 10, "limit": 50}, + "apps": {"size": 80, "limit": 200}, + "vector_space": {"size": 5120.75, "limit": 20480}, + "knowledge_rate_limit": {"limit": 1000}, + "documents_upload_quota": {"size": 450, "limit": 1000}, + "annotation_quota_limit": {"size": 1200, "limit": 5000}, + "docs_processing": "top-priority", + "can_replace_logo": True, + "model_load_balancing_enabled": True, + "knowledge_pipeline_publish_enabled": True, + "next_credit_reset_date": 1745971200, + } + + @pytest.fixture + def string_billing_response(self) -> dict: + return { + "enabled": True, + "subscription": { + "plan": "team", + "interval": "year", + "education": False, + }, + "members": {"size": "10", "limit": "50"}, + "apps": {"size": "80", "limit": "200"}, + "vector_space": {"size": 5120.75, "limit": "20480"}, + "knowledge_rate_limit": {"limit": "1000"}, + "documents_upload_quota": {"size": "450", "limit": "1000"}, + "annotation_quota_limit": {"size": "1200", "limit": "5000"}, + "docs_processing": "top-priority", + "can_replace_logo": True, + "model_load_balancing_enabled": True, + "knowledge_pipeline_publish_enabled": True, + "next_credit_reset_date": "1745971200", + } + + @staticmethod + def _assert_billing_info_types(result: dict): + assert isinstance(result["enabled"], bool) + assert isinstance(result["subscription"]["plan"], str) + assert isinstance(result["subscription"]["interval"], str) + assert isinstance(result["subscription"]["education"], bool) + + assert isinstance(result["members"]["size"], int) + assert isinstance(result["members"]["limit"], int) + + assert isinstance(result["apps"]["size"], int) + assert isinstance(result["apps"]["limit"], int) + + assert isinstance(result["vector_space"]["size"], float) + assert isinstance(result["vector_space"]["limit"], int) + + assert isinstance(result["knowledge_rate_limit"]["limit"], int) + + assert isinstance(result["documents_upload_quota"]["size"], int) + assert isinstance(result["documents_upload_quota"]["limit"], int) + + assert isinstance(result["annotation_quota_limit"]["size"], int) + assert isinstance(result["annotation_quota_limit"]["limit"], int) + + assert isinstance(result["docs_processing"], str) + assert isinstance(result["can_replace_logo"], bool) + assert isinstance(result["model_load_balancing_enabled"], bool) + assert isinstance(result["knowledge_pipeline_publish_enabled"], bool) + if "next_credit_reset_date" in result: + assert isinstance(result["next_credit_reset_date"], int) + + def test_get_info_with_normal_types(self, mock_send_request, normal_billing_response): + """When the billing API returns native numeric types, get_info should preserve them.""" + mock_send_request.return_value = normal_billing_response + + result = BillingService.get_info("tenant-type-test") + + self._assert_billing_info_types(result) + mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"}) + + def test_get_info_with_string_types(self, mock_send_request, string_billing_response): + """When the billing API returns numeric values as strings, get_info should coerce them.""" + mock_send_request.return_value = string_billing_response + + result = BillingService.get_info("tenant-type-test") + + self._assert_billing_info_types(result) + mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"}) + + def test_get_info_without_optional_fields(self, mock_send_request, string_billing_response): + """NotRequired fields can be absent without raising.""" + del string_billing_response["next_credit_reset_date"] + mock_send_request.return_value = string_billing_response + + result = BillingService.get_info("tenant-type-test") + + assert "next_credit_reset_date" not in result + self._assert_billing_info_types(result) + + def test_get_info_with_extra_fields(self, mock_send_request, string_billing_response): + """Undefined fields are silently stripped by validate_python.""" + string_billing_response["new_feature"] = "something" + mock_send_request.return_value = string_billing_response + + result = BillingService.get_info("tenant-type-test") + + # extra fields are dropped by TypeAdapter on TypedDict + assert "new_feature" not in result + self._assert_billing_info_types(result) + + def test_get_info_missing_required_field_raises(self, mock_send_request, string_billing_response): + """Missing a required field should raise ValidationError.""" + from pydantic import ValidationError + + del string_billing_response["members"] + mock_send_request.return_value = string_billing_response + + with pytest.raises(ValidationError): + BillingService.get_info("tenant-type-test") diff --git a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py index 1926cb133a..1bbd214110 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py @@ -17,8 +17,7 @@ class TestClearFreePlanTenantExpiredLogs: def mock_session(self): """Create a mock database session.""" session = Mock(spec=Session) - session.query.return_value.filter.return_value.all.return_value = [] - session.query.return_value.filter.return_value.delete.return_value = 0 + session.scalars.return_value.all.return_value = [] return session @pytest.fixture @@ -54,18 +53,18 @@ class TestClearFreePlanTenantExpiredLogs: ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", []) # Should not call any database operations - mock_session.query.assert_not_called() + mock_session.scalars.assert_not_called() mock_storage.save.assert_not_called() def test_clear_message_related_tables_no_records_found(self, mock_session, sample_message_ids): """Test when no related records are found.""" with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.query.return_value.where.return_value.all.return_value = [] + mock_session.scalars.return_value.all.return_value = [] ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - # Should call query for each related table but find no records - assert mock_session.query.call_count > 0 + # Should call scalars for each related table but find no records + assert mock_session.scalars.call_count > 0 mock_storage.save.assert_not_called() def test_clear_message_related_tables_with_records_and_to_dict( @@ -73,7 +72,7 @@ class TestClearFreePlanTenantExpiredLogs: ): """Test when records are found and have to_dict method.""" with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.query.return_value.where.return_value.all.return_value = sample_records + mock_session.scalars.return_value.all.return_value = sample_records ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) @@ -104,7 +103,7 @@ class TestClearFreePlanTenantExpiredLogs: records.append(record) # Mock records for first table only, empty for others - mock_session.query.return_value.where.return_value.all.side_effect = [ + mock_session.scalars.return_value.all.side_effect = [ records, [], [], @@ -126,13 +125,13 @@ class TestClearFreePlanTenantExpiredLogs: with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: mock_storage.save.side_effect = Exception("Storage error") - mock_session.query.return_value.where.return_value.all.return_value = sample_records + mock_session.scalars.return_value.all.return_value = sample_records # Should not raise exception ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) # Should still delete records even if backup fails - assert mock_session.query.return_value.where.return_value.delete.called + assert mock_session.execute.called def test_clear_message_related_tables_serialization_error_continues(self, mock_session, sample_message_ids): """Test that method continues even when record serialization fails.""" @@ -141,23 +140,23 @@ class TestClearFreePlanTenantExpiredLogs: record.id = "record-1" record.to_dict.side_effect = Exception("Serialization error") - mock_session.query.return_value.where.return_value.all.return_value = [record] + mock_session.scalars.return_value.all.return_value = [record] # Should not raise exception ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) # Should still delete records even if serialization fails - assert mock_session.query.return_value.where.return_value.delete.called + assert mock_session.execute.called def test_clear_message_related_tables_deletion_called(self, mock_session, sample_message_ids, sample_records): """Test that deletion is called for found records.""" with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.query.return_value.where.return_value.all.return_value = sample_records + mock_session.scalars.return_value.all.return_value = sample_records ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - # Should call delete for each table that has records - assert mock_session.query.return_value.where.return_value.delete.called + # Should call execute(delete(...)) for each table that has records + assert mock_session.execute.called def test_clear_message_related_tables_all_serialization_fails_skips_backup_but_deletes( self, mock_session, sample_message_ids @@ -167,12 +166,12 @@ class TestClearFreePlanTenantExpiredLogs: record.to_dict.side_effect = Exception("Serialization error") with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.query.return_value.where.return_value.all.return_value = [record] + mock_session.scalars.return_value.all.return_value = [record] ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) mock_storage.save.assert_not_called() - assert mock_session.query.return_value.where.return_value.delete.called + assert mock_session.execute.called class _ImmediateFuture: @@ -209,8 +208,22 @@ def _session_wrapper_for_no_autoflush(session: Mock) -> Mock: return wrapper +def _sessionmaker_wrapper_for_begin(session: Mock) -> Mock: + """ + ClearFreePlanTenantExpiredLogs.process uses: with sessionmaker(db.engine).begin() as session: + so sessionmaker(db.engine) must return an object with a begin() method that returns a context manager. + """ + begin_cm = MagicMock() + begin_cm.__enter__.return_value = session + begin_cm.__exit__.return_value = None + + sessionmaker_result = MagicMock() + sessionmaker_result.begin.return_value = begin_cm + return sessionmaker_result + + def _session_wrapper_for_direct(session: Mock) -> Mock: - """ClearFreePlanTenantExpiredLogs.process uses: with Session(db.engine) as session:""" + """ClearFreePlanTenantExpiredLogs.process uses: with Session(db.engine) as session: (for old code paths)""" wrapper = MagicMock() wrapper.__enter__.return_value = session wrapper.__exit__.return_value = None @@ -249,60 +262,39 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) - conv1 = SimpleNamespace(id="c1", to_dict=lambda: {"id": "c1"}) log1 = SimpleNamespace(id="l1", to_dict=lambda: {"id": "l1"}) - def make_query_with_batches(batches: list[list[object]]): - q = MagicMock() - q.where.return_value = q - q.limit.return_value = q - q.all.side_effect = batches - q.delete.return_value = 1 - return q - msg_session_1 = MagicMock() - msg_session_1.query.side_effect = lambda model: ( - make_query_with_batches([[msg1], []]) if model == service_module.Message else MagicMock() - ) - msg_session_1.commit.return_value = None + msg_session_1.scalars.return_value.all.return_value = [msg1] msg_session_2 = MagicMock() - msg_session_2.query.side_effect = lambda model: ( - make_query_with_batches([[]]) if model == service_module.Message else MagicMock() - ) - msg_session_2.commit.return_value = None + msg_session_2.scalars.return_value.all.return_value = [] conv_session_1 = MagicMock() - conv_session_1.query.side_effect = lambda model: ( - make_query_with_batches([[conv1], []]) if model == service_module.Conversation else MagicMock() - ) - conv_session_1.commit.return_value = None + conv_session_1.scalars.return_value.all.return_value = [conv1] conv_session_2 = MagicMock() - conv_session_2.query.side_effect = lambda model: ( - make_query_with_batches([[]]) if model == service_module.Conversation else MagicMock() - ) - conv_session_2.commit.return_value = None + conv_session_2.scalars.return_value.all.return_value = [] wal_session_1 = MagicMock() - wal_session_1.query.side_effect = lambda model: ( - make_query_with_batches([[log1], []]) if model == service_module.WorkflowAppLog else MagicMock() - ) - wal_session_1.commit.return_value = None + wal_session_1.scalars.return_value.all.return_value = [log1] wal_session_2 = MagicMock() - wal_session_2.query.side_effect = lambda model: ( - make_query_with_batches([[]]) if model == service_module.WorkflowAppLog else MagicMock() - ) - wal_session_2.commit.return_value = None + wal_session_2.scalars.return_value.all.return_value = [] session_wrappers = [ - _session_wrapper_for_no_autoflush(msg_session_1), - _session_wrapper_for_no_autoflush(msg_session_2), - _session_wrapper_for_no_autoflush(conv_session_1), - _session_wrapper_for_no_autoflush(conv_session_2), - _session_wrapper_for_no_autoflush(wal_session_1), - _session_wrapper_for_no_autoflush(wal_session_2), + _sessionmaker_wrapper_for_begin(msg_session_1), + _sessionmaker_wrapper_for_begin(msg_session_2), + _sessionmaker_wrapper_for_begin(conv_session_1), + _sessionmaker_wrapper_for_begin(conv_session_2), + _sessionmaker_wrapper_for_begin(wal_session_1), + _sessionmaker_wrapper_for_begin(wal_session_2), ] - monkeypatch.setattr(service_module, "Session", lambda _engine: session_wrappers.pop(0)) + def fake_sessionmaker(*args, **kwargs): + if kwargs.get("autoflush") is False: + return session_wrappers.pop(0) + return object() + + monkeypatch.setattr(service_module, "sessionmaker", fake_sessionmaker) def fake_select(*_args, **_kwargs): stmt = MagicMock() @@ -319,8 +311,6 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) - run_repo = MagicMock() run_repo.get_expired_runs_batch.side_effect = [[SimpleNamespace(id="wr-1", to_dict=lambda: {"id": "wr-1"})], []] run_repo.delete_runs_by_ids.return_value = 1 - - monkeypatch.setattr(service_module, "sessionmaker", lambda **_kwargs: object()) monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_node_execution_repository", @@ -344,11 +334,9 @@ def test_process_with_tenant_ids_filters_by_plan_and_logs_errors(monkeypatch: py # Total tenant count query count_session = MagicMock() - count_query = MagicMock() - count_query.count.return_value = 2 - count_session.query.return_value = count_query + count_session.scalar.return_value = 2 - monkeypatch.setattr(service_module, "Session", lambda _engine: _session_wrapper_for_direct(count_session)) + monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: _sessionmaker_wrapper_for_begin(count_session)) # Avoid LocalProxy usage flask_app = service_module.Flask("test-app") @@ -411,35 +399,18 @@ def test_process_without_tenant_ids_batches_and_scales_interval(monkeypatch: pyt # Sessions used: # 1) total tenant count - # 2) per-batch tenant scan (count + tenant list) + # 2) per-batch tenant scan (interval counts + tenant list) total_session = MagicMock() - total_query = MagicMock() - total_query.count.return_value = 250 - total_session.query.return_value = total_query - - batch_session = MagicMock() - q1 = MagicMock() - q1.where.return_value = q1 - q1.count.return_value = 200 - q2 = MagicMock() - q2.where.return_value = q2 - q2.count.return_value = 200 - q3 = MagicMock() - q3.where.return_value = q3 - q3.count.return_value = 200 - q4 = MagicMock() - q4.where.return_value = q4 - q4.count.return_value = 50 # choose this interval, then scale it + total_session.scalar.return_value = 250 rows = [SimpleNamespace(id="tenant-a"), SimpleNamespace(id="tenant-b")] - q_rs = MagicMock() - q_rs.where.return_value = q_rs - q_rs.order_by.return_value = rows + batch_session = MagicMock() + # 4 test intervals queried: 200, 200, 200, 50 — breaks on 50 <= 100 (4th interval = 3h) + batch_session.scalar.side_effect = [200, 200, 200, 50] + batch_session.execute.return_value = rows - batch_session.query.side_effect = [q1, q2, q3, q4, q_rs] - - sessions = [_session_wrapper_for_direct(total_session), _session_wrapper_for_direct(batch_session)] - monkeypatch.setattr(service_module, "Session", lambda _engine: sessions.pop(0)) + sessions = [_sessionmaker_wrapper_for_begin(total_session), _sessionmaker_wrapper_for_begin(batch_session)] + monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: sessions.pop(0)) process_tenant_mock = MagicMock() monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock) @@ -454,10 +425,8 @@ def test_process_with_tenant_ids_emits_progress_every_100(monkeypatch: pytest.Mo monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) count_session = MagicMock() - count_query = MagicMock() - count_query.count.return_value = 100 - count_session.query.return_value = count_query - monkeypatch.setattr(service_module, "Session", lambda _engine: _session_wrapper_for_direct(count_session)) + count_session.scalar.return_value = 100 + monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: _sessionmaker_wrapper_for_begin(count_session)) flask_app = service_module.Flask("test-app") monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) @@ -503,28 +472,16 @@ def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval(mon monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) total_session = MagicMock() - total_query = MagicMock() - total_query.count.return_value = 250 - total_session.query.return_value = total_query - - batch_session = MagicMock() - # Count results for all 5 intervals, all > 100 => take the for-else path. - count_queries = [] - for _ in range(5): - q = MagicMock() - q.where.return_value = q - q.count.return_value = 200 - count_queries.append(q) + total_session.scalar.return_value = 250 rows = [SimpleNamespace(id="tenant-a")] - q_rs = MagicMock() - q_rs.where.return_value = q_rs - q_rs.order_by.return_value = rows + batch_session = MagicMock() + # All 5 intervals have > 100 tenants => for-else falls through to min interval (1h) + batch_session.scalar.side_effect = [200, 200, 200, 200, 200] + batch_session.execute.return_value = rows - batch_session.query.side_effect = [*count_queries, q_rs] - - sessions = [_session_wrapper_for_direct(total_session), _session_wrapper_for_direct(batch_session)] - monkeypatch.setattr(service_module, "Session", lambda _engine: sessions.pop(0)) + sessions = [_sessionmaker_wrapper_for_begin(total_session), _sessionmaker_wrapper_for_begin(batch_session)] + monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: sessions.pop(0)) process_tenant_mock = MagicMock() monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock) @@ -532,8 +489,7 @@ def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval(mon ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) assert process_tenant_mock.call_count == 1 - assert len(count_queries) == 5 - assert batch_session.query.call_count >= 6 + assert batch_session.scalar.call_count == 5 def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pytest.MonkeyPatch) -> None: @@ -555,18 +511,19 @@ def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pyte # Make message/conversation/workflow_app_log loops no-op (empty immediately) empty_session = MagicMock() - q_empty = MagicMock() - q_empty.where.return_value = q_empty - q_empty.limit.return_value = q_empty - q_empty.all.return_value = [] - empty_session.query.return_value = q_empty - empty_session.commit.return_value = None + empty_session.scalars.return_value.all.return_value = [] session_wrappers = [ - _session_wrapper_for_no_autoflush(empty_session), - _session_wrapper_for_no_autoflush(empty_session), - _session_wrapper_for_no_autoflush(empty_session), + _sessionmaker_wrapper_for_begin(empty_session), + _sessionmaker_wrapper_for_begin(empty_session), + _sessionmaker_wrapper_for_begin(empty_session), ] - monkeypatch.setattr(service_module, "Session", lambda _engine: session_wrappers.pop(0)) + + def fake_sessionmaker(*args, **kwargs): + if kwargs.get("autoflush") is False: + return session_wrappers.pop(0) + return object() + + monkeypatch.setattr(service_module, "sessionmaker", fake_sessionmaker) def fake_select(*_args, **_kwargs): stmt = MagicMock() @@ -592,8 +549,6 @@ def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pyte [], ] run_repo.delete_runs_by_ids.return_value = 2 - - monkeypatch.setattr(service_module, "sessionmaker", lambda **_kwargs: object()) monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_node_execution_repository", diff --git a/api/tests/unit_tests/services/test_conversation_service.py b/api/tests/unit_tests/services/test_conversation_service.py index a4359f00b8..68f4c51afe 100644 --- a/api/tests/unit_tests/services/test_conversation_service.py +++ b/api/tests/unit_tests/services/test_conversation_service.py @@ -435,36 +435,6 @@ class TestConversationServiceRename: assert conversation.name == "New Name" mock_db_session.commit.assert_called_once() - @patch("services.conversation_service.db.session") - @patch("services.conversation_service.ConversationService.get_conversation") - @patch("services.conversation_service.ConversationService.auto_generate_name") - def test_rename_with_auto_generate(self, mock_auto_generate, mock_get_conversation, mock_db_session): - """ - Test renaming conversation with auto-generation. - - Should call auto_generate_name when auto_generate is True. - """ - # Arrange - app_model = ConversationServiceTestDataFactory.create_app_mock() - user = ConversationServiceTestDataFactory.create_account_mock() - conversation = ConversationServiceTestDataFactory.create_conversation_mock() - - mock_get_conversation.return_value = conversation - mock_auto_generate.return_value = conversation - - # Act - result = ConversationService.rename( - app_model=app_model, - conversation_id="conv-123", - user=user, - name=None, - auto_generate=True, - ) - - # Assert - assert result == conversation - mock_auto_generate.assert_called_once_with(app_model, conversation) - class TestConversationServiceAutoGenerateName: """Test conversation auto-name generation operations.""" @@ -576,29 +546,6 @@ class TestConversationServiceDelete: mock_db_session.commit.assert_called_once() mock_delete_task.delay.assert_called_once_with(conversation.id) - @patch("services.conversation_service.db.session") - @patch("services.conversation_service.ConversationService.get_conversation") - def test_delete_handles_exception_and_rollback(self, mock_get_conversation, mock_db_session): - """ - Test deletion handles exceptions and rolls back transaction. - - Should rollback database changes when deletion fails. - """ - # Arrange - app_model = ConversationServiceTestDataFactory.create_app_mock() - user = ConversationServiceTestDataFactory.create_account_mock() - conversation = ConversationServiceTestDataFactory.create_conversation_mock() - - mock_get_conversation.return_value = conversation - mock_db_session.delete.side_effect = Exception("Database Error") - - # Act & Assert - with pytest.raises(Exception, match="Database Error"): - ConversationService.delete(app_model, "conv-123", user) - - # Assert rollback was called - mock_db_session.rollback.assert_called_once() - class TestConversationServiceConversationalVariable: """Test conversational variable operations.""" diff --git a/api/tests/unit_tests/services/test_dataset_service_dataset.py b/api/tests/unit_tests/services/test_dataset_service_dataset.py index 64741eb5bb..2913ae20fe 100644 --- a/api/tests/unit_tests/services/test_dataset_service_dataset.py +++ b/api/tests/unit_tests/services/test_dataset_service_dataset.py @@ -532,6 +532,9 @@ class TestDatasetServiceCreationAndUpdate: with ( patch.object(DatasetService, "_update_external_knowledge_binding") as update_binding, + patch( + "services.dataset_service.ExternalDatasetService.get_external_knowledge_api", return_value=object() + ) as get_external_knowledge_api, patch("services.dataset_service.naive_utc_now", return_value=now), patch("services.dataset_service.db") as mock_db, ): @@ -557,6 +560,7 @@ class TestDatasetServiceCreationAndUpdate: assert dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM assert dataset.updated_by == "user-1" assert dataset.updated_at is now + get_external_knowledge_api.assert_called_once_with("api-1", dataset.tenant_id) update_binding.assert_called_once_with("dataset-1", "knowledge-1", "api-1") mock_db.session.add.assert_called_once_with(dataset) mock_db.session.commit.assert_called_once() @@ -574,30 +578,62 @@ class TestDatasetServiceCreationAndUpdate: with pytest.raises(ValueError, match=message): DatasetService._update_external_dataset(dataset, payload, SimpleNamespace(id="user-1")) + def test_update_external_dataset_rejects_cross_tenant_external_api_id(self): + dataset = DatasetServiceUnitDataFactory.create_dataset_mock(dataset_id="dataset-1") + + with ( + patch( + "services.dataset_service.ExternalDatasetService.get_external_knowledge_api", + side_effect=ValueError("api template not found"), + ) as get_external_knowledge_api, + patch.object(DatasetService, "_update_external_knowledge_binding") as update_binding, + patch("services.dataset_service.db") as mock_db, + ): + with pytest.raises(ValueError, match="api template not found"): + DatasetService._update_external_dataset( + dataset, + { + "external_knowledge_id": "knowledge-1", + "external_knowledge_api_id": "foreign-api", + }, + SimpleNamespace(id="user-1"), + ) + + get_external_knowledge_api.assert_called_once_with("foreign-api", dataset.tenant_id) + update_binding.assert_not_called() + mock_db.session.commit.assert_not_called() + def test_update_external_knowledge_binding_updates_changed_binding_values(self): binding = SimpleNamespace(external_knowledge_id="old-knowledge", external_knowledge_api_id="old-api") session = MagicMock() - session.query.return_value.filter_by.return_value.first.return_value = binding + session.scalar.return_value = binding + session.add = MagicMock() session_context = _make_session_context(session) + mock_sessionmaker = MagicMock() + mock_sessionmaker.return_value.begin.return_value = session_context + with ( patch("services.dataset_service.db") as mock_db, - patch("services.dataset_service.Session", return_value=session_context), + patch("services.dataset_service.sessionmaker", mock_sessionmaker), ): DatasetService._update_external_knowledge_binding("dataset-1", "new-knowledge", "new-api") assert binding.external_knowledge_id == "new-knowledge" assert binding.external_knowledge_api_id == "new-api" - mock_db.session.add.assert_called_once_with(binding) + session.add.assert_called_once_with(binding) def test_update_external_knowledge_binding_raises_for_missing_binding(self): session = MagicMock() - session.query.return_value.filter_by.return_value.first.return_value = None + session.scalar.return_value = None session_context = _make_session_context(session) + mock_sessionmaker = MagicMock() + mock_sessionmaker.return_value.begin.return_value = session_context + with ( patch("services.dataset_service.db"), - patch("services.dataset_service.Session", return_value=session_context), + patch("services.dataset_service.sessionmaker", mock_sessionmaker), ): with pytest.raises(ValueError, match="External knowledge binding not found"): DatasetService._update_external_knowledge_binding("dataset-1", "knowledge-1", "api-1") diff --git a/api/tests/unit_tests/services/test_dataset_service_document.py b/api/tests/unit_tests/services/test_dataset_service_document.py index e5a2541da7..3f9386e704 100644 --- a/api/tests/unit_tests/services/test_dataset_service_document.py +++ b/api/tests/unit_tests/services/test_dataset_service_document.py @@ -129,7 +129,7 @@ class TestDocumentServiceQueryAndDownloadHelpers: def test_update_documents_need_summary_updates_matching_documents_and_commits(self): session = MagicMock() - session.query.return_value.filter.return_value.update.return_value = 2 + session.execute.return_value.rowcount = 2 with patch("services.dataset_service.session_factory") as session_factory_mock: session_factory_mock.create_session.return_value = _make_session_context(session) @@ -1069,6 +1069,33 @@ class TestDocumentServiceCreateValidation: assert len(knowledge_config.process_rule.rules.pre_processing_rules) == 1 assert knowledge_config.process_rule.rules.pre_processing_rules[0].enabled is False + def test_process_rule_args_validate_hierarchical_defaults_parent_mode_to_paragraph(self): + knowledge_config = KnowledgeConfig( + indexing_technique="economy", + data_source=DataSource( + info_list=InfoList( + data_source_type="upload_file", + file_info_list=FileInfo(file_ids=["file-1"]), + ) + ), + process_rule=ProcessRule( + mode="hierarchical", + rules=Rule( + pre_processing_rules=[ + PreProcessingRule(id="remove_extra_spaces", enabled=True), + ], + segmentation=Segmentation(separator="\n", max_tokens=1024), + subchunk_segmentation=Segmentation(separator="\n", max_tokens=512), + ), + ), + ) + + DocumentService.process_rule_args_validate(knowledge_config) + + assert knowledge_config.process_rule is not None + assert knowledge_config.process_rule.rules is not None + assert knowledge_config.process_rule.rules.parent_mode == "paragraph" + class TestDocumentServiceSaveDocumentWithDatasetId: """Unit tests for non-SQL validation branches in save_document_with_dataset_id.""" diff --git a/api/tests/unit_tests/services/test_datasource_provider_service.py b/api/tests/unit_tests/services/test_datasource_provider_service.py index bc4120e2af..c00a4938bb 100644 --- a/api/tests/unit_tests/services/test_datasource_provider_service.py +++ b/api/tests/unit_tests/services/test_datasource_provider_service.py @@ -40,7 +40,10 @@ class TestDatasourceProviderService: q returns itself for .filter_by(), .order_by(), .where() so any SQLAlchemy chaining pattern works without multiple brittle sub-mocks. """ - with patch("services.datasource_provider_service.Session") as mock_cls: + with ( + patch("services.datasource_provider_service.Session") as mock_cls, + patch("services.datasource_provider_service.sessionmaker") as mock_sm, + ): sess = MagicMock(spec=Session) q = MagicMock() @@ -63,6 +66,8 @@ class TestDatasourceProviderService: mock_cls.return_value.__enter__.return_value = sess mock_cls.return_value.no_autoflush.__enter__.return_value = sess + mock_sm.return_value.begin.return_value.__enter__.return_value = sess + mock_sm.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) yield sess @@ -174,11 +179,11 @@ class TestDatasourceProviderService: # ----------------------------------------------------------------------- def test_should_return_true_when_system_oauth_params_exist(self, service, mock_db_session): - mock_db_session.query().first.return_value = MagicMock() + mock_db_session.scalar.return_value = MagicMock() assert service.is_system_oauth_params_exist(make_id()) is True def test_should_return_false_when_system_oauth_params_missing(self, service, mock_db_session): - mock_db_session.query().first.return_value = None + mock_db_session.scalar.return_value = None assert service.is_system_oauth_params_exist(make_id()) is False # ----------------------------------------------------------------------- @@ -200,7 +205,7 @@ class TestDatasourceProviderService: def test_should_delete_tenant_config_when_removing_oauth_params(self, service, mock_db_session): service.remove_oauth_custom_client_params("t1", make_id()) - mock_db_session.query().delete.assert_called_once() + mock_db_session.execute.assert_called_once() # ----------------------------------------------------------------------- # setup_oauth_custom_client_params (315-351) @@ -212,14 +217,14 @@ class TestDatasourceProviderService: mock_db_session.add.assert_not_called() def test_should_create_new_config_when_none_exists(self, service, mock_db_session): - mock_db_session.query().first.return_value = None + mock_db_session.scalar.return_value = None with patch.object(service, "get_oauth_encrypter", return_value=(self._enc, None)): service.setup_oauth_custom_client_params("t1", make_id(), {"k": "v"}, True) mock_db_session.add.assert_called_once() def test_should_update_existing_config_when_record_found(self, service, mock_db_session): existing = MagicMock() - mock_db_session.query().first.return_value = existing + mock_db_session.scalar.return_value = existing with patch.object(service, "get_oauth_encrypter", return_value=(self._enc, None)): service.setup_oauth_custom_client_params("t1", make_id(), {"k": "v"}, False) mock_db_session.add.assert_not_called() # update in place, no add @@ -250,7 +255,7 @@ class TestDatasourceProviderService: def test_should_return_empty_dict_when_credential_not_found(self, service, mock_db_session, mock_user): with patch("services.datasource_provider_service.get_current_user", return_value=mock_user): - mock_db_session.query().first.return_value = None + mock_db_session.scalar.return_value = None assert service.get_datasource_credentials("t1", "prov", "org/plug") == {} def test_should_refresh_oauth_tokens_when_expired(self, service, mock_db_session, mock_user): @@ -259,14 +264,13 @@ class TestDatasourceProviderService: p.auth_type = "oauth2" p.expires_at = 0 # expired p.encrypted_credentials = {"tok": "x"} - mock_db_session.query().first.return_value = p + mock_db_session.scalar.return_value = p with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service, "get_oauth_client", return_value={"oc": "v"}), patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}), ): service.get_datasource_credentials("t1", "prov", "org/plug") - mock_db_session.commit.assert_called_once() def test_should_return_decrypted_credentials_when_api_key_not_expired(self, service, mock_db_session, mock_user): """API key credentials with expires_at=-1 skip refresh and return directly.""" @@ -274,7 +278,7 @@ class TestDatasourceProviderService: p.auth_type = "api_key" p.expires_at = -1 # sentinel: never expires p.encrypted_credentials = {"k": "v"} - mock_db_session.query().first.return_value = p + mock_db_session.scalar.return_value = p with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service, "decrypt_datasource_provider_credentials", return_value={"k": "plain"}), @@ -288,7 +292,7 @@ class TestDatasourceProviderService: p.auth_type = "api_key" p.expires_at = -1 p.encrypted_credentials = {} - mock_db_session.query().first.return_value = p + mock_db_session.scalar.return_value = p with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service, "decrypt_datasource_provider_credentials", return_value={"k": "v"}), @@ -302,7 +306,7 @@ class TestDatasourceProviderService: def test_should_return_empty_list_when_no_provider_credentials_exist(self, service, mock_db_session, mock_user): with patch("services.datasource_provider_service.get_current_user", return_value=mock_user): - mock_db_session.query().all.return_value = [] + mock_db_session.scalars.return_value.all.return_value = [] assert service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug") == [] def test_should_refresh_and_return_credentials_when_oauth_expired(self, service, mock_db_session, mock_user): @@ -310,7 +314,7 @@ class TestDatasourceProviderService: p.auth_type = "oauth2" p.expires_at = 0 p.encrypted_credentials = {"t": "x"} - mock_db_session.query().all.return_value = [p] + mock_db_session.scalars.return_value.all.return_value = [p] with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service, "get_oauth_client", return_value={"oc": "v"}), @@ -324,23 +328,21 @@ class TestDatasourceProviderService: # ----------------------------------------------------------------------- def test_should_raise_value_error_when_provider_not_found_on_name_update(self, service, mock_db_session): - mock_db_session.query().first.return_value = None + mock_db_session.scalar.return_value = None with pytest.raises(ValueError, match="not found"): service.update_datasource_provider_name("t1", make_id(), "new", "cred-id") def test_should_return_early_when_new_name_matches_current(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) p.name = "same" - mock_db_session.query().first.return_value = p + mock_db_session.scalar.return_value = p service.update_datasource_provider_name("t1", make_id(), "same", "cred-id") - mock_db_session.commit.assert_not_called() def test_should_raise_value_error_when_name_already_exists(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) p.name = "old_name" p.is_default = False - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 1 # conflict + mock_db_session.scalar.side_effect = [p, 1] # first: fetch provider, second: name conflict count with pytest.raises(ValueError, match="already exists"): service.update_datasource_provider_name("t1", make_id(), "new_name", "some-id") @@ -348,18 +350,16 @@ class TestDatasourceProviderService: p = MagicMock(spec=DatasourceProvider) p.name = "old_name" p.is_default = False - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.side_effect = [p, 0] # first: fetch provider, second: name conflict count service.update_datasource_provider_name("t1", make_id(), "new_name", "some-id") assert p.name == "new_name" - mock_db_session.commit.assert_called_once() # ----------------------------------------------------------------------- # set_default_datasource_provider (lines 277-303) # ----------------------------------------------------------------------- def test_should_raise_value_error_when_target_provider_not_found(self, service, mock_db_session): - mock_db_session.query().first.return_value = None + mock_db_session.scalar.return_value = None with pytest.raises(ValueError, match="not found"): service.set_default_datasource_provider("t1", make_id(), "bad-id") @@ -367,10 +367,9 @@ class TestDatasourceProviderService: target = MagicMock(spec=DatasourceProvider) target.provider = "provider" target.plugin_id = "org/plug" - mock_db_session.query().first.return_value = target + mock_db_session.scalar.return_value = target service.set_default_datasource_provider("t1", make_id(), "new-id") assert target.is_default is True - mock_db_session.commit.assert_called_once() # ----------------------------------------------------------------------- # get_oauth_encrypter (lines 404-420) @@ -427,13 +426,13 @@ class TestDatasourceProviderService: # ----------------------------------------------------------------------- def test_should_use_tenant_config_when_available(self, service, mock_db_session): - mock_db_session.query().first.return_value = MagicMock(client_params={"k": "v"}) + mock_db_session.scalar.return_value = MagicMock(client_params={"k": "v"}) with patch.object(service, "get_oauth_encrypter", return_value=(self._enc, None)): result = service.get_oauth_client("t1", make_id()) assert result == {"k": "dec"} def test_should_fallback_to_system_credentials_when_tenant_config_missing(self, service, mock_db_session): - mock_db_session.query().first.side_effect = [None, MagicMock(system_credentials={"k": "sys"})] + mock_db_session.scalar.side_effect = [None, MagicMock(system_credentials={"k": "sys"})] with ( patch.object(service.provider_manager, "fetch_datasource_provider"), patch("services.datasource_provider_service.PluginService.is_plugin_verified", return_value=True), @@ -443,7 +442,7 @@ class TestDatasourceProviderService: def test_should_raise_value_error_when_no_oauth_config_available(self, service, mock_db_session): """Neither tenant nor system credentials → raises ValueError.""" - mock_db_session.query().first.side_effect = [None, None] + mock_db_session.scalar.side_effect = [None, None] with ( patch.object(service.provider_manager, "fetch_datasource_provider"), patch("services.datasource_provider_service.PluginService.is_plugin_verified", return_value=False), @@ -456,16 +455,14 @@ class TestDatasourceProviderService: # ----------------------------------------------------------------------- def test_should_add_oauth_provider_successfully_when_name_is_unique(self, service, mock_db_session): - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.return_value = 0 with patch.object(service, "extract_secret_variables", return_value=[]): service.add_datasource_oauth_provider("new", "t1", make_id(), "http://cb", 9999, {}) mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() def test_should_auto_rename_when_oauth_provider_name_conflicts(self, service, mock_db_session): """Conflict on name results in auto-incremented name, not an error.""" - mock_db_session.query().count.return_value = 1 # conflict first, then auto-named - mock_db_session.query().all.return_value = [] + mock_db_session.scalar.return_value = 1 # conflict first, then auto-named with ( patch.object(service, "extract_secret_variables", return_value=[]), patch.object(service, "generate_next_datasource_provider_name", return_value="new_gen"), @@ -475,8 +472,7 @@ class TestDatasourceProviderService: def test_should_auto_generate_name_when_none_provided_for_oauth(self, service, mock_db_session): """name=None causes auto-generation via generate_next_datasource_provider_name.""" - mock_db_session.query().count.return_value = 0 - mock_db_session.query().all.return_value = [] + mock_db_session.scalar.return_value = 0 with ( patch.object(service, "extract_secret_variables", return_value=[]), patch.object(service, "generate_next_datasource_provider_name", return_value="auto"), @@ -485,13 +481,13 @@ class TestDatasourceProviderService: mock_db_session.add.assert_called_once() def test_should_encrypt_secret_fields_when_adding_oauth_provider(self, service, mock_db_session): - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.return_value = 0 with patch.object(service, "extract_secret_variables", return_value=["secret_key"]): service.add_datasource_oauth_provider("nm", "t1", make_id(), "http://cb", 9999, {"secret_key": "value"}) self._enc.encrypt_token.assert_called() def test_should_acquire_redis_lock_when_adding_oauth_provider(self, service, mock_db_session): - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.return_value = 0 with patch.object(service, "extract_secret_variables", return_value=[]): service.add_datasource_oauth_provider("nm", "t1", make_id(), "http://cb", 9999, {}) self._redis.lock.assert_called() @@ -501,42 +497,36 @@ class TestDatasourceProviderService: # ----------------------------------------------------------------------- def test_should_raise_value_error_when_credential_id_not_found_on_reauth(self, service, mock_db_session): - mock_db_session.query().first.return_value = None + mock_db_session.scalar.return_value = None with patch.object(service, "extract_secret_variables", return_value=[]): with pytest.raises(ValueError, match="not found"): service.reauthorize_datasource_oauth_provider("n", "t1", make_id(), "u", 1, {}, "bad-id") def test_should_reauthorize_and_commit_when_credential_found(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.side_effect = [p, 0] # first: fetch provider, second: name conflict count with patch.object(service, "extract_secret_variables", return_value=[]): service.reauthorize_datasource_oauth_provider("n", "t1", make_id(), "u", 1, {}, "oid") - mock_db_session.commit.assert_called_once() def test_should_auto_rename_when_reauth_name_conflicts(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 1 # conflict - mock_db_session.query().all.return_value = [] + mock_db_session.scalar.side_effect = [p, 1] # first: fetch provider, second: name conflict count + mock_db_session.scalars.return_value.all.return_value = [] with patch.object(service, "extract_secret_variables", return_value=["tok"]): service.reauthorize_datasource_oauth_provider( "conflict_name", "t1", make_id(), "u", 9999, {"tok": "v"}, "cred-id" ) - mock_db_session.commit.assert_called_once() def test_should_encrypt_secret_fields_when_reauthorizing(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.side_effect = [p, 0] # first: fetch provider, second: name conflict count with patch.object(service, "extract_secret_variables", return_value=["tok"]): service.reauthorize_datasource_oauth_provider(None, "t1", make_id(), "u", 9999, {"tok": "val"}, "cred-id") self._enc.encrypt_token.assert_called() def test_should_acquire_redis_lock_when_reauthorizing(self, service, mock_db_session): p = MagicMock(spec=DatasourceProvider) - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.side_effect = [p, 0] # first: fetch provider, second: name conflict count with patch.object(service, "extract_secret_variables", return_value=[]): service.reauthorize_datasource_oauth_provider("n", "t1", make_id(), "u", 1, {}, "oid") self._redis.lock.assert_called() @@ -547,13 +537,13 @@ class TestDatasourceProviderService: def test_should_raise_value_error_when_api_key_name_already_exists(self, service, mock_db_session, mock_user): """explicit name supplied + conflict → raises ValueError immediately.""" - mock_db_session.query().count.return_value = 1 + mock_db_session.scalar.return_value = 1 with patch("services.datasource_provider_service.get_current_user", return_value=mock_user): with pytest.raises(ValueError, match="already exists"): service.add_datasource_api_key_provider("clash", "t1", make_id(), {"sk": "v"}) def test_should_raise_value_error_when_credentials_validation_fails(self, service, mock_db_session, mock_user): - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.return_value = 0 with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service.provider_manager, "validate_provider_credentials", side_effect=Exception("bad cred")), @@ -563,7 +553,7 @@ class TestDatasourceProviderService: service.add_datasource_api_key_provider("nm", "t1", make_id(), {"k": "v"}) def test_should_add_api_key_provider_and_commit_when_valid(self, service, mock_db_session, mock_user): - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.return_value = 0 with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service.provider_manager, "validate_provider_credentials"), @@ -571,10 +561,9 @@ class TestDatasourceProviderService: ): service.add_datasource_api_key_provider(None, "t1", make_id(), {"sk": "v"}) mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() def test_should_acquire_redis_lock_when_adding_api_key_provider(self, service, mock_db_session, mock_user): - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.return_value = 0 with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service.provider_manager, "validate_provider_credentials"), @@ -697,7 +686,7 @@ class TestDatasourceProviderService: # ----------------------------------------------------------------------- def test_should_raise_value_error_when_credential_not_found_on_update(self, service, mock_db_session, mock_user): - mock_db_session.query().first.return_value = None + mock_db_session.scalar.return_value = None with patch("services.datasource_provider_service.get_current_user", return_value=mock_user): with pytest.raises(ValueError, match="not found"): service.update_datasource_credentials("t1", "id", "prov", "org/plug", {}, "name") @@ -707,8 +696,7 @@ class TestDatasourceProviderService: p.name = "old_name" p.auth_type = "api_key" p.encrypted_credentials = {"sk": "e"} - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 1 + mock_db_session.scalar.side_effect = [p, 1] # first: fetch provider, second: name conflict count with patch("services.datasource_provider_service.get_current_user", return_value=mock_user): with pytest.raises(ValueError, match="already exists"): service.update_datasource_credentials("t1", "id", "prov", "org/plug", {}, "new_name") @@ -720,8 +708,7 @@ class TestDatasourceProviderService: p.name = "old_name" p.auth_type = "api_key" p.encrypted_credentials = {"sk": "e"} - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.side_effect = [p, 0] # first: fetch provider, second: name conflict count with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service, "extract_secret_variables", return_value=["sk"]), @@ -736,8 +723,7 @@ class TestDatasourceProviderService: p.name = "old_name" p.auth_type = "api_key" p.encrypted_credentials = {"sk": "old_enc"} - mock_db_session.query().first.return_value = p - mock_db_session.query().count.return_value = 0 + mock_db_session.scalar.side_effect = [p, 0] # first: fetch provider, second: name conflict count with ( patch("services.datasource_provider_service.get_current_user", return_value=mock_user), patch.object(service, "extract_secret_variables", return_value=["sk"]), @@ -747,7 +733,6 @@ class TestDatasourceProviderService: # encrypter must have been called with the new secret value self._enc.encrypt_token.assert_called() # commit must be called exactly once - mock_db_session.commit.assert_called_once() # ----------------------------------------------------------------------- # remove_datasource_credentials (lines 980-997) @@ -758,7 +743,6 @@ class TestDatasourceProviderService: mock_db_session.scalar.return_value = p service.remove_datasource_credentials("t1", "id", "prov", "org/plug") mock_db_session.delete.assert_called_once_with(p) - mock_db_session.commit.assert_called_once() def test_should_do_nothing_when_credential_not_found_on_remove(self, service, mock_db_session): """No error raised; no delete called when record doesn't exist (lines 994 branch).""" diff --git a/api/tests/unit_tests/services/test_external_dataset_service.py b/api/tests/unit_tests/services/test_external_dataset_service.py index 7c8dab5029..9c1a92b4d9 100644 --- a/api/tests/unit_tests/services/test_external_dataset_service.py +++ b/api/tests/unit_tests/services/test_external_dataset_service.py @@ -974,26 +974,29 @@ class TestExternalDatasetServiceAPIUseCheck: """Test API use check when API has one binding.""" # Arrange api_id = "api-123" + tenant_id = "tenant-123" mock_db.session.scalar.return_value = 1 # Act - in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id) + in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id, tenant_id) # Assert assert in_use is True assert count == 1 + assert "tenant_id" in str(mock_db.session.scalar.call_args.args[0]) @patch("services.external_knowledge_service.db") def test_external_knowledge_api_use_check_in_use_multiple(self, mock_db, factory): """Test API use check with multiple bindings.""" # Arrange api_id = "api-123" + tenant_id = "tenant-123" mock_db.session.scalar.return_value = 10 # Act - in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id) + in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id, tenant_id) # Assert assert in_use is True @@ -1004,11 +1007,12 @@ class TestExternalDatasetServiceAPIUseCheck: """Test API use check when API is not in use.""" # Arrange api_id = "api-123" + tenant_id = "tenant-123" mock_db.session.scalar.return_value = 0 # Act - in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id) + in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id, tenant_id) # Assert assert in_use is False @@ -1556,6 +1560,17 @@ class TestExternalDatasetServiceFetchRetrieval: with pytest.raises(ValueError, match="external knowledge binding not found"): ExternalDatasetService.fetch_external_knowledge_retrieval("tenant-123", "dataset-123", "query", {}) + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_cross_tenant_api_template_error(self, mock_db, factory): + """Test error when a binding points to an API template outside the dataset tenant.""" + # Arrange + binding = factory.create_external_knowledge_binding_mock() + mock_db.session.scalar.side_effect = [binding, None] + + # Act & Assert + with pytest.raises(ValueError, match="external api template not found"): + ExternalDatasetService.fetch_external_knowledge_retrieval("tenant-123", "dataset-123", "query", {}) + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @patch("services.external_knowledge_service.db") def test_fetch_external_knowledge_retrieval_empty_results(self, mock_db, mock_process, factory): @@ -1687,7 +1702,7 @@ class TestExternalDatasetServiceFetchRetrieval: mock_process.return_value = mock_response # Act & Assert - with pytest.raises(Exception, match=""): + with pytest.raises(ValueError): ExternalDatasetService.fetch_external_knowledge_retrieval( "tenant-123", "dataset-123", "query", {"top_k": 5} ) diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index b7259c3e82..8e1b22886b 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -165,7 +165,7 @@ class TestFileService: upload_file = MagicMock(spec=UploadFile) upload_file.id = "file_id" upload_file.key = "test_key" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with patch("services.file_service.storage") as mock_storage: mock_storage.load_once.return_value = b"test content" @@ -178,7 +178,7 @@ class TestFileService: mock_storage.load_once.assert_called_once_with("test_key") def test_get_file_base64_not_found(self, file_service, mock_db_session): - mock_db_session.query().where().first.return_value = None + mock_db_session.scalar.return_value = None with pytest.raises(NotFound, match="File not found"): file_service.get_file_base64("non_existent") @@ -215,7 +215,7 @@ class TestFileService: upload_file = MagicMock(spec=UploadFile) upload_file.id = "file_id" upload_file.extension = "pdf" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with patch("services.file_service.ExtractProcessor.load_from_upload_file") as mock_extract: mock_extract.return_value = "Extracted text content" @@ -227,7 +227,7 @@ class TestFileService: assert result == "Extracted text content" def test_get_file_preview_not_found(self, file_service, mock_db_session): - mock_db_session.query().where().first.return_value = None + mock_db_session.scalar.return_value = None with pytest.raises(NotFound, match="File not found"): file_service.get_file_preview("non_existent") @@ -235,7 +235,7 @@ class TestFileService: upload_file = MagicMock(spec=UploadFile) upload_file.id = "file_id" upload_file.extension = "exe" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with pytest.raises(UnsupportedFileTypeError): file_service.get_file_preview("file_id") @@ -246,7 +246,7 @@ class TestFileService: upload_file.extension = "jpg" upload_file.mime_type = "image/jpeg" upload_file.key = "key" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with ( patch("services.file_service.file_helpers.verify_image_signature") as mock_verify, @@ -269,7 +269,7 @@ class TestFileService: file_service.get_image_preview("file_id", "ts", "nonce", "sign") def test_get_image_preview_not_found(self, file_service, mock_db_session): - mock_db_session.query().where().first.return_value = None + mock_db_session.scalar.return_value = None with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify: mock_verify.return_value = True with pytest.raises(NotFound, match="File not found or signature is invalid"): @@ -279,7 +279,7 @@ class TestFileService: upload_file = MagicMock(spec=UploadFile) upload_file.id = "file_id" upload_file.extension = "txt" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify: mock_verify.return_value = True with pytest.raises(UnsupportedFileTypeError): @@ -289,7 +289,7 @@ class TestFileService: upload_file = MagicMock(spec=UploadFile) upload_file.id = "file_id" upload_file.key = "key" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with ( patch("services.file_service.file_helpers.verify_file_signature") as mock_verify, @@ -309,7 +309,7 @@ class TestFileService: file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign") def test_get_file_generator_by_file_id_not_found(self, file_service, mock_db_session): - mock_db_session.query().where().first.return_value = None + mock_db_session.scalar.return_value = None with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify: mock_verify.return_value = True with pytest.raises(NotFound, match="File not found or signature is invalid"): @@ -321,7 +321,7 @@ class TestFileService: upload_file.extension = "png" upload_file.mime_type = "image/png" upload_file.key = "key" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with patch("services.file_service.storage") as mock_storage: mock_storage.load.return_value = b"image content" @@ -330,7 +330,7 @@ class TestFileService: assert mime == "image/png" def test_get_public_image_preview_not_found(self, file_service, mock_db_session): - mock_db_session.query().where().first.return_value = None + mock_db_session.scalar.return_value = None with pytest.raises(NotFound, match="File not found or signature is invalid"): file_service.get_public_image_preview("file_id") @@ -338,7 +338,7 @@ class TestFileService: upload_file = MagicMock(spec=UploadFile) upload_file.id = "file_id" upload_file.extension = "txt" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with pytest.raises(UnsupportedFileTypeError): file_service.get_public_image_preview("file_id") @@ -346,7 +346,7 @@ class TestFileService: upload_file = MagicMock(spec=UploadFile) upload_file.id = "file_id" upload_file.key = "key" - mock_db_session.query().where().first.return_value = upload_file + mock_db_session.scalar.return_value = upload_file with patch("services.file_service.storage") as mock_storage: mock_storage.load.return_value = b"hello world" @@ -354,7 +354,7 @@ class TestFileService: assert result == "hello world" def test_get_file_content_not_found(self, file_service, mock_db_session): - mock_db_session.query().where().first.return_value = None + mock_db_session.scalar.return_value = None with pytest.raises(NotFound, match="File not found"): file_service.get_file_content("file_id") diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py index b6e990ebe0..969132cfd8 100644 --- a/api/tests/unit_tests/services/test_message_service.py +++ b/api/tests/unit_tests/services/test_message_service.py @@ -131,9 +131,12 @@ class TestMessageServicePaginationByFirstId: assert result.has_more is False # Test 03: Basic pagination without first_id (desc order) + @patch("services.message_service._create_execution_extra_content_repository") @patch("services.message_service.db") @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_without_first_id_desc(self, mock_conversation_service, mock_db, factory): + def test_pagination_by_first_id_without_first_id_desc( + self, mock_conversation_service, mock_db, mock_create_repo, factory + ): """Test basic pagination without first_id in descending order.""" # Arrange app = factory.create_app_mock() @@ -171,9 +174,12 @@ class TestMessageServicePaginationByFirstId: assert result.data[0].id == "msg-000" # Test 04: Basic pagination without first_id (asc order) + @patch("services.message_service._create_execution_extra_content_repository") @patch("services.message_service.db") @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_without_first_id_asc(self, mock_conversation_service, mock_db, factory): + def test_pagination_by_first_id_without_first_id_asc( + self, mock_conversation_service, mock_db, mock_create_repo, factory + ): """Test basic pagination without first_id in ascending order.""" # Arrange app = factory.create_app_mock() @@ -211,9 +217,10 @@ class TestMessageServicePaginationByFirstId: assert result.data[4].id == "msg-000" # Test 05: Pagination with first_id + @patch("services.message_service._create_execution_extra_content_repository") @patch("services.message_service.db") @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_with_first_id(self, mock_conversation_service, mock_db, factory): + def test_pagination_by_first_id_with_first_id(self, mock_conversation_service, mock_db, mock_create_repo, factory): """Test pagination with first_id to get messages before a specific message.""" # Arrange app = factory.create_app_mock() @@ -278,9 +285,10 @@ class TestMessageServicePaginationByFirstId: ) # Test 07: Has_more flag when results exceed limit + @patch("services.message_service._create_execution_extra_content_repository") @patch("services.message_service.db") @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_has_more_true(self, mock_conversation_service, mock_db, factory): + def test_pagination_by_first_id_has_more_true(self, mock_conversation_service, mock_db, mock_create_repo, factory): """Test has_more flag is True when results exceed limit.""" # Arrange app = factory.create_app_mock() diff --git a/api/tests/unit_tests/services/test_model_provider_service.py b/api/tests/unit_tests/services/test_model_provider_service.py new file mode 100644 index 0000000000..756d3e9b59 --- /dev/null +++ b/api/tests/unit_tests/services/test_model_provider_service.py @@ -0,0 +1,602 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +from graphon.model_runtime.entities.common_entities import I18nObject +from graphon.model_runtime.entities.model_entities import FetchFrom, ModelType, ParameterRule, ParameterType + +from core.entities.model_entities import ModelStatus +from models.provider import ProviderType +from services import model_provider_service as service_module +from services.errors.app_model_config import ProviderNotFoundError +from services.model_provider_service import ModelProviderService + + +def _create_service_with_mocked_manager() -> tuple[ModelProviderService, MagicMock]: + manager = MagicMock() + service = ModelProviderService() + service._get_provider_manager = MagicMock(return_value=manager) + return service, manager + + +def _build_provider_configuration( + *, + provider_name: str = "openai", + supported_model_types: list[ModelType] | None = None, + custom_models: list[Any] | None = None, + custom_config_available: bool = True, +) -> SimpleNamespace: + if supported_model_types is None: + supported_model_types = [ModelType.LLM] + + return SimpleNamespace( + provider=SimpleNamespace( + provider=provider_name, + label=I18nObject(en_US=provider_name), + description=None, + icon_small=None, + icon_small_dark=None, + background=None, + help=None, + supported_model_types=supported_model_types, + configurate_methods=[], + provider_credential_schema=None, + model_credential_schema=None, + ), + preferred_provider_type=ProviderType.CUSTOM, + custom_configuration=SimpleNamespace( + provider=SimpleNamespace( + current_credential_id="cred-1", + current_credential_name="Credential 1", + available_credentials=[], + ), + models=custom_models, + can_added_models=[], + ), + system_configuration=SimpleNamespace(enabled=False, current_quota_type=None, quota_configurations=[]), + is_custom_configuration_available=lambda: custom_config_available, + ) + + +class TestModelProviderServiceConfiguration: + def test__get_provider_configuration_should_return_configuration_when_provider_exists(self) -> None: + service, manager = _create_service_with_mocked_manager() + provider_configuration = SimpleNamespace(name="provider-config") + manager.get_configurations.return_value = {"openai": provider_configuration} + + result = service._get_provider_configuration(tenant_id="tenant-1", provider="openai") + + assert result is provider_configuration + + def test__get_provider_configuration_should_raise_error_when_provider_is_missing(self) -> None: + service, manager = _create_service_with_mocked_manager() + manager.get_configurations.return_value = {} + + with pytest.raises(ProviderNotFoundError, match="does not exist"): + service._get_provider_configuration(tenant_id="tenant-1", provider="missing") + + def test_get_provider_list_should_filter_by_model_type_and_build_no_configure_status(self) -> None: + service, manager = _create_service_with_mocked_manager() + allowed = _build_provider_configuration( + provider_name="openai", + supported_model_types=[ModelType.LLM], + custom_config_available=False, + ) + filtered = _build_provider_configuration( + provider_name="embedding", + supported_model_types=[ModelType.TEXT_EMBEDDING], + custom_config_available=True, + ) + manager.get_configurations.return_value = {"openai": allowed, "embedding": filtered} + + result = service.get_provider_list(tenant_id="tenant-1", model_type=ModelType.LLM.value) + + assert len(result) == 1 + assert result[0].provider == "openai" + assert result[0].custom_configuration.status.value == "no-configure" + + def test_get_models_by_provider_should_wrap_model_entities_with_tenant_context(self) -> None: + service, manager = _create_service_with_mocked_manager() + + class _Model: + def __init__(self, model_name: str) -> None: + self.model_name = model_name + + def model_dump(self) -> dict[str, Any]: + return { + "model": self.model_name, + "label": {"en_US": self.model_name}, + "model_type": ModelType.LLM, + "features": [], + "fetch_from": FetchFrom.PREDEFINED_MODEL, + "model_properties": {}, + "deprecated": False, + "status": ModelStatus.ACTIVE, + "load_balancing_enabled": False, + "has_invalid_load_balancing_configs": False, + "provider": { + "provider": "openai", + "label": {"en_US": "OpenAI"}, + "icon_small": None, + "icon_small_dark": None, + "supported_model_types": [ModelType.LLM], + }, + } + + provider_configurations = SimpleNamespace( + get_models=MagicMock(return_value=[_Model("gpt-4o"), _Model("gpt-4o-mini")]) + ) + manager.get_configurations.return_value = provider_configurations + + result = service.get_models_by_provider(tenant_id="tenant-1", provider="openai") + + assert len(result) == 2 + assert result[0].model == "gpt-4o" + assert result[1].provider.provider == "openai" + provider_configurations.get_models.assert_called_once_with(provider="openai") + + +class TestModelProviderServiceDelegation: + @pytest.mark.parametrize( + ("method_name", "method_kwargs", "provider_method_name", "provider_call_kwargs", "provider_return"), + [ + ( + "get_provider_credential", + {"tenant_id": "tenant-1", "provider": "openai", "credential_id": "cred-1"}, + "get_provider_credential", + {"credential_id": "cred-1"}, + {"token": "abc"}, + ), + ( + "validate_provider_credentials", + {"tenant_id": "tenant-1", "provider": "openai", "credentials": {"token": "abc"}}, + "validate_provider_credentials", + ({"token": "abc"},), + None, + ), + ( + "create_provider_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "credentials": {"token": "abc"}, + "credential_name": "A", + }, + "create_provider_credential", + ({"token": "abc"}, "A"), + None, + ), + ( + "update_provider_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "credentials": {"token": "abc"}, + "credential_id": "cred-1", + "credential_name": "B", + }, + "update_provider_credential", + {"credential_id": "cred-1", "credentials": {"token": "abc"}, "credential_name": "B"}, + None, + ), + ( + "remove_provider_credential", + {"tenant_id": "tenant-1", "provider": "openai", "credential_id": "cred-1"}, + "delete_provider_credential", + {"credential_id": "cred-1"}, + None, + ), + ( + "switch_active_provider_credential", + {"tenant_id": "tenant-1", "provider": "openai", "credential_id": "cred-1"}, + "switch_active_provider_credential", + {"credential_id": "cred-1"}, + None, + ), + ], + ) + def test_provider_credential_methods_should_delegate_to_provider_configuration( + self, + method_name: str, + method_kwargs: dict[str, Any], + provider_method_name: str, + provider_call_kwargs: Any, + provider_return: Any, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = ModelProviderService() + provider_configuration = MagicMock() + getattr(provider_configuration, provider_method_name).return_value = provider_return + get_provider_config_mock = MagicMock(return_value=provider_configuration) + monkeypatch.setattr(service, "_get_provider_configuration", get_provider_config_mock) + + result = getattr(service, method_name)(**method_kwargs) + + get_provider_config_mock.assert_called_once_with("tenant-1", "openai") + provider_method = getattr(provider_configuration, provider_method_name) + if isinstance(provider_call_kwargs, tuple): + provider_method.assert_called_once_with(*provider_call_kwargs) + elif isinstance(provider_call_kwargs, dict): + provider_method.assert_called_once_with(**provider_call_kwargs) + else: + provider_method.assert_called_once_with(provider_call_kwargs) + if method_name == "get_provider_credential": + assert result == {"token": "abc"} + + @pytest.mark.parametrize( + ("method_name", "method_kwargs", "provider_method_name", "expected_kwargs", "provider_return"), + [ + ( + "get_model_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": ModelType.LLM.value, + "model": "gpt-4o", + "credential_id": "cred-1", + }, + "get_custom_model_credential", + {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, + {"api_key": "x"}, + ), + ( + "validate_model_credentials", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": ModelType.LLM.value, + "model": "gpt-4o", + "credentials": {"api_key": "x"}, + }, + "validate_custom_model_credentials", + {"model_type": ModelType.LLM, "model": "gpt-4o", "credentials": {"api_key": "x"}}, + None, + ), + ( + "create_model_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": ModelType.LLM.value, + "model": "gpt-4o", + "credentials": {"api_key": "x"}, + "credential_name": "cred-a", + }, + "create_custom_model_credential", + { + "model_type": ModelType.LLM, + "model": "gpt-4o", + "credentials": {"api_key": "x"}, + "credential_name": "cred-a", + }, + None, + ), + ( + "update_model_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": ModelType.LLM.value, + "model": "gpt-4o", + "credentials": {"api_key": "x"}, + "credential_id": "cred-1", + "credential_name": "cred-b", + }, + "update_custom_model_credential", + { + "model_type": ModelType.LLM, + "model": "gpt-4o", + "credentials": {"api_key": "x"}, + "credential_id": "cred-1", + "credential_name": "cred-b", + }, + None, + ), + ( + "remove_model_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": ModelType.LLM.value, + "model": "gpt-4o", + "credential_id": "cred-1", + }, + "delete_custom_model_credential", + {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, + None, + ), + ( + "switch_active_custom_model_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": ModelType.LLM.value, + "model": "gpt-4o", + "credential_id": "cred-1", + }, + "switch_custom_model_credential", + {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, + None, + ), + ( + "add_model_credential_to_model_list", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": ModelType.LLM.value, + "model": "gpt-4o", + "credential_id": "cred-1", + }, + "add_model_credential_to_model", + {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, + None, + ), + ( + "remove_model", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": ModelType.LLM.value, + "model": "gpt-4o", + }, + "delete_custom_model", + {"model_type": ModelType.LLM, "model": "gpt-4o"}, + None, + ), + ], + ) + def test_custom_model_methods_should_convert_model_type_and_delegate( + self, + method_name: str, + method_kwargs: dict[str, Any], + provider_method_name: str, + expected_kwargs: dict[str, Any], + provider_return: Any, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = ModelProviderService() + provider_configuration = MagicMock() + getattr(provider_configuration, provider_method_name).return_value = provider_return + get_provider_config_mock = MagicMock(return_value=provider_configuration) + monkeypatch.setattr(service, "_get_provider_configuration", get_provider_config_mock) + + result = getattr(service, method_name)(**method_kwargs) + + get_provider_config_mock.assert_called_once_with("tenant-1", "openai") + getattr(provider_configuration, provider_method_name).assert_called_once_with(**expected_kwargs) + if method_name == "get_model_credential": + assert result == {"api_key": "x"} + + +class TestModelProviderServiceListingsAndDefaults: + def test_get_models_by_model_type_should_group_active_non_deprecated_models(self) -> None: + service, manager = _create_service_with_mocked_manager() + openai_provider = SimpleNamespace( + provider="openai", + label=I18nObject(en_US="OpenAI"), + icon_small=None, + icon_small_dark=None, + ) + anthropic_provider = SimpleNamespace( + provider="anthropic", + label=I18nObject(en_US="Anthropic"), + icon_small=None, + icon_small_dark=None, + ) + models = [ + SimpleNamespace( + provider=openai_provider, + model="gpt-4o", + label=I18nObject(en_US="GPT-4o"), + model_type=ModelType.LLM, + features=[], + fetch_from=FetchFrom.PREDEFINED_MODEL, + model_properties={}, + status=ModelStatus.ACTIVE, + load_balancing_enabled=False, + deprecated=False, + ), + SimpleNamespace( + provider=openai_provider, + model="old-openai", + label=I18nObject(en_US="Old OpenAI"), + model_type=ModelType.LLM, + features=[], + fetch_from=FetchFrom.PREDEFINED_MODEL, + model_properties={}, + status=ModelStatus.ACTIVE, + load_balancing_enabled=False, + deprecated=True, + ), + SimpleNamespace( + provider=anthropic_provider, + model="old-anthropic", + label=I18nObject(en_US="Old Anthropic"), + model_type=ModelType.LLM, + features=[], + fetch_from=FetchFrom.PREDEFINED_MODEL, + model_properties={}, + status=ModelStatus.ACTIVE, + load_balancing_enabled=False, + deprecated=True, + ), + ] + provider_configurations = SimpleNamespace(get_models=MagicMock(return_value=models)) + manager.get_configurations.return_value = provider_configurations + + result = service.get_models_by_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) + + provider_configurations.get_models.assert_called_once_with(model_type=ModelType.LLM, only_active=True) + assert len(result) == 1 + assert result[0].provider == "openai" + assert len(result[0].models) == 1 + assert result[0].models[0].model == "gpt-4o" + + @pytest.mark.parametrize( + ("credentials", "schema", "expected_count"), + [ + (None, None, 0), + ({"api_key": "x"}, None, 0), + ( + {"api_key": "x"}, + SimpleNamespace( + parameter_rules=[ + ParameterRule( + name="temperature", + label=I18nObject(en_US="Temperature"), + type=ParameterType.FLOAT, + ) + ] + ), + 1, + ), + ], + ) + def test_get_model_parameter_rules_should_handle_missing_credentials_and_schema( + self, + credentials: dict[str, Any] | None, + schema: Any, + expected_count: int, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = ModelProviderService() + provider_configuration = MagicMock() + provider_configuration.get_current_credentials.return_value = credentials + provider_configuration.get_model_schema.return_value = schema + monkeypatch.setattr(service, "_get_provider_configuration", MagicMock(return_value=provider_configuration)) + + result = service.get_model_parameter_rules(tenant_id="tenant-1", provider="openai", model="gpt-4o") + + assert len(result) == expected_count + provider_configuration.get_current_credentials.assert_called_once_with( + model_type=ModelType.LLM, + model="gpt-4o", + ) + if credentials: + provider_configuration.get_model_schema.assert_called_once_with( + model_type=ModelType.LLM, + model="gpt-4o", + credentials=credentials, + ) + else: + provider_configuration.get_model_schema.assert_not_called() + + def test_get_default_model_of_model_type_should_return_response_when_manager_returns_model(self) -> None: + service, manager = _create_service_with_mocked_manager() + manager.get_default_model.return_value = SimpleNamespace( + model="gpt-4o", + model_type=ModelType.LLM, + provider=SimpleNamespace( + provider="openai", + label=I18nObject(en_US="OpenAI"), + icon_small=None, + supported_model_types=[ModelType.LLM], + ), + ) + + result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) + + assert result is not None + assert result.model == "gpt-4o" + assert result.provider.provider == "openai" + manager.get_default_model.assert_called_once_with(tenant_id="tenant-1", model_type=ModelType.LLM) + + def test_get_default_model_of_model_type_should_return_none_when_manager_returns_none(self) -> None: + service, manager = _create_service_with_mocked_manager() + manager.get_default_model.return_value = None + + result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) + + assert result is None + + def test_get_default_model_of_model_type_should_return_none_when_manager_raises_exception(self) -> None: + service, manager = _create_service_with_mocked_manager() + manager.get_default_model.side_effect = RuntimeError("boom") + + result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) + + assert result is None + + def test_update_default_model_of_model_type_should_delegate_to_provider_manager(self) -> None: + service, manager = _create_service_with_mocked_manager() + + service.update_default_model_of_model_type( + tenant_id="tenant-1", + model_type=ModelType.LLM.value, + provider="openai", + model="gpt-4o", + ) + + manager.update_default_model_record.assert_called_once_with( + tenant_id="tenant-1", + model_type=ModelType.LLM, + provider="openai", + model="gpt-4o", + ) + + def test_get_model_provider_icon_should_fetch_icon_bytes_from_factory( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = ModelProviderService() + factory_instance = MagicMock() + factory_instance.get_provider_icon.return_value = (b"icon-bytes", "image/png") + factory_constructor = MagicMock(return_value=factory_instance) + monkeypatch.setattr(service_module, "create_plugin_model_provider_factory", factory_constructor) + + result = service.get_model_provider_icon( + tenant_id="tenant-1", + provider="openai", + icon_type="icon_small", + lang="en_US", + ) + + factory_constructor.assert_called_once_with(tenant_id="tenant-1") + factory_instance.get_provider_icon.assert_called_once_with("openai", "icon_small", "en_US") + assert result == (b"icon-bytes", "image/png") + + def test_switch_preferred_provider_should_convert_enum_and_delegate( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = ModelProviderService() + provider_configuration = MagicMock() + monkeypatch.setattr(service, "_get_provider_configuration", MagicMock(return_value=provider_configuration)) + + service.switch_preferred_provider( + tenant_id="tenant-1", + provider="openai", + preferred_provider_type=ProviderType.SYSTEM.value, + ) + + provider_configuration.switch_preferred_provider_type.assert_called_once_with(ProviderType.SYSTEM) + + @pytest.mark.parametrize( + ("method_name", "provider_method_name"), + [ + ("enable_model", "enable_model"), + ("disable_model", "disable_model"), + ], + ) + def test_model_enablement_methods_should_convert_model_type_and_delegate( + self, + method_name: str, + provider_method_name: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = ModelProviderService() + provider_configuration = MagicMock() + monkeypatch.setattr(service, "_get_provider_configuration", MagicMock(return_value=provider_configuration)) + + getattr(service, method_name)( + tenant_id="tenant-1", + provider="openai", + model="gpt-4o", + model_type=ModelType.LLM.value, + ) + + getattr(provider_configuration, provider_method_name).assert_called_once_with( + model="gpt-4o", + model_type=ModelType.LLM, + ) diff --git a/api/tests/unit_tests/services/test_model_provider_service_sanitization.py b/api/tests/unit_tests/services/test_model_provider_service_sanitization.py index acf5dff634..1bd979b9ec 100644 --- a/api/tests/unit_tests/services/test_model_provider_service_sanitization.py +++ b/api/tests/unit_tests/services/test_model_provider_service_sanitization.py @@ -85,644 +85,3 @@ def test_get_provider_list_strips_credentials(service_with_fake_configurations: assert len(custom_models) == 1 # The sanitizer should drop credentials in list response assert custom_models[0].credentials is None - - -# === Merged from test_model_provider_service.py === - - -from types import SimpleNamespace -from typing import Any -from unittest.mock import MagicMock - -import pytest -from graphon.model_runtime.entities.common_entities import I18nObject -from graphon.model_runtime.entities.model_entities import FetchFrom, ModelType, ParameterRule, ParameterType - -from core.entities.model_entities import ModelStatus -from models.provider import ProviderType -from services import model_provider_service as service_module -from services.errors.app_model_config import ProviderNotFoundError -from services.model_provider_service import ModelProviderService - - -def _create_service_with_mocked_manager() -> tuple[ModelProviderService, MagicMock]: - manager = MagicMock() - service = ModelProviderService() - service._get_provider_manager = MagicMock(return_value=manager) - return service, manager - - -def _build_provider_configuration( - *, - provider_name: str = "openai", - supported_model_types: list[ModelType] | None = None, - custom_models: list[Any] | None = None, - custom_config_available: bool = True, -) -> SimpleNamespace: - if supported_model_types is None: - supported_model_types = [ModelType.LLM] - return SimpleNamespace( - provider=SimpleNamespace( - provider=provider_name, - label=I18nObject(en_US=provider_name), - description=None, - icon_small=None, - icon_small_dark=None, - background=None, - help=None, - supported_model_types=supported_model_types, - configurate_methods=[], - provider_credential_schema=None, - model_credential_schema=None, - ), - preferred_provider_type=ProviderType.CUSTOM, - custom_configuration=SimpleNamespace( - provider=SimpleNamespace( - current_credential_id="cred-1", - current_credential_name="Credential 1", - available_credentials=[], - ), - models=custom_models, - can_added_models=[], - ), - system_configuration=SimpleNamespace(enabled=False, current_quota_type=None, quota_configurations=[]), - is_custom_configuration_available=lambda: custom_config_available, - ) - - -def test__get_provider_configuration_should_return_configuration_when_provider_exists() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - provider_configuration = SimpleNamespace(name="provider-config") - manager.get_configurations.return_value = {"openai": provider_configuration} - - # Act - result = service._get_provider_configuration(tenant_id="tenant-1", provider="openai") - - # Assert - assert result is provider_configuration - - -def test__get_provider_configuration_should_raise_error_when_provider_is_missing() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - manager.get_configurations.return_value = {} - - # Act / Assert - with pytest.raises(ProviderNotFoundError, match="does not exist"): - service._get_provider_configuration(tenant_id="tenant-1", provider="missing") - - -def test_get_provider_list_should_filter_by_model_type_and_build_no_configure_status() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - allowed = _build_provider_configuration( - provider_name="openai", - supported_model_types=[ModelType.LLM], - custom_config_available=False, - ) - filtered = _build_provider_configuration( - provider_name="embedding", - supported_model_types=[ModelType.TEXT_EMBEDDING], - custom_config_available=True, - ) - manager.get_configurations.return_value = {"openai": allowed, "embedding": filtered} - - # Act - result = service.get_provider_list(tenant_id="tenant-1", model_type=ModelType.LLM.value) - - # Assert - assert len(result) == 1 - assert result[0].provider == "openai" - assert result[0].custom_configuration.status.value == "no-configure" - - -def test_get_models_by_provider_should_wrap_model_entities_with_tenant_context() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - - class _Model: - def __init__(self, model_name: str) -> None: - self.model_name = model_name - - def model_dump(self) -> dict[str, Any]: - return { - "model": self.model_name, - "label": {"en_US": self.model_name}, - "model_type": ModelType.LLM, - "features": [], - "fetch_from": FetchFrom.PREDEFINED_MODEL, - "model_properties": {}, - "deprecated": False, - "status": ModelStatus.ACTIVE, - "load_balancing_enabled": False, - "has_invalid_load_balancing_configs": False, - "provider": { - "provider": "openai", - "label": {"en_US": "OpenAI"}, - "icon_small": None, - "icon_small_dark": None, - "supported_model_types": [ModelType.LLM], - }, - } - - provider_configurations = SimpleNamespace( - get_models=MagicMock(return_value=[_Model("gpt-4o"), _Model("gpt-4o-mini")]) - ) - manager.get_configurations.return_value = provider_configurations - - # Act - result = service.get_models_by_provider(tenant_id="tenant-1", provider="openai") - - # Assert - assert len(result) == 2 - assert result[0].model == "gpt-4o" - assert result[1].provider.provider == "openai" - provider_configurations.get_models.assert_called_once_with(provider="openai") - - -@pytest.mark.parametrize( - ("method_name", "method_kwargs", "provider_method_name", "provider_call_kwargs", "provider_return"), - [ - ( - "get_provider_credential", - {"tenant_id": "tenant-1", "provider": "openai", "credential_id": "cred-1"}, - "get_provider_credential", - {"credential_id": "cred-1"}, - {"token": "abc"}, - ), - ( - "validate_provider_credentials", - {"tenant_id": "tenant-1", "provider": "openai", "credentials": {"token": "abc"}}, - "validate_provider_credentials", - ({"token": "abc"},), - None, - ), - ( - "create_provider_credential", - {"tenant_id": "tenant-1", "provider": "openai", "credentials": {"token": "abc"}, "credential_name": "A"}, - "create_provider_credential", - ({"token": "abc"}, "A"), - None, - ), - ( - "update_provider_credential", - { - "tenant_id": "tenant-1", - "provider": "openai", - "credentials": {"token": "abc"}, - "credential_id": "cred-1", - "credential_name": "B", - }, - "update_provider_credential", - {"credential_id": "cred-1", "credentials": {"token": "abc"}, "credential_name": "B"}, - None, - ), - ( - "remove_provider_credential", - {"tenant_id": "tenant-1", "provider": "openai", "credential_id": "cred-1"}, - "delete_provider_credential", - {"credential_id": "cred-1"}, - None, - ), - ( - "switch_active_provider_credential", - {"tenant_id": "tenant-1", "provider": "openai", "credential_id": "cred-1"}, - "switch_active_provider_credential", - {"credential_id": "cred-1"}, - None, - ), - ], -) -def test_provider_credential_methods_should_delegate_to_provider_configuration( - method_name: str, - method_kwargs: dict[str, Any], - provider_method_name: str, - provider_call_kwargs: Any, - provider_return: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - service = ModelProviderService() - provider_configuration = MagicMock() - getattr(provider_configuration, provider_method_name).return_value = provider_return - get_provider_config_mock = MagicMock(return_value=provider_configuration) - monkeypatch.setattr(service, "_get_provider_configuration", get_provider_config_mock) - - # Act - result = getattr(service, method_name)(**method_kwargs) - - # Assert - get_provider_config_mock.assert_called_once_with("tenant-1", "openai") - provider_method = getattr(provider_configuration, provider_method_name) - if isinstance(provider_call_kwargs, tuple): - provider_method.assert_called_once_with(*provider_call_kwargs) - elif isinstance(provider_call_kwargs, dict): - provider_method.assert_called_once_with(**provider_call_kwargs) - else: - provider_method.assert_called_once_with(provider_call_kwargs) - if method_name == "get_provider_credential": - assert result == {"token": "abc"} - - -@pytest.mark.parametrize( - ("method_name", "method_kwargs", "provider_method_name", "expected_kwargs", "provider_return"), - [ - ( - "get_model_credential", - { - "tenant_id": "tenant-1", - "provider": "openai", - "model_type": ModelType.LLM.value, - "model": "gpt-4o", - "credential_id": "cred-1", - }, - "get_custom_model_credential", - {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, - {"api_key": "x"}, - ), - ( - "validate_model_credentials", - { - "tenant_id": "tenant-1", - "provider": "openai", - "model_type": ModelType.LLM.value, - "model": "gpt-4o", - "credentials": {"api_key": "x"}, - }, - "validate_custom_model_credentials", - {"model_type": ModelType.LLM, "model": "gpt-4o", "credentials": {"api_key": "x"}}, - None, - ), - ( - "create_model_credential", - { - "tenant_id": "tenant-1", - "provider": "openai", - "model_type": ModelType.LLM.value, - "model": "gpt-4o", - "credentials": {"api_key": "x"}, - "credential_name": "cred-a", - }, - "create_custom_model_credential", - { - "model_type": ModelType.LLM, - "model": "gpt-4o", - "credentials": {"api_key": "x"}, - "credential_name": "cred-a", - }, - None, - ), - ( - "update_model_credential", - { - "tenant_id": "tenant-1", - "provider": "openai", - "model_type": ModelType.LLM.value, - "model": "gpt-4o", - "credentials": {"api_key": "x"}, - "credential_id": "cred-1", - "credential_name": "cred-b", - }, - "update_custom_model_credential", - { - "model_type": ModelType.LLM, - "model": "gpt-4o", - "credentials": {"api_key": "x"}, - "credential_id": "cred-1", - "credential_name": "cred-b", - }, - None, - ), - ( - "remove_model_credential", - { - "tenant_id": "tenant-1", - "provider": "openai", - "model_type": ModelType.LLM.value, - "model": "gpt-4o", - "credential_id": "cred-1", - }, - "delete_custom_model_credential", - {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, - None, - ), - ( - "switch_active_custom_model_credential", - { - "tenant_id": "tenant-1", - "provider": "openai", - "model_type": ModelType.LLM.value, - "model": "gpt-4o", - "credential_id": "cred-1", - }, - "switch_custom_model_credential", - {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, - None, - ), - ( - "add_model_credential_to_model_list", - { - "tenant_id": "tenant-1", - "provider": "openai", - "model_type": ModelType.LLM.value, - "model": "gpt-4o", - "credential_id": "cred-1", - }, - "add_model_credential_to_model", - {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, - None, - ), - ( - "remove_model", - { - "tenant_id": "tenant-1", - "provider": "openai", - "model_type": ModelType.LLM.value, - "model": "gpt-4o", - }, - "delete_custom_model", - {"model_type": ModelType.LLM, "model": "gpt-4o"}, - None, - ), - ], -) -def test_custom_model_methods_should_convert_model_type_and_delegate( - method_name: str, - method_kwargs: dict[str, Any], - provider_method_name: str, - expected_kwargs: dict[str, Any], - provider_return: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - service = ModelProviderService() - provider_configuration = MagicMock() - getattr(provider_configuration, provider_method_name).return_value = provider_return - get_provider_config_mock = MagicMock(return_value=provider_configuration) - monkeypatch.setattr(service, "_get_provider_configuration", get_provider_config_mock) - - # Act - result = getattr(service, method_name)(**method_kwargs) - - # Assert - get_provider_config_mock.assert_called_once_with("tenant-1", "openai") - getattr(provider_configuration, provider_method_name).assert_called_once_with(**expected_kwargs) - if method_name == "get_model_credential": - assert result == {"api_key": "x"} - - -def test_get_models_by_model_type_should_group_active_non_deprecated_models() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - openai_provider = SimpleNamespace( - provider="openai", - label=I18nObject(en_US="OpenAI"), - icon_small=None, - icon_small_dark=None, - ) - anthropic_provider = SimpleNamespace( - provider="anthropic", - label=I18nObject(en_US="Anthropic"), - icon_small=None, - icon_small_dark=None, - ) - models = [ - SimpleNamespace( - provider=openai_provider, - model="gpt-4o", - label=I18nObject(en_US="GPT-4o"), - model_type=ModelType.LLM, - features=[], - fetch_from=FetchFrom.PREDEFINED_MODEL, - model_properties={}, - status=ModelStatus.ACTIVE, - load_balancing_enabled=False, - deprecated=False, - ), - SimpleNamespace( - provider=openai_provider, - model="old-openai", - label=I18nObject(en_US="Old OpenAI"), - model_type=ModelType.LLM, - features=[], - fetch_from=FetchFrom.PREDEFINED_MODEL, - model_properties={}, - status=ModelStatus.ACTIVE, - load_balancing_enabled=False, - deprecated=True, - ), - SimpleNamespace( - provider=anthropic_provider, - model="old-anthropic", - label=I18nObject(en_US="Old Anthropic"), - model_type=ModelType.LLM, - features=[], - fetch_from=FetchFrom.PREDEFINED_MODEL, - model_properties={}, - status=ModelStatus.ACTIVE, - load_balancing_enabled=False, - deprecated=True, - ), - ] - provider_configurations = SimpleNamespace(get_models=MagicMock(return_value=models)) - manager.get_configurations.return_value = provider_configurations - - # Act - result = service.get_models_by_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) - - # Assert - provider_configurations.get_models.assert_called_once_with(model_type=ModelType.LLM, only_active=True) - assert len(result) == 1 - assert result[0].provider == "openai" - assert len(result[0].models) == 1 - assert result[0].models[0].model == "gpt-4o" - - -@pytest.mark.parametrize( - ("credentials", "schema", "expected_count"), - [ - (None, None, 0), - ({"api_key": "x"}, None, 0), - ( - {"api_key": "x"}, - SimpleNamespace( - parameter_rules=[ - ParameterRule( - name="temperature", - label=I18nObject(en_US="Temperature"), - type=ParameterType.FLOAT, - ) - ] - ), - 1, - ), - ], -) -def test_get_model_parameter_rules_should_handle_missing_credentials_and_schema( - credentials: dict[str, Any] | None, - schema: Any, - expected_count: int, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - service = ModelProviderService() - provider_configuration = MagicMock() - provider_configuration.get_current_credentials.return_value = credentials - provider_configuration.get_model_schema.return_value = schema - monkeypatch.setattr(service, "_get_provider_configuration", MagicMock(return_value=provider_configuration)) - - # Act - result = service.get_model_parameter_rules(tenant_id="tenant-1", provider="openai", model="gpt-4o") - - # Assert - assert len(result) == expected_count - provider_configuration.get_current_credentials.assert_called_once_with(model_type=ModelType.LLM, model="gpt-4o") - if credentials: - provider_configuration.get_model_schema.assert_called_once_with( - model_type=ModelType.LLM, - model="gpt-4o", - credentials=credentials, - ) - else: - provider_configuration.get_model_schema.assert_not_called() - - -def test_get_default_model_of_model_type_should_return_response_when_manager_returns_model() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - manager.get_default_model.return_value = SimpleNamespace( - model="gpt-4o", - model_type=ModelType.LLM, - provider=SimpleNamespace( - provider="openai", - label=I18nObject(en_US="OpenAI"), - icon_small=None, - supported_model_types=[ModelType.LLM], - ), - ) - - # Act - result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) - - # Assert - assert result is not None - assert result.model == "gpt-4o" - assert result.provider.provider == "openai" - manager.get_default_model.assert_called_once_with(tenant_id="tenant-1", model_type=ModelType.LLM) - - -def test_get_default_model_of_model_type_should_return_none_when_manager_returns_none() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - manager.get_default_model.return_value = None - - # Act - result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) - - # Assert - assert result is None - - -def test_get_default_model_of_model_type_should_return_none_when_manager_raises_exception() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - manager.get_default_model.side_effect = RuntimeError("boom") - - # Act - result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) - - # Assert - assert result is None - - -def test_update_default_model_of_model_type_should_delegate_to_provider_manager() -> None: - # Arrange - service, manager = _create_service_with_mocked_manager() - - # Act - service.update_default_model_of_model_type( - tenant_id="tenant-1", - model_type=ModelType.LLM.value, - provider="openai", - model="gpt-4o", - ) - - # Assert - manager.update_default_model_record.assert_called_once_with( - tenant_id="tenant-1", - model_type=ModelType.LLM, - provider="openai", - model="gpt-4o", - ) - - -def test_get_model_provider_icon_should_fetch_icon_bytes_from_factory(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - service = ModelProviderService() - factory_instance = MagicMock() - factory_instance.get_provider_icon.return_value = (b"icon-bytes", "image/png") - factory_constructor = MagicMock(return_value=factory_instance) - monkeypatch.setattr(service_module, "create_plugin_model_provider_factory", factory_constructor) - - # Act - result = service.get_model_provider_icon( - tenant_id="tenant-1", - provider="openai", - icon_type="icon_small", - lang="en_US", - ) - - # Assert - factory_constructor.assert_called_once_with(tenant_id="tenant-1") - factory_instance.get_provider_icon.assert_called_once_with("openai", "icon_small", "en_US") - assert result == (b"icon-bytes", "image/png") - - -def test_switch_preferred_provider_should_convert_enum_and_delegate(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - service = ModelProviderService() - provider_configuration = MagicMock() - monkeypatch.setattr(service, "_get_provider_configuration", MagicMock(return_value=provider_configuration)) - - # Act - service.switch_preferred_provider( - tenant_id="tenant-1", - provider="openai", - preferred_provider_type=ProviderType.SYSTEM.value, - ) - - # Assert - provider_configuration.switch_preferred_provider_type.assert_called_once_with(ProviderType.SYSTEM) - - -@pytest.mark.parametrize( - ("method_name", "provider_method_name"), - [ - ("enable_model", "enable_model"), - ("disable_model", "disable_model"), - ], -) -def test_model_enablement_methods_should_convert_model_type_and_delegate( - method_name: str, - provider_method_name: str, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - service = ModelProviderService() - provider_configuration = MagicMock() - monkeypatch.setattr(service, "_get_provider_configuration", MagicMock(return_value=provider_configuration)) - - # Act - getattr(service, method_name)( - tenant_id="tenant-1", - provider="openai", - model="gpt-4o", - model_type=ModelType.LLM.value, - ) - - # Assert - getattr(provider_configuration, provider_method_name).assert_called_once_with( - model="gpt-4o", - model_type=ModelType.LLM, - ) diff --git a/api/tests/unit_tests/services/test_ops_service.py b/api/tests/unit_tests/services/test_ops_service.py deleted file mode 100644 index 7067e3b3dd..0000000000 --- a/api/tests/unit_tests/services/test_ops_service.py +++ /dev/null @@ -1,392 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest - -from core.ops.entities.config_entity import TracingProviderEnum -from models.model import App, TraceAppConfig -from services.ops_service import OpsService - - -class TestOpsService: - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_get_tracing_app_config_no_config(self, mock_ops_trace_manager, mock_db): - # Arrange - mock_db.session.scalar.return_value = None - - # Act - result = OpsService.get_tracing_app_config("app_id", "arize") - - # Assert - assert result is None - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_get_tracing_app_config_no_app(self, mock_ops_trace_manager, mock_db): - # Arrange - trace_config = MagicMock(spec=TraceAppConfig) - mock_db.session.scalar.return_value = trace_config - mock_db.session.get.return_value = None - - # Act - result = OpsService.get_tracing_app_config("app_id", "arize") - - # Assert - assert result is None - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_get_tracing_app_config_none_config(self, mock_ops_trace_manager, mock_db): - # Arrange - trace_config = MagicMock(spec=TraceAppConfig) - trace_config.tracing_config = None - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = trace_config - mock_db.session.get.return_value = app - - # Act & Assert - with pytest.raises(ValueError, match="Tracing config cannot be None."): - OpsService.get_tracing_app_config("app_id", "arize") - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - @pytest.mark.parametrize( - ("provider", "default_url"), - [ - ("arize", "https://app.arize.com/"), - ("phoenix", "https://app.phoenix.arize.com/projects/"), - ("langsmith", "https://smith.langchain.com/"), - ("opik", "https://www.comet.com/opik/"), - ("weave", "https://wandb.ai/"), - ("aliyun", "https://arms.console.aliyun.com/"), - ("tencent", "https://console.cloud.tencent.com/apm"), - ("mlflow", "http://localhost:5000/"), - ("databricks", "https://www.databricks.com/"), - ], - ) - def test_get_tracing_app_config_providers_exception(self, mock_ops_trace_manager, mock_db, provider, default_url): - # Arrange - trace_config = MagicMock(spec=TraceAppConfig) - trace_config.tracing_config = {"some": "config"} - trace_config.to_dict.return_value = {"tracing_config": {"project_url": default_url}} - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = trace_config - mock_db.session.get.return_value = app - - mock_ops_trace_manager.decrypt_tracing_config.return_value = {} - mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {} - mock_ops_trace_manager.get_trace_config_project_url.side_effect = Exception("error") - mock_ops_trace_manager.get_trace_config_project_key.side_effect = Exception("error") - - # Act - result = OpsService.get_tracing_app_config("app_id", provider) - - # Assert - assert result["tracing_config"]["project_url"] == default_url - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - @pytest.mark.parametrize( - "provider", ["arize", "phoenix", "langsmith", "opik", "weave", "aliyun", "tencent", "mlflow", "databricks"] - ) - def test_get_tracing_app_config_providers_success(self, mock_ops_trace_manager, mock_db, provider): - # Arrange - trace_config = MagicMock(spec=TraceAppConfig) - trace_config.tracing_config = {"some": "config"} - trace_config.to_dict.return_value = {"tracing_config": {"project_url": "success_url"}} - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = trace_config - mock_db.session.get.return_value = app - - mock_ops_trace_manager.decrypt_tracing_config.return_value = {} - mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {} - mock_ops_trace_manager.get_trace_config_project_url.return_value = "success_url" - - # Act - result = OpsService.get_tracing_app_config("app_id", provider) - - # Assert - assert result["tracing_config"]["project_url"] == "success_url" - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_get_tracing_app_config_langfuse_success(self, mock_ops_trace_manager, mock_db): - # Arrange - trace_config = MagicMock(spec=TraceAppConfig) - trace_config.tracing_config = {"some": "config"} - trace_config.to_dict.return_value = {"tracing_config": {"project_url": "https://api.langfuse.com/project/key"}} - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = trace_config - mock_db.session.get.return_value = app - - mock_ops_trace_manager.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"} - mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"} - mock_ops_trace_manager.get_trace_config_project_key.return_value = "key" - - # Act - result = OpsService.get_tracing_app_config("app_id", "langfuse") - - # Assert - assert result["tracing_config"]["project_url"] == "https://api.langfuse.com/project/key" - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_get_tracing_app_config_langfuse_exception(self, mock_ops_trace_manager, mock_db): - # Arrange - trace_config = MagicMock(spec=TraceAppConfig) - trace_config.tracing_config = {"some": "config"} - trace_config.to_dict.return_value = {"tracing_config": {"project_url": "https://api.langfuse.com/"}} - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = trace_config - mock_db.session.get.return_value = app - - mock_ops_trace_manager.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"} - mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"} - mock_ops_trace_manager.get_trace_config_project_key.side_effect = Exception("error") - - # Act - result = OpsService.get_tracing_app_config("app_id", "langfuse") - - # Assert - assert result["tracing_config"]["project_url"] == "https://api.langfuse.com/" - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_create_tracing_app_config_invalid_provider(self, mock_ops_trace_manager, mock_db): - # Act - result = OpsService.create_tracing_app_config("app_id", "invalid_provider", {}) - - # Assert - assert result == {"error": "Invalid tracing provider: invalid_provider"} - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_create_tracing_app_config_invalid_credentials(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.LANGFUSE - mock_ops_trace_manager.check_trace_config_is_effective.return_value = False - - # Act - result = OpsService.create_tracing_app_config("app_id", provider, {"public_key": "p", "secret_key": "s"}) - - # Assert - assert result == {"error": "Invalid Credentials"} - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - @pytest.mark.parametrize( - ("provider", "config"), - [ - (TracingProviderEnum.ARIZE, {}), - (TracingProviderEnum.LANGFUSE, {"public_key": "p", "secret_key": "s"}), - (TracingProviderEnum.LANGSMITH, {"api_key": "k", "project": "p"}), - (TracingProviderEnum.ALIYUN, {"license_key": "k", "endpoint": "https://aliyun.com"}), - ], - ) - def test_create_tracing_app_config_project_url_exception(self, mock_ops_trace_manager, mock_db, provider, config): - # Arrange - mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - mock_ops_trace_manager.get_trace_config_project_url.side_effect = Exception("error") - mock_ops_trace_manager.get_trace_config_project_key.side_effect = Exception("error") - mock_db.session.scalar.return_value = MagicMock(spec=TraceAppConfig) - - # Act - result = OpsService.create_tracing_app_config("app_id", provider, config) - - # Assert - assert result is None - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_create_tracing_app_config_langfuse_success(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.LANGFUSE - mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - mock_ops_trace_manager.get_trace_config_project_key.return_value = "key" - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = None - mock_db.session.get.return_value = app - mock_ops_trace_manager.encrypt_tracing_config.return_value = {} - - # Act - result = OpsService.create_tracing_app_config( - "app_id", provider, {"public_key": "p", "secret_key": "s", "host": "https://api.langfuse.com"} - ) - - # Assert - assert result == {"result": "success"} - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_create_tracing_app_config_already_exists(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.ARIZE - mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - mock_db.session.scalar.return_value = MagicMock(spec=TraceAppConfig) - - # Act - result = OpsService.create_tracing_app_config("app_id", provider, {}) - - # Assert - assert result is None - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_create_tracing_app_config_no_app(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.ARIZE - mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - mock_db.session.scalar.return_value = None - mock_db.session.get.return_value = None - - # Act - result = OpsService.create_tracing_app_config("app_id", provider, {}) - - # Assert - assert result is None - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_create_tracing_app_config_with_empty_other_keys(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.ARIZE - mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = None - mock_db.session.get.return_value = app - mock_ops_trace_manager.encrypt_tracing_config.return_value = {} - - # Act - # 'project' is in other_keys for Arize - # provide an empty string for the project in the tracing_config - # create_tracing_app_config will replace it with the default from the model - result = OpsService.create_tracing_app_config("app_id", provider, {"project": ""}) - - # Assert - assert result == {"result": "success"} - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_create_tracing_app_config_success(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.ARIZE - mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - mock_ops_trace_manager.get_trace_config_project_url.return_value = "http://project_url" - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = None - mock_db.session.get.return_value = app - mock_ops_trace_manager.encrypt_tracing_config.return_value = {"encrypted": "config"} - - # Act - result = OpsService.create_tracing_app_config("app_id", provider, {}) - - # Assert - assert result == {"result": "success"} - mock_db.session.add.assert_called() - mock_db.session.commit.assert_called() - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_update_tracing_app_config_invalid_provider(self, mock_ops_trace_manager, mock_db): - # Act & Assert - with pytest.raises(ValueError, match="Invalid tracing provider: invalid_provider"): - OpsService.update_tracing_app_config("app_id", "invalid_provider", {}) - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_update_tracing_app_config_no_config(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.ARIZE - mock_db.session.scalar.return_value = None - - # Act - result = OpsService.update_tracing_app_config("app_id", provider, {}) - - # Assert - assert result is None - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_update_tracing_app_config_no_app(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.ARIZE - current_config = MagicMock(spec=TraceAppConfig) - mock_db.session.scalar.return_value = current_config - mock_db.session.get.return_value = None - - # Act - result = OpsService.update_tracing_app_config("app_id", provider, {}) - - # Assert - assert result is None - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_update_tracing_app_config_invalid_credentials(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.ARIZE - current_config = MagicMock(spec=TraceAppConfig) - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = current_config - mock_db.session.get.return_value = app - mock_ops_trace_manager.decrypt_tracing_config.return_value = {} - mock_ops_trace_manager.check_trace_config_is_effective.return_value = False - - # Act & Assert - with pytest.raises(ValueError, match="Invalid Credentials"): - OpsService.update_tracing_app_config("app_id", provider, {}) - - @patch("services.ops_service.db") - @patch("services.ops_service.OpsTraceManager") - def test_update_tracing_app_config_success(self, mock_ops_trace_manager, mock_db): - # Arrange - provider = TracingProviderEnum.ARIZE - current_config = MagicMock(spec=TraceAppConfig) - current_config.to_dict.return_value = {"some": "data"} - app = MagicMock(spec=App) - app.tenant_id = "tenant_id" - mock_db.session.scalar.return_value = current_config - mock_db.session.get.return_value = app - mock_ops_trace_manager.decrypt_tracing_config.return_value = {} - mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - - # Act - result = OpsService.update_tracing_app_config("app_id", provider, {}) - - # Assert - assert result == {"some": "data"} - mock_db.session.commit.assert_called_once() - - @patch("services.ops_service.db") - def test_delete_tracing_app_config_no_config(self, mock_db): - # Arrange - mock_db.session.scalar.return_value = None - - # Act - result = OpsService.delete_tracing_app_config("app_id", "arize") - - # Assert - assert result is None - - @patch("services.ops_service.db") - def test_delete_tracing_app_config_success(self, mock_db): - # Arrange - trace_config = MagicMock(spec=TraceAppConfig) - mock_db.session.scalar.return_value = trace_config - - # Act - result = OpsService.delete_tracing_app_config("app_id", "arize") - - # Assert - assert result is True - mock_db.session.delete.assert_called_with(trace_config) - mock_db.session.commit.assert_called_once() diff --git a/api/tests/unit_tests/services/test_recommended_app_service.py b/api/tests/unit_tests/services/test_recommended_app_service.py deleted file mode 100644 index 12bc84db87..0000000000 --- a/api/tests/unit_tests/services/test_recommended_app_service.py +++ /dev/null @@ -1,628 +0,0 @@ -""" -Comprehensive unit tests for RecommendedAppService. - -This test suite provides complete coverage of recommended app operations in Dify, -following TDD principles with the Arrange-Act-Assert pattern. - -## Test Coverage - -### 1. Get Recommended Apps and Categories (TestRecommendedAppServiceGetApps) -Tests fetching recommended apps with categories: -- Successful retrieval with recommended apps -- Fallback to builtin when no recommended apps -- Different language support -- Factory mode selection (remote, builtin, db) -- Empty result handling - -### 2. Get Recommend App Detail (TestRecommendedAppServiceGetDetail) -Tests fetching individual app details: -- Successful app detail retrieval -- Different factory modes -- App not found scenarios -- Language-specific details - -## Testing Approach - -- **Mocking Strategy**: All external dependencies (dify_config, RecommendAppRetrievalFactory) - are mocked for fast, isolated unit tests -- **Factory Pattern**: Tests verify correct factory selection based on mode -- **Fixtures**: Mock objects are configured per test method -- **Assertions**: Each test verifies return values and factory method calls - -## Key Concepts - -**Factory Modes:** -- remote: Fetch from remote API -- builtin: Use built-in templates -- db: Fetch from database - -**Fallback Logic:** -- If remote/db returns no apps, fallback to builtin en-US templates -- Ensures users always see some recommended apps -""" - -from unittest.mock import MagicMock, patch - -import pytest - -from services.recommended_app_service import RecommendedAppService - - -class RecommendedAppServiceTestDataFactory: - """ - Factory for creating test data and mock objects. - - Provides reusable methods to create consistent mock objects for testing - recommended app operations. - """ - - @staticmethod - def create_recommended_apps_response( - recommended_apps: list[dict] | None = None, - categories: list[str] | None = None, - ) -> dict: - """ - Create a mock response for recommended apps. - - Args: - recommended_apps: List of recommended app dictionaries - categories: List of category names - - Returns: - Dictionary with recommended_apps and categories - """ - if recommended_apps is None: - recommended_apps = [ - { - "id": "app-1", - "name": "Test App 1", - "description": "Test description 1", - "category": "productivity", - }, - { - "id": "app-2", - "name": "Test App 2", - "description": "Test description 2", - "category": "communication", - }, - ] - if categories is None: - categories = ["productivity", "communication", "utilities"] - - return { - "recommended_apps": recommended_apps, - "categories": categories, - } - - @staticmethod - def create_app_detail_response( - app_id: str = "app-123", - name: str = "Test App", - description: str = "Test description", - **kwargs, - ) -> dict: - """ - Create a mock response for app detail. - - Args: - app_id: App identifier - name: App name - description: App description - **kwargs: Additional fields - - Returns: - Dictionary with app details - """ - detail = { - "id": app_id, - "name": name, - "description": description, - "category": kwargs.get("category", "productivity"), - "icon": kwargs.get("icon", "🚀"), - "model_config": kwargs.get("model_config", {}), - } - detail.update(kwargs) - return detail - - -@pytest.fixture -def factory(): - """Provide the test data factory to all tests.""" - return RecommendedAppServiceTestDataFactory - - -class TestRecommendedAppServiceGetApps: - """Test get_recommended_apps_and_categories operations.""" - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_success_with_apps(self, mock_config, mock_factory_class, factory): - """Test successful retrieval of recommended apps when apps are returned.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - - expected_response = factory.create_recommended_apps_response() - - # Mock factory and retrieval instance - mock_retrieval_instance = MagicMock() - mock_retrieval_instance.get_recommended_apps_and_categories.return_value = expected_response - - mock_factory = MagicMock() - mock_factory.return_value = mock_retrieval_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") - - # Assert - assert result == expected_response - assert len(result["recommended_apps"]) == 2 - assert len(result["categories"]) == 3 - mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote") - mock_retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_fallback_to_builtin_when_empty(self, mock_config, mock_factory_class, factory): - """Test fallback to builtin when no recommended apps are returned.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - - # Remote returns empty recommended_apps - empty_response = {"recommended_apps": [], "categories": []} - - # Builtin fallback response - builtin_response = factory.create_recommended_apps_response( - recommended_apps=[{"id": "builtin-1", "name": "Builtin App", "category": "default"}] - ) - - # Mock remote retrieval instance (returns empty) - mock_remote_instance = MagicMock() - mock_remote_instance.get_recommended_apps_and_categories.return_value = empty_response - - mock_remote_factory = MagicMock() - mock_remote_factory.return_value = mock_remote_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_remote_factory - - # Mock builtin retrieval instance - mock_builtin_instance = MagicMock() - mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response - mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("zh-CN") - - # Assert - assert result == builtin_response - assert len(result["recommended_apps"]) == 1 - assert result["recommended_apps"][0]["id"] == "builtin-1" - # Verify fallback was called with en-US (hardcoded) - mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_fallback_when_none_recommended_apps(self, mock_config, mock_factory_class, factory): - """Test fallback when recommended_apps key is None.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db" - - # Response with None recommended_apps - none_response = {"recommended_apps": None, "categories": ["test"]} - - # Builtin fallback response - builtin_response = factory.create_recommended_apps_response() - - # Mock db retrieval instance (returns None) - mock_db_instance = MagicMock() - mock_db_instance.get_recommended_apps_and_categories.return_value = none_response - - mock_db_factory = MagicMock() - mock_db_factory.return_value = mock_db_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_db_factory - - # Mock builtin retrieval instance - mock_builtin_instance = MagicMock() - mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response - mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") - - # Assert - assert result == builtin_response - mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once() - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_with_different_languages(self, mock_config, mock_factory_class, factory): - """Test retrieval with different language codes.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" - - languages = ["en-US", "zh-CN", "ja-JP", "fr-FR"] - - for language in languages: - # Create language-specific response - lang_response = factory.create_recommended_apps_response( - recommended_apps=[{"id": f"app-{language}", "name": f"App {language}", "category": "test"}] - ) - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommended_apps_and_categories.return_value = lang_response - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories(language) - - # Assert - assert result["recommended_apps"][0]["id"] == f"app-{language}" - mock_instance.get_recommended_apps_and_categories.assert_called_with(language) - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommended_apps_uses_correct_factory_mode(self, mock_config, mock_factory_class, factory): - """Test that correct factory is selected based on mode.""" - # Arrange - modes = ["remote", "builtin", "db"] - - for mode in modes: - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode - - response = factory.create_recommended_apps_response() - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommended_apps_and_categories.return_value = response - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - RecommendedAppService.get_recommended_apps_and_categories("en-US") - - # Assert - mock_factory_class.get_recommend_app_factory.assert_called_with(mode) - - -class TestRecommendedAppServiceGetDetail: - """Test get_recommend_app_detail operations.""" - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_success(self, mock_config, mock_factory_class, factory): - """Test successful retrieval of app detail.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - app_id = "app-123" - - expected_detail = factory.create_app_detail_response( - app_id=app_id, - name="Productivity App", - description="A great productivity app", - category="productivity", - ) - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = expected_detail - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result == expected_detail - assert result["id"] == app_id - assert result["name"] == "Productivity App" - mock_instance.get_recommend_app_detail.assert_called_once_with(app_id) - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_with_different_modes(self, mock_config, mock_factory_class, factory): - """Test app detail retrieval with different factory modes.""" - # Arrange - modes = ["remote", "builtin", "db"] - app_id = "test-app" - - for mode in modes: - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode - - detail = factory.create_app_detail_response(app_id=app_id, name=f"App from {mode}") - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = detail - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result["name"] == f"App from {mode}" - mock_factory_class.get_recommend_app_factory.assert_called_with(mode) - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_returns_none_when_not_found(self, mock_config, mock_factory_class, factory): - """Test that None is returned when app is not found.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - app_id = "nonexistent-app" - - # Mock retrieval instance returning None - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = None - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result is None - mock_instance.get_recommend_app_detail.assert_called_once_with(app_id) - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_returns_empty_dict(self, mock_config, mock_factory_class, factory): - """Test handling of empty dict response.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" - app_id = "app-empty" - - # Mock retrieval instance returning empty dict - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = {} - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result == {} - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config", autospec=True) - def test_get_recommend_app_detail_with_complex_model_config(self, mock_config, mock_factory_class, factory): - """Test app detail with complex model configuration.""" - # Arrange - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - app_id = "complex-app" - - complex_model_config = { - "provider": "openai", - "model": "gpt-4", - "parameters": { - "temperature": 0.7, - "max_tokens": 2000, - "top_p": 1.0, - }, - } - - expected_detail = factory.create_app_detail_response( - app_id=app_id, - name="Complex App", - model_config=complex_model_config, - workflows=["workflow-1", "workflow-2"], - tools=["tool-1", "tool-2", "tool-3"], - ) - - # Mock retrieval instance - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = expected_detail - - mock_factory = MagicMock() - mock_factory.return_value = mock_instance - mock_factory_class.get_recommend_app_factory.return_value = mock_factory - - # Act - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail(app_id)) - - # Assert - assert result["model_config"] == complex_model_config - assert len(result["workflows"]) == 2 - assert len(result["tools"]) == 3 - - -# === Merged from test_recommended_app_service_additional.py === - - -from types import SimpleNamespace -from typing import Any, cast -from unittest.mock import MagicMock - -import pytest - -from services import recommended_app_service as service_module -from services.recommended_app_service import RecommendedAppService - - -def _recommendation_detail(result: dict[str, Any] | None) -> dict[str, Any]: - return cast(dict[str, Any], result) - - -@pytest.fixture -def mocked_db_session(monkeypatch: pytest.MonkeyPatch) -> MagicMock: - # Arrange - session = MagicMock() - monkeypatch.setattr(service_module, "db", SimpleNamespace(session=session)) - - # Assert - return session - - -def _mock_factory_for_apps( - monkeypatch: pytest.MonkeyPatch, - *, - mode: str, - result: dict[str, Any], - fallback_result: dict[str, Any] | None = None, -) -> tuple[MagicMock, MagicMock]: - retrieval_instance = MagicMock() - retrieval_instance.get_recommended_apps_and_categories.return_value = result - retrieval_factory = MagicMock(return_value=retrieval_instance) - monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", mode, raising=False) - monkeypatch.setattr( - service_module.RecommendAppRetrievalFactory, - "get_recommend_app_factory", - MagicMock(return_value=retrieval_factory), - ) - - builtin_instance = MagicMock() - if fallback_result is not None: - builtin_instance.fetch_recommended_apps_from_builtin.return_value = fallback_result - monkeypatch.setattr( - service_module.RecommendAppRetrievalFactory, - "get_buildin_recommend_app_retrieval", - MagicMock(return_value=builtin_instance), - ) - return retrieval_instance, builtin_instance - - -def test_get_recommended_apps_and_categories_should_not_query_trial_table_when_trial_feature_disabled( - monkeypatch: pytest.MonkeyPatch, - mocked_db_session: MagicMock, -) -> None: - # Arrange - expected = {"recommended_apps": [{"app_id": "app-1"}], "categories": ["all"]} - retrieval_instance, builtin_instance = _mock_factory_for_apps( - monkeypatch, - mode="remote", - result=expected, - ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), - ) - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") - - # Assert - assert result == expected - retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") - builtin_instance.fetch_recommended_apps_from_builtin.assert_not_called() - mocked_db_session.scalar.assert_not_called() - - -def test_get_recommended_apps_and_categories_should_fallback_and_enrich_can_trial_when_trial_feature_enabled( - monkeypatch: pytest.MonkeyPatch, - mocked_db_session: MagicMock, -) -> None: - # Arrange - remote_result = {"recommended_apps": [], "categories": []} - fallback_result = {"recommended_apps": [{"app_id": "app-1"}, {"app_id": "app-2"}], "categories": ["all"]} - _, builtin_instance = _mock_factory_for_apps( - monkeypatch, - mode="remote", - result=remote_result, - fallback_result=fallback_result, - ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), - ) - mocked_db_session.scalar.side_effect = [SimpleNamespace(id="trial-app"), None] - - # Act - result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP") - - # Assert - builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") - assert result["recommended_apps"][0]["can_trial"] is True - assert result["recommended_apps"][1]["can_trial"] is False - assert mocked_db_session.scalar.call_count == 2 - - -@pytest.mark.parametrize( - ("trial_query_result", "expected_can_trial"), - [ - (SimpleNamespace(id="trial"), True), - (None, False), - ], -) -def test_get_recommend_app_detail_should_set_can_trial_when_trial_feature_enabled( - monkeypatch: pytest.MonkeyPatch, - mocked_db_session: MagicMock, - trial_query_result: Any, - expected_can_trial: bool, -) -> None: - # Arrange - detail = {"id": "app-1", "name": "Test App"} - retrieval_instance = MagicMock() - retrieval_instance.get_recommend_app_detail.return_value = detail - retrieval_factory = MagicMock(return_value=retrieval_instance) - monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False) - monkeypatch.setattr( - service_module.RecommendAppRetrievalFactory, - "get_recommend_app_factory", - MagicMock(return_value=retrieval_factory), - ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), - ) - mocked_db_session.scalar.return_value = trial_query_result - - # Act - result = cast(dict[str, Any], RecommendedAppService.get_recommend_app_detail("app-1")) - - # Assert - assert result["id"] == "app-1" - assert result["can_trial"] is expected_can_trial - mocked_db_session.scalar.assert_called_once() - - -def test_add_trial_app_record_should_increment_count_when_existing_record_found( - mocked_db_session: MagicMock, -) -> None: - # Arrange - existing_record = SimpleNamespace(count=3) - mocked_db_session.scalar.return_value = existing_record - - # Act - RecommendedAppService.add_trial_app_record("app-1", "account-1") - - # Assert - assert existing_record.count == 4 - mocked_db_session.scalar.assert_called_once() - mocked_db_session.commit.assert_called_once() - mocked_db_session.add.assert_not_called() - - -def test_add_trial_app_record_should_create_new_record_when_no_existing_record( - mocked_db_session: MagicMock, -) -> None: - # Arrange - mocked_db_session.scalar.return_value = None - - # Act - RecommendedAppService.add_trial_app_record("app-2", "account-2") - - # Assert - mocked_db_session.scalar.assert_called_once() - mocked_db_session.add.assert_called_once() - added = mocked_db_session.add.call_args.args[0] - assert added.app_id == "app-2" - assert added.account_id == "account-2" - assert added.count == 1 - mocked_db_session.commit.assert_called_once() diff --git a/api/tests/unit_tests/services/test_summary_index_service.py b/api/tests/unit_tests/services/test_summary_index_service.py index cbf3e121d8..e17d4134ac 100644 --- a/api/tests/unit_tests/services/test_summary_index_service.py +++ b/api/tests/unit_tests/services/test_summary_index_service.py @@ -124,10 +124,7 @@ def test_create_summary_record_updates_existing_and_reenables(monkeypatch: pytes existing.disabled_by = "u" session = MagicMock(name="session") - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = existing - session.query.return_value = query + session.scalar.return_value = existing create_session_mock = MagicMock(return_value=_SessionContext(session)) monkeypatch.setattr(summary_module, "session_factory", SimpleNamespace(create_session=create_session_mock)) @@ -149,10 +146,7 @@ def test_create_summary_record_updates_existing_and_reenables(monkeypatch: pytes def test_create_summary_record_creates_new(monkeypatch: pytest.MonkeyPatch) -> None: session = MagicMock(name="session") - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = None - session.query.return_value = query + session.scalar.return_value = None create_session_mock = MagicMock(return_value=_SessionContext(session)) monkeypatch.setattr(summary_module, "session_factory", SimpleNamespace(create_session=create_session_mock)) @@ -234,10 +228,7 @@ def test_vectorize_summary_without_session_creates_record_when_missing(monkeypat # New session used after vectorization succeeds (record not found by id nor chunk_id). session = MagicMock(name="session") - q1 = MagicMock() - q1.filter_by.return_value = q1 - q1.first.side_effect = [None, None] - session.query.return_value = q1 + session.scalar.side_effect = [None, None] create_session_mock = MagicMock(return_value=_SessionContext(session)) monkeypatch.setattr(summary_module, "session_factory", SimpleNamespace(create_session=create_session_mock)) @@ -267,10 +258,7 @@ def test_vectorize_summary_final_failure_updates_error_status(monkeypatch: pytes # error_session should find record and commit status update error_session = MagicMock(name="error_session") - q = MagicMock() - q.filter_by.return_value = q - q.first.return_value = summary - error_session.query.return_value = q + error_session.scalar.return_value = summary create_session_mock = MagicMock(return_value=_SessionContext(error_session)) monkeypatch.setattr(summary_module, "session_factory", SimpleNamespace(create_session=create_session_mock)) @@ -302,10 +290,7 @@ def test_batch_create_summary_records_creates_and_updates(monkeypatch: pytest.Mo existing.enabled = False session = MagicMock() - query = MagicMock() - query.filter.return_value = query - query.all.return_value = [existing] - session.query.return_value = query + session.scalars.return_value.all.return_value = [existing] monkeypatch.setattr( summary_module, @@ -324,10 +309,7 @@ def test_update_summary_record_error_updates_when_exists(monkeypatch: pytest.Mon record = _summary_record() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record monkeypatch.setattr( summary_module, "session_factory", @@ -346,10 +328,7 @@ def test_generate_and_vectorize_summary_success(monkeypatch: pytest.MonkeyPatch) record = _summary_record(summary_content="") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record monkeypatch.setattr( summary_module, @@ -373,10 +352,7 @@ def test_generate_and_vectorize_summary_vectorize_failure_sets_error(monkeypatch record = _summary_record(summary_content="") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record monkeypatch.setattr( summary_module, @@ -415,10 +391,7 @@ def test_vectorize_summary_updates_existing_record_found_by_chunk_id(monkeypatch existing = _summary_record(summary_content="old", node_id="old-node") existing.id = "other-id" session = MagicMock(name="session") - q = MagicMock() - q.filter_by.return_value = q - q.first.side_effect = [None, existing] # miss by id, hit by chunk_id - session.query.return_value = q + session.scalar.side_effect = [None, existing] # miss by id, hit by chunk_id monkeypatch.setattr( summary_module, "session_factory", @@ -448,10 +421,7 @@ def test_vectorize_summary_updates_existing_record_found_by_id(monkeypatch: pyte existing = _summary_record(summary_content="old", node_id="old-node") session = MagicMock(name="session") - q = MagicMock() - q.filter_by.return_value = q - q.first.return_value = existing # hit by id - session.query.return_value = q + session.scalar.return_value = existing # hit by id monkeypatch.setattr( summary_module, "session_factory", @@ -487,10 +457,7 @@ def test_vectorize_summary_session_enter_returns_none_triggers_runtime_error(mon return None error_session = MagicMock() - q = MagicMock() - q.filter_by.return_value = q - q.first.return_value = summary - error_session.query.return_value = q + error_session.scalar.return_value = summary create_session_mock = MagicMock(side_effect=[_BadContext(), _SessionContext(error_session)]) monkeypatch.setattr(summary_module, "session_factory", SimpleNamespace(create_session=create_session_mock)) @@ -516,21 +483,17 @@ def test_vectorize_summary_created_record_becomes_none_triggers_guard(monkeypatc ) session = MagicMock() - q = MagicMock() - q.filter_by.return_value = q - q.first.side_effect = [None, None] # miss by id and chunk_id - session.query.return_value = q + session.scalar.side_effect = [None, None] # miss by id and chunk_id error_session = MagicMock() - eq = MagicMock() - eq.filter_by.return_value = eq - eq.first.return_value = summary - error_session.query.return_value = eq + error_session.scalar.return_value = summary create_session_mock = MagicMock(side_effect=[_SessionContext(session), _SessionContext(error_session)]) monkeypatch.setattr(summary_module, "session_factory", SimpleNamespace(create_session=create_session_mock)) # Force the created record to be None so the "should not be None" guard triggers. + # Also mock select() so SQLAlchemy doesn't validate the mocked DocumentSegmentSummary as a real column clause. + monkeypatch.setattr(summary_module, "select", MagicMock(return_value=MagicMock())) monkeypatch.setattr(summary_module, "DocumentSegmentSummary", MagicMock(return_value=None)) with pytest.raises(RuntimeError, match="summary_record_in_session should not be None"): @@ -554,10 +517,7 @@ def test_vectorize_summary_error_handler_tries_chunk_id_lookup_and_can_warn_not_ ) error_session = MagicMock(name="error_session") - q = MagicMock() - q.filter_by.return_value = q - q.first.side_effect = [None, None] # not found by id, not found by chunk_id - error_session.query.return_value = q + error_session.scalar.side_effect = [None, None] # not found by id, not found by chunk_id monkeypatch.setattr( summary_module, @@ -577,10 +537,7 @@ def test_update_summary_record_error_warns_when_missing(monkeypatch: pytest.Monk segment = _segment() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = None - session.query.return_value = query + session.scalar.return_value = None monkeypatch.setattr( summary_module, "session_factory", @@ -599,10 +556,7 @@ def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(mo segment = _segment() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = None - session.query.return_value = query + session.scalar.return_value = None monkeypatch.setattr( summary_module, "session_factory", @@ -646,11 +600,7 @@ def test_generate_summaries_for_document_runs_and_handles_errors(monkeypatch: py seg2.id = "seg-2" session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.filter.return_value = query - query.all.return_value = [seg1, seg2] - session.query.return_value = query + session.scalars.return_value.all.return_value = [seg1, seg2] monkeypatch.setattr( summary_module, @@ -678,11 +628,7 @@ def test_generate_summaries_for_document_no_segments_returns_empty(monkeypatch: document.doc_form = IndexStructureType.PARAGRAPH_INDEX session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.filter.return_value = query - query.all.return_value = [] - session.query.return_value = query + session.scalars.return_value.all.return_value = [] monkeypatch.setattr( summary_module, "session_factory", @@ -702,11 +648,7 @@ def test_generate_summaries_for_document_applies_segment_ids_and_only_parent_chu seg = _segment() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.filter.return_value = query - query.all.return_value = [seg] - session.query.return_value = query + session.scalars.return_value.all.return_value = [seg] monkeypatch.setattr( summary_module, "session_factory", @@ -723,7 +665,7 @@ def test_generate_summaries_for_document_applies_segment_ids_and_only_parent_chu segment_ids=[seg.id], only_parent_chunks=True, ) - query.filter.assert_called() + session.scalars.assert_called() def test_disable_summaries_for_segments_handles_vector_delete_error(monkeypatch: pytest.MonkeyPatch) -> None: @@ -732,11 +674,7 @@ def test_disable_summaries_for_segments_handles_vector_delete_error(monkeypatch: summary2 = _summary_record(summary_content="s", node_id=None) session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.filter.return_value = query - query.all.return_value = [summary1, summary2] - session.query.return_value = query + session.scalars.return_value.all.return_value = [summary1, summary2] monkeypatch.setattr( summary_module, @@ -761,11 +699,7 @@ def test_disable_summaries_for_segments_handles_vector_delete_error(monkeypatch: def test_disable_summaries_for_segments_no_summaries_noop(monkeypatch: pytest.MonkeyPatch) -> None: dataset = _dataset() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.filter.return_value = query - query.all.return_value = [] - session.query.return_value = query + session.scalars.return_value.all.return_value = [] monkeypatch.setattr( summary_module, "session_factory", @@ -793,21 +727,8 @@ def test_enable_summaries_for_segments_revectorizes_and_enables(monkeypatch: pyt segment.status = SegmentStatus.COMPLETED session = MagicMock() - summary_query = MagicMock() - summary_query.filter_by.return_value = summary_query - summary_query.filter.return_value = summary_query - summary_query.all.return_value = [summary] - - seg_query = MagicMock() - seg_query.filter_by.return_value = seg_query - seg_query.first.return_value = segment - - def query_side_effect(model: object) -> MagicMock: - if model is summary_module.DocumentSegmentSummary: - return summary_query - return seg_query - - session.query.side_effect = query_side_effect + session.scalars.return_value.all.return_value = [summary] + session.scalar.return_value = segment monkeypatch.setattr( summary_module, @@ -826,11 +747,7 @@ def test_enable_summaries_for_segments_revectorizes_and_enables(monkeypatch: pyt def test_enable_summaries_for_segments_no_summaries_noop(monkeypatch: pytest.MonkeyPatch) -> None: dataset = _dataset() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.filter.return_value = query - query.all.return_value = [] - session.query.return_value = query + session.scalars.return_value.all.return_value = [] monkeypatch.setattr( summary_module, "session_factory", @@ -860,21 +777,9 @@ def test_enable_summaries_for_segments_skips_segment_or_content_and_handles_vect good_segment.status = SegmentStatus.COMPLETED session = MagicMock() - summary_query = MagicMock() - summary_query.filter_by.return_value = summary_query - summary_query.filter.return_value = summary_query - summary_query.all.return_value = [summary1, summary2, summary3] + session.scalars.return_value.all.return_value = [summary1, summary2, summary3] + session.scalar.side_effect = [bad_segment, good_segment, good_segment] - seg_query = MagicMock() - seg_query.filter_by.return_value = seg_query - seg_query.first.side_effect = [bad_segment, good_segment, good_segment] - - def query_side_effect(model: object) -> MagicMock: - if model is summary_module.DocumentSegmentSummary: - return summary_query - return seg_query - - session.query.side_effect = query_side_effect monkeypatch.setattr( summary_module, "session_factory", @@ -895,11 +800,7 @@ def test_delete_summaries_for_segments_deletes_vectors_and_records(monkeypatch: summary = _summary_record(summary_content="sum", node_id="n1") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.filter.return_value = query - query.all.return_value = [summary] - session.query.return_value = query + session.scalars.return_value.all.return_value = [summary] vector_instance = MagicMock() monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance)) @@ -918,11 +819,7 @@ def test_delete_summaries_for_segments_deletes_vectors_and_records(monkeypatch: def test_delete_summaries_for_segments_no_summaries_noop(monkeypatch: pytest.MonkeyPatch) -> None: dataset = _dataset() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.filter.return_value = query - query.all.return_value = [] - session.query.return_value = query + session.scalars.return_value.all.return_value = [] monkeypatch.setattr( summary_module, "session_factory", @@ -946,10 +843,7 @@ def test_update_summary_for_segment_empty_content_deletes_existing(monkeypatch: record = _summary_record(summary_content="old", node_id="n1") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record vector_instance = MagicMock() monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance)) @@ -971,10 +865,7 @@ def test_update_summary_for_segment_empty_content_delete_vector_warns(monkeypatc record = _summary_record(summary_content="old", node_id="n1") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record monkeypatch.setattr( summary_module, "session_factory", @@ -996,10 +887,7 @@ def test_update_summary_for_segment_empty_content_no_record_noop(monkeypatch: py segment = _segment() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = None - session.query.return_value = query + session.scalar.return_value = None monkeypatch.setattr( summary_module, "session_factory", @@ -1015,10 +903,7 @@ def test_update_summary_for_segment_updates_existing_and_vectorizes(monkeypatch: record = _summary_record(summary_content="old", node_id="n1") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record vector_instance = MagicMock() monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance)) @@ -1044,10 +929,7 @@ def test_update_summary_for_segment_existing_vector_delete_warns(monkeypatch: py record = _summary_record(summary_content="old", node_id="n1") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record monkeypatch.setattr( summary_module, "session_factory", @@ -1073,10 +955,7 @@ def test_update_summary_for_segment_existing_vectorize_failure_returns_error_rec record = _summary_record(summary_content="old", node_id="n1") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record monkeypatch.setattr( summary_module, "session_factory", @@ -1095,10 +974,7 @@ def test_update_summary_for_segment_new_record_success(monkeypatch: pytest.Monke segment = _segment() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = None - session.query.return_value = query + session.scalar.return_value = None monkeypatch.setattr( summary_module, "session_factory", @@ -1122,10 +998,7 @@ def test_update_summary_for_segment_outer_exception_sets_error_and_reraises(monk record = _summary_record(summary_content="old", node_id="n1") session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = record - session.query.return_value = query + session.scalar.return_value = record session.flush.side_effect = RuntimeError("flush boom") monkeypatch.setattr( summary_module, @@ -1143,25 +1016,9 @@ def test_update_summary_for_segment_outer_exception_sets_error_and_reraises(monk def test_get_segment_summary_and_document_summaries(monkeypatch: pytest.MonkeyPatch) -> None: record = _summary_record(summary_content="sum", node_id="n1") session = MagicMock() + session.scalar.return_value = record + session.scalars.return_value.all.return_value = [record] - q1 = MagicMock() - q1.where.return_value = q1 - q1.first.return_value = record - - q2 = MagicMock() - q2.filter.return_value = q2 - q2.all.return_value = [record] - - def query_side_effect(model: object) -> MagicMock: - if model is summary_module.DocumentSegmentSummary: - # first call used by get_segment_summary, second by get_document_summaries - if not hasattr(query_side_effect, "_called"): - query_side_effect._called = True # type: ignore[attr-defined] - return q1 - return q2 - return MagicMock() - - session.query.side_effect = query_side_effect monkeypatch.setattr( summary_module, "session_factory", @@ -1178,10 +1035,7 @@ def test_get_segments_summaries_non_empty(monkeypatch: pytest.MonkeyPatch) -> No record2 = _summary_record() record2.chunk_id = "seg-2" session = MagicMock() - q = MagicMock() - q.where.return_value = q - q.all.return_value = [record1, record2] - session.query.return_value = q + session.scalars.return_value.all.return_value = [record1, record2] monkeypatch.setattr( summary_module, "session_factory", @@ -1194,10 +1048,7 @@ def test_get_segments_summaries_non_empty(monkeypatch: pytest.MonkeyPatch) -> No def test_get_document_summary_index_status_no_segments_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: session = MagicMock() - q = MagicMock() - q.where.return_value = q - q.all.return_value = [] - session.query.return_value = q + session.scalars.return_value.all.return_value = [] monkeypatch.setattr( summary_module, "session_factory", @@ -1212,10 +1063,7 @@ def test_get_documents_summary_index_status_empty_input(monkeypatch: pytest.Monk def test_get_documents_summary_index_status_no_pending_sets_none(monkeypatch: pytest.MonkeyPatch) -> None: session = MagicMock() - q = MagicMock() - q.where.return_value = q - q.all.return_value = [SimpleNamespace(id="seg-1", document_id="doc-1")] - session.query.return_value = q + session.execute.return_value.all.return_value = [SimpleNamespace(id="seg-1", document_id="doc-1")] monkeypatch.setattr( summary_module, "session_factory", @@ -1237,10 +1085,7 @@ def test_update_summary_for_segment_creates_new_and_vectorize_fails_returns_erro segment = _segment() session = MagicMock() - query = MagicMock() - query.filter_by.return_value = query - query.first.return_value = None - session.query.return_value = query + session.scalar.return_value = None monkeypatch.setattr( summary_module, @@ -1267,10 +1112,7 @@ def test_get_segments_summaries_empty_list() -> None: def test_get_document_summary_index_status_and_documents_status(monkeypatch: pytest.MonkeyPatch) -> None: seg_row = SimpleNamespace(id="seg-1", document_id="doc-1") session = MagicMock() - query = MagicMock() - query.where.return_value = query - query.all.return_value = [SimpleNamespace(id="seg-1")] - session.query.return_value = query + session.scalars.return_value.all.return_value = ["seg-1"] # get_document_summary_index_status returns IDs create_session_mock = MagicMock(return_value=_SessionContext(session)) monkeypatch.setattr(summary_module, "session_factory", SimpleNamespace(create_session=create_session_mock)) @@ -1283,11 +1125,8 @@ def test_get_document_summary_index_status_and_documents_status(monkeypatch: pyt assert SummaryIndexService.get_document_summary_index_status("doc-1", "dataset-1", "tenant-1") == "SUMMARIZING" # Multiple docs - query2 = MagicMock() - query2.where.return_value = query2 - query2.all.return_value = [seg_row] session2 = MagicMock() - session2.query.return_value = query2 + session2.execute.return_value.all.return_value = [seg_row] # get_documents_summary_index_status uses execute monkeypatch.setattr( summary_module, "session_factory", diff --git a/api/tests/unit_tests/services/test_trigger_provider_service.py b/api/tests/unit_tests/services/test_trigger_provider_service.py index 81a3b181fd..bd2e936b62 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -63,6 +63,12 @@ def mock_session(mocker: MockerFixture) -> MagicMock: mock_session_cm.__enter__.return_value = mock_session_instance mock_session_cm.__exit__.return_value = False mocker.patch("services.trigger.trigger_provider_service.Session", return_value=mock_session_cm) + mock_begin_cm = MagicMock() + mock_begin_cm.__enter__.return_value = mock_session_instance + mock_begin_cm.__exit__.return_value = False + mock_sessionmaker_instance = MagicMock() + mock_sessionmaker_instance.begin.return_value = mock_begin_cm + mocker.patch("services.trigger.trigger_provider_service.sessionmaker", return_value=mock_sessionmaker_instance) return mock_session_instance @@ -118,9 +124,7 @@ def test_list_trigger_provider_subscriptions_should_return_empty_list_when_no_su provider_id: TriggerProviderID, ) -> None: # Arrange - query = MagicMock() - query.filter_by.return_value.order_by.return_value.all.return_value = [] - mock_session.query.return_value = query + mock_session.scalars.return_value.all.return_value = [] # Act result = TriggerProviderService.list_trigger_provider_subscriptions("tenant-1", provider_id) @@ -146,11 +150,8 @@ def test_list_trigger_provider_subscriptions_should_mask_fields_and_attach_workf db_sub = SimpleNamespace(to_api_entity=lambda: api_sub) usage_row = SimpleNamespace(subscription_id="sub-1", app_count=2) - query_subs = MagicMock() - query_subs.filter_by.return_value.order_by.return_value.all.return_value = [db_sub] - query_usage = MagicMock() - query_usage.filter.return_value.group_by.return_value.all.return_value = [usage_row] - mock_session.query.side_effect = [query_subs, query_usage] + mock_session.scalars.return_value.all.return_value = [db_sub] + mock_session.execute.return_value.all.return_value = [usage_row] _mock_get_trigger_provider(mocker, provider_controller) cred_enc = _encrypter_mock(decrypted={"token": "plain"}, masked={"token": "****"}) @@ -182,11 +183,7 @@ def test_add_trigger_subscription_should_create_subscription_successfully_for_ap ) -> None: # Arrange _patch_redis_lock(mocker) - query_count = MagicMock() - query_count.filter_by.return_value.count.return_value = 0 - query_existing = MagicMock() - query_existing.filter_by.return_value.first.return_value = None - mock_session.query.side_effect = [query_count, query_existing] + mock_session.scalar.side_effect = [0, None] # count=0, no existing name _mock_get_trigger_provider(mocker, provider_controller) cred_enc = _encrypter_mock(encrypted={"api_key": "enc"}) @@ -212,7 +209,6 @@ def test_add_trigger_subscription_should_create_subscription_successfully_for_ap # Assert assert result["result"] == "success" mock_session.add.assert_called_once() - mock_session.commit.assert_called_once() def test_add_trigger_subscription_should_store_empty_credentials_for_unauthorized_type( @@ -223,11 +219,7 @@ def test_add_trigger_subscription_should_store_empty_credentials_for_unauthorize ) -> None: # Arrange _patch_redis_lock(mocker) - query_count = MagicMock() - query_count.filter_by.return_value.count.return_value = 0 - query_existing = MagicMock() - query_existing.filter_by.return_value.first.return_value = None - mock_session.query.side_effect = [query_count, query_existing] + mock_session.scalar.side_effect = [0, None] # count=0, no existing name _mock_get_trigger_provider(mocker, provider_controller) prop_enc = _encrypter_mock(encrypted={"p": "enc"}) @@ -262,9 +254,7 @@ def test_add_trigger_subscription_should_raise_error_when_provider_limit_reached ) -> None: # Arrange _patch_redis_lock(mocker) - query_count = MagicMock() - query_count.filter_by.return_value.count.return_value = TriggerProviderService.__MAX_TRIGGER_PROVIDER_COUNT__ - mock_session.query.return_value = query_count + mock_session.scalar.return_value = TriggerProviderService.__MAX_TRIGGER_PROVIDER_COUNT__ _mock_get_trigger_provider(mocker, provider_controller) mock_logger = mocker.patch("services.trigger.trigger_provider_service.logger") @@ -292,11 +282,7 @@ def test_add_trigger_subscription_should_raise_error_when_name_exists( ) -> None: # Arrange _patch_redis_lock(mocker) - query_count = MagicMock() - query_count.filter_by.return_value.count.return_value = 0 - query_existing = MagicMock() - query_existing.filter_by.return_value.first.return_value = object() - mock_session.query.side_effect = [query_count, query_existing] + mock_session.scalar.side_effect = [0, object()] # count=0, existing name conflict _mock_get_trigger_provider(mocker, provider_controller) # Act + Assert @@ -320,9 +306,7 @@ def test_update_trigger_subscription_should_raise_error_when_subscription_not_fo ) -> None: # Arrange _patch_redis_lock(mocker) - query_sub = MagicMock() - query_sub.filter_by.return_value.first.return_value = None - mock_session.query.return_value = query_sub + mock_session.scalar.return_value = None # Act + Assert with pytest.raises(ValueError, match="not found"): @@ -342,11 +326,7 @@ def test_update_trigger_subscription_should_raise_error_when_name_conflicts( provider_id="langgenius/github/github", credential_type=CredentialType.API_KEY.value, ) - query_sub = MagicMock() - query_sub.filter_by.return_value.first.return_value = subscription - query_existing = MagicMock() - query_existing.filter_by.return_value.first.return_value = object() - mock_session.query.side_effect = [query_sub, query_existing] + mock_session.scalar.side_effect = [subscription, object()] # found sub, name conflict _mock_get_trigger_provider(mocker, provider_controller) # Act + Assert @@ -373,11 +353,7 @@ def test_update_trigger_subscription_should_update_fields_and_clear_cache( credential_expires_at=0, expires_at=0, ) - query_sub = MagicMock() - query_sub.filter_by.return_value.first.return_value = subscription - query_existing = MagicMock() - query_existing.filter_by.return_value.first.return_value = None - mock_session.query.side_effect = [query_sub, query_existing] + mock_session.scalar.side_effect = [subscription, None] # found sub, no name conflict _mock_get_trigger_provider(mocker, provider_controller) prop_enc = _encrypter_mock(decrypted={"project": "old-value"}, encrypted={"project": "new-value"}) @@ -406,13 +382,13 @@ def test_update_trigger_subscription_should_update_fields_and_clear_cache( assert subscription.credentials == {"api_key": "new-key"} assert subscription.credential_expires_at == 100 assert subscription.expires_at == 200 - mock_session.commit.assert_called_once() + mock_delete_cache.assert_called_once() def test_get_subscription_by_id_should_return_none_when_missing(mocker: MockerFixture, mock_session: MagicMock) -> None: # Arrange - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalar.return_value = None # Act result = TriggerProviderService.get_subscription_by_id("tenant-1", "sub-1") @@ -434,7 +410,7 @@ def test_get_subscription_by_id_should_decrypt_credentials_and_properties( credentials={"token": "enc"}, properties={"project": "enc"}, ) - mock_session.query.return_value.filter_by.return_value.first.return_value = subscription + mock_session.scalar.return_value = subscription _mock_get_trigger_provider(mocker, provider_controller) cred_enc = _encrypter_mock(decrypted={"token": "plain"}) prop_enc = _encrypter_mock(decrypted={"project": "plain"}) @@ -461,7 +437,7 @@ def test_delete_trigger_provider_should_raise_error_when_subscription_missing( mock_session: MagicMock, ) -> None: # Arrange - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalar.return_value = None # Act + Assert with pytest.raises(ValueError, match="not found"): @@ -483,7 +459,7 @@ def test_delete_trigger_provider_should_delete_and_clear_cache_even_if_unsubscri credentials={"token": "enc"}, to_entity=lambda: SimpleNamespace(id="sub-1"), ) - mock_session.query.return_value.filter_by.return_value.first.return_value = subscription + mock_session.scalar.return_value = subscription _mock_get_trigger_provider(mocker, provider_controller) cred_enc = _encrypter_mock(decrypted={"token": "plain"}) mocker.patch( @@ -519,7 +495,7 @@ def test_delete_trigger_provider_should_skip_unsubscribe_for_unauthorized( credentials={}, to_entity=lambda: SimpleNamespace(id="sub-2"), ) - mock_session.query.return_value.filter_by.return_value.first.return_value = subscription + mock_session.scalar.return_value = subscription _mock_get_trigger_provider(mocker, provider_controller) mock_unsubscribe = mocker.patch("services.trigger.trigger_provider_service.TriggerManager.unsubscribe_trigger") mocker.patch( @@ -539,7 +515,7 @@ def test_refresh_oauth_token_should_raise_error_when_subscription_missing( mocker: MockerFixture, mock_session: MagicMock ) -> None: # Arrange - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalar.return_value = None # Act + Assert with pytest.raises(ValueError, match="not found"): @@ -551,7 +527,7 @@ def test_refresh_oauth_token_should_raise_error_for_non_oauth_credentials( ) -> None: # Arrange subscription = SimpleNamespace(credential_type=CredentialType.API_KEY.value) - mock_session.query.return_value.filter_by.return_value.first.return_value = subscription + mock_session.scalar.return_value = subscription # Act + Assert with pytest.raises(ValueError, match="Only OAuth credentials can be refreshed"): @@ -572,7 +548,7 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials( credentials={"access_token": "enc"}, credential_expires_at=0, ) - mock_session.query.return_value.filter_by.return_value.first.return_value = subscription + mock_session.scalar.return_value = subscription _mock_get_trigger_provider(mocker, provider_controller) cache = MagicMock() cred_enc = _encrypter_mock(decrypted={"access_token": "old"}, encrypted={"access_token": "new"}) @@ -593,7 +569,7 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials( assert result == {"result": "success", "expires_at": 12345} assert subscription.credentials == {"access_token": "new"} assert subscription.credential_expires_at == 12345 - mock_session.commit.assert_called_once() + cache.delete.assert_called_once() @@ -601,7 +577,7 @@ def test_refresh_subscription_should_raise_error_when_subscription_missing( mocker: MockerFixture, mock_session: MagicMock ) -> None: # Arrange - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalar.return_value = None # Act + Assert with pytest.raises(ValueError, match="not found"): @@ -611,7 +587,7 @@ def test_refresh_subscription_should_raise_error_when_subscription_missing( def test_refresh_subscription_should_skip_when_not_due(mocker: MockerFixture, mock_session: MagicMock) -> None: # Arrange subscription = SimpleNamespace(expires_at=200) - mock_session.query.return_value.filter_by.return_value.first.return_value = subscription + mock_session.scalar.return_value = subscription # Act result = TriggerProviderService.refresh_subscription("tenant-1", "sub-1", now=100) @@ -638,7 +614,7 @@ def test_refresh_subscription_should_refresh_and_persist_properties( credentials={"c": "enc"}, credential_type=CredentialType.API_KEY.value, ) - mock_session.query.return_value.filter_by.return_value.first.return_value = subscription + mock_session.scalar.return_value = subscription _mock_get_trigger_provider(mocker, provider_controller) cred_enc = _encrypter_mock(decrypted={"c": "plain"}) prop_cache = MagicMock() @@ -664,7 +640,7 @@ def test_refresh_subscription_should_refresh_and_persist_properties( assert result == {"result": "success", "expires_at": 999} assert subscription.properties == {"p": "new-enc"} assert subscription.expires_at == 999 - mock_session.commit.assert_called_once() + prop_cache.delete.assert_called_once() @@ -676,10 +652,7 @@ def test_get_oauth_client_should_return_tenant_client_when_available( ) -> None: # Arrange tenant_client = SimpleNamespace(oauth_params={"client_id": "enc"}) - system_client = None - query_tenant = MagicMock() - query_tenant.filter_by.return_value.first.return_value = tenant_client - mock_session.query.return_value = query_tenant + mock_session.scalar.return_value = tenant_client _mock_get_trigger_provider(mocker, provider_controller) enc = _encrypter_mock(decrypted={"client_id": "plain"}) mocker.patch("services.trigger.trigger_provider_service.create_provider_encrypter", return_value=(enc, MagicMock())) @@ -698,11 +671,7 @@ def test_get_oauth_client_should_return_none_when_plugin_not_verified( provider_controller: MagicMock, ) -> None: # Arrange - query_tenant = MagicMock() - query_tenant.filter_by.return_value.first.return_value = None - query_system = MagicMock() - query_system.filter_by.return_value.first.return_value = None - mock_session.query.side_effect = [query_tenant, query_system] + mock_session.scalar.return_value = None # no tenant client; plugin not verified → early return _mock_get_trigger_provider(mocker, provider_controller) mocker.patch("services.trigger.trigger_provider_service.PluginService.is_plugin_verified", return_value=False) @@ -720,11 +689,7 @@ def test_get_oauth_client_should_return_decrypted_system_client_when_verified( provider_controller: MagicMock, ) -> None: # Arrange - query_tenant = MagicMock() - query_tenant.filter_by.return_value.first.return_value = None - query_system = MagicMock() - query_system.filter_by.return_value.first.return_value = SimpleNamespace(encrypted_oauth_params="enc") - mock_session.query.side_effect = [query_tenant, query_system] + mock_session.scalar.side_effect = [None, SimpleNamespace(encrypted_oauth_params="enc")] _mock_get_trigger_provider(mocker, provider_controller) mocker.patch("services.trigger.trigger_provider_service.PluginService.is_plugin_verified", return_value=True) mocker.patch( @@ -746,11 +711,7 @@ def test_get_oauth_client_should_raise_error_when_system_decryption_fails( provider_controller: MagicMock, ) -> None: # Arrange - query_tenant = MagicMock() - query_tenant.filter_by.return_value.first.return_value = None - query_system = MagicMock() - query_system.filter_by.return_value.first.return_value = SimpleNamespace(encrypted_oauth_params="enc") - mock_session.query.side_effect = [query_tenant, query_system] + mock_session.scalar.side_effect = [None, SimpleNamespace(encrypted_oauth_params="enc")] _mock_get_trigger_provider(mocker, provider_controller) mocker.patch("services.trigger.trigger_provider_service.PluginService.is_plugin_verified", return_value=True) mocker.patch( @@ -789,7 +750,7 @@ def test_is_oauth_system_client_exists_should_reflect_database_record( provider_controller: MagicMock, ) -> None: # Arrange - mock_session.query.return_value.filter_by.return_value.first.return_value = object() if has_client else None + mock_session.scalar.return_value = object() if has_client else None _mock_get_trigger_provider(mocker, provider_controller) mocker.patch("services.trigger.trigger_provider_service.PluginService.is_plugin_verified", return_value=True) @@ -818,11 +779,11 @@ def test_save_custom_oauth_client_params_should_create_record_and_clear_params_w provider_controller: MagicMock, ) -> None: # Arrange - query = MagicMock() - query.filter_by.return_value.first.return_value = None - mock_session.query.return_value = query + mock_session.scalar.return_value = None _mock_get_trigger_provider(mocker, provider_controller) fake_model = SimpleNamespace(encrypted_oauth_params="", enabled=False, oauth_params={}) + # Also mock select() so SQLAlchemy doesn't validate the patched TriggerOAuthTenantClient. + mocker.patch("services.trigger.trigger_provider_service.select", MagicMock(return_value=MagicMock())) mocker.patch("services.trigger.trigger_provider_service.TriggerOAuthTenantClient", return_value=fake_model) # Act @@ -838,7 +799,6 @@ def test_save_custom_oauth_client_params_should_create_record_and_clear_params_w assert fake_model.encrypted_oauth_params == "{}" assert fake_model.enabled is True mock_session.add.assert_called_once_with(fake_model) - mock_session.commit.assert_called_once() def test_save_custom_oauth_client_params_should_merge_hidden_values_and_delete_cache( @@ -849,7 +809,7 @@ def test_save_custom_oauth_client_params_should_merge_hidden_values_and_delete_c ) -> None: # Arrange custom_client = SimpleNamespace(oauth_params={"client_id": "enc-old"}, enabled=False) - mock_session.query.return_value.filter_by.return_value.first.return_value = custom_client + mock_session.scalar.return_value = custom_client _mock_get_trigger_provider(mocker, provider_controller) cache = MagicMock() enc = _encrypter_mock(decrypted={"client_id": "old-id"}, encrypted={"client_id": "new-id"}) @@ -870,7 +830,6 @@ def test_save_custom_oauth_client_params_should_merge_hidden_values_and_delete_c assert result == {"result": "success"} assert json.loads(custom_client.encrypted_oauth_params) == {"client_id": "new-id"} cache.delete.assert_called_once() - mock_session.commit.assert_called_once() def test_get_custom_oauth_client_params_should_return_empty_when_record_missing( @@ -879,7 +838,7 @@ def test_get_custom_oauth_client_params_should_return_empty_when_record_missing( provider_id: TriggerProviderID, ) -> None: # Arrange - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalar.return_value = None # Act result = TriggerProviderService.get_custom_oauth_client_params("tenant-1", provider_id) @@ -896,7 +855,7 @@ def test_get_custom_oauth_client_params_should_return_masked_decrypted_values( ) -> None: # Arrange custom_client = SimpleNamespace(oauth_params={"client_id": "enc"}) - mock_session.query.return_value.filter_by.return_value.first.return_value = custom_client + mock_session.scalar.return_value = custom_client _mock_get_trigger_provider(mocker, provider_controller) enc = _encrypter_mock(decrypted={"client_id": "plain"}, masked={"client_id": "pl***id"}) mocker.patch("services.trigger.trigger_provider_service.create_provider_encrypter", return_value=(enc, MagicMock())) @@ -913,15 +872,11 @@ def test_delete_custom_oauth_client_params_should_delete_record_and_commit( mock_session: MagicMock, provider_id: TriggerProviderID, ) -> None: - # Arrange - mock_session.query.return_value.filter_by.return_value.delete.return_value = 1 - # Act result = TriggerProviderService.delete_custom_oauth_client_params("tenant-1", provider_id) # Assert assert result == {"result": "success"} - mock_session.commit.assert_called_once() @pytest.mark.parametrize("exists", [True, False]) @@ -932,7 +887,7 @@ def test_is_oauth_custom_client_enabled_should_return_expected_boolean( provider_id: TriggerProviderID, ) -> None: # Arrange - mock_session.query.return_value.filter_by.return_value.first.return_value = object() if exists else None + mock_session.scalar.return_value = object() if exists else None # Act result = TriggerProviderService.is_oauth_custom_client_enabled("tenant-1", provider_id) @@ -945,7 +900,7 @@ def test_get_subscription_by_endpoint_should_return_none_when_not_found( mocker: MockerFixture, mock_session: MagicMock ) -> None: # Arrange - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalar.return_value = None # Act result = TriggerProviderService.get_subscription_by_endpoint("endpoint-1") @@ -966,7 +921,7 @@ def test_get_subscription_by_endpoint_should_decrypt_credentials_and_properties( credentials={"token": "enc"}, properties={"hook": "enc"}, ) - mock_session.query.return_value.filter_by.return_value.first.return_value = subscription + mock_session.scalar.return_value = subscription _mock_get_trigger_provider(mocker, provider_controller) mocker.patch( "services.trigger.trigger_provider_service.create_trigger_provider_encrypter_for_subscription", diff --git a/api/tests/unit_tests/services/test_variable_truncator.py b/api/tests/unit_tests/services/test_variable_truncator.py index 27602bb1cc..98ec6fb77c 100644 --- a/api/tests/unit_tests/services/test_variable_truncator.py +++ b/api/tests/unit_tests/services/test_variable_truncator.py @@ -12,7 +12,6 @@ This test suite covers all functionality of the current VariableTruncator includ import functools import json import uuid -from collections.abc import Mapping from typing import Any from uuid import uuid4 @@ -674,229 +673,3 @@ def test_dummy_variable_truncator_methods(): assert isinstance(result, TruncationResult) assert result.result == segment assert result.truncated is False - - -# === Merged from test_variable_truncator_additional.py === - - -from typing import Any - -import pytest -from graphon.nodes.variable_assigner.common.helpers import UpdatedVariable -from graphon.variables.segments import IntegerSegment, ObjectSegment, StringSegment -from graphon.variables.types import SegmentType - -from services import variable_truncator as truncator_module -from services.variable_truncator import BaseTruncator, TruncationResult, VariableTruncator - - -class _AbstractPassthrough(BaseTruncator): - def truncate(self, segment: Any) -> TruncationResult: - # Arrange / Act - return super().truncate(segment) # type: ignore[misc] - - def truncate_variable_mapping(self, v: Mapping[str, Any]) -> tuple[Mapping[str, Any], bool]: - # Arrange / Act - return super().truncate_variable_mapping(v) # type: ignore[misc] - - -def test_base_truncator_methods_should_execute_abstract_placeholders() -> None: - # Arrange - passthrough = _AbstractPassthrough() - - # Act - truncate_result = passthrough.truncate(StringSegment(value="x")) - mapping_result = passthrough.truncate_variable_mapping({"a": 1}) - - # Assert - assert truncate_result is None - assert mapping_result is None - - -def test_default_should_use_dify_config_limits(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE", 111) - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_ARRAY_LENGTH", 7) - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH", 33) - - # Act - truncator = VariableTruncator.default() - - # Assert - assert truncator._max_size_bytes == 111 - assert truncator._array_element_limit == 7 - assert truncator._string_length_limit == 33 - - -def test_truncate_variable_mapping_should_mark_over_budget_keys_with_ellipsis() -> None: - # Arrange - truncator = VariableTruncator(max_size_bytes=5) - mapping = {"very_long_key": "value"} - - # Act - result, truncated = truncator.truncate_variable_mapping(mapping) - - # Assert - assert result == {"very_long_key": "..."} - assert truncated is True - - -def test_truncate_variable_mapping_should_handle_segment_values() -> None: - # Arrange - truncator = VariableTruncator(max_size_bytes=100) - mapping = {"seg": StringSegment(value="hello")} - - # Act - result, truncated = truncator.truncate_variable_mapping(mapping) - - # Assert - assert isinstance(result["seg"], StringSegment) - assert result["seg"].value == "hello" - assert truncated is False - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - (None, False), - (True, False), - (1, False), - (1.5, False), - ("x", True), - ({"k": "v"}, True), - ], -) -def test_json_value_needs_truncation_should_match_expected_rules(value: Any, expected: bool) -> None: - # Arrange - - # Act - result = VariableTruncator._json_value_needs_truncation(value) - - # Assert - assert result is expected - - -def test_truncate_should_use_string_fallback_when_truncated_value_size_exceeds_limit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - truncator = VariableTruncator(max_size_bytes=10) - forced_result = truncator_module._PartResult( - value=StringSegment(value="this is too long"), - value_size=100, - truncated=True, - ) - monkeypatch.setattr(truncator, "_truncate_segment", lambda *_args, **_kwargs: forced_result) - - # Act - result = truncator.truncate(StringSegment(value="input")) - - # Assert - assert result.truncated is True - assert isinstance(result.result, StringSegment) - assert not result.result.value.startswith('"') - - -def test_truncate_segment_should_raise_assertion_for_unexpected_truncatable_segment( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - truncator = VariableTruncator() - monkeypatch.setattr(VariableTruncator, "_segment_need_truncation", lambda _segment: True) - - # Act / Assert - with pytest.raises(AssertionError): - truncator._truncate_segment(IntegerSegment(value=1), 10) - - -def test_calculate_json_size_should_unwrap_segment_values() -> None: - # Arrange - segment = StringSegment(value="abc") - - # Act - size = VariableTruncator.calculate_json_size(segment) - - # Assert - assert size == VariableTruncator.calculate_json_size("abc") - - -def test_calculate_json_size_should_handle_updated_variable_instances() -> None: - # Arrange - updated = UpdatedVariable(name="n", selector=["node", "var"], value_type=SegmentType.STRING, new_value="v") - - # Act - size = VariableTruncator.calculate_json_size(updated) - - # Assert - assert size > 0 - - -def test_maybe_qa_structure_should_validate_shape() -> None: - # Arrange - - # Act / Assert - assert VariableTruncator._maybe_qa_structure({"qa_chunks": []}) is True - assert VariableTruncator._maybe_qa_structure({"qa_chunks": "not-list"}) is False - assert VariableTruncator._maybe_qa_structure({}) is False - - -def test_maybe_parent_child_structure_should_validate_shape() -> None: - # Arrange - - # Act / Assert - assert VariableTruncator._maybe_parent_child_structure({"parent_mode": "full", "parent_child_chunks": []}) is True - assert VariableTruncator._maybe_parent_child_structure({"parent_mode": 1, "parent_child_chunks": []}) is False - assert ( - VariableTruncator._maybe_parent_child_structure({"parent_mode": "full", "parent_child_chunks": "bad"}) is False - ) - - -def test_truncate_object_should_truncate_segment_values_inside_object() -> None: - # Arrange - truncator = VariableTruncator(string_length_limit=8, max_size_bytes=30) - mapping = {"s": StringSegment(value="long-content")} - - # Act - result = truncator._truncate_object(mapping, 20) - - # Assert - assert result.truncated is True - assert isinstance(result.value["s"], StringSegment) - - -def test_truncate_json_primitives_should_handle_updated_variable_input() -> None: - # Arrange - truncator = VariableTruncator(max_size_bytes=100) - updated = UpdatedVariable(name="n", selector=["node", "var"], value_type=SegmentType.STRING, new_value="v") - - # Act - result = truncator._truncate_json_primitives(updated, 100) - - # Assert - assert isinstance(result.value, dict) - - -def test_truncate_json_primitives_should_raise_assertion_for_unsupported_value_type() -> None: - # Arrange - truncator = VariableTruncator() - - # Act / Assert - with pytest.raises(AssertionError): - truncator._truncate_json_primitives(object(), 100) # type: ignore[arg-type] - - -def test_truncate_should_apply_json_string_fallback_for_large_non_string_segment( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - truncator = VariableTruncator(max_size_bytes=10) - forced_segment = ObjectSegment(value={"k": "v"}) - forced_result = truncator_module._PartResult(value=forced_segment, value_size=100, truncated=True) - monkeypatch.setattr(truncator, "_truncate_segment", lambda *_args, **_kwargs: forced_result) - - # Act - result = truncator.truncate(ObjectSegment(value={"a": "b"})) - - # Assert - assert result.truncated is True - assert isinstance(result.result, StringSegment) diff --git a/api/tests/unit_tests/services/test_variable_truncator_additional.py b/api/tests/unit_tests/services/test_variable_truncator_additional.py new file mode 100644 index 0000000000..4f705cf5f4 --- /dev/null +++ b/api/tests/unit_tests/services/test_variable_truncator_additional.py @@ -0,0 +1,174 @@ +from collections.abc import Mapping +from typing import Any + +import pytest +from graphon.nodes.variable_assigner.common.helpers import UpdatedVariable +from graphon.variables.segments import IntegerSegment, ObjectSegment, StringSegment +from graphon.variables.types import SegmentType + +from services import variable_truncator as truncator_module +from services.variable_truncator import BaseTruncator, TruncationResult, VariableTruncator + + +class _AbstractPassthrough(BaseTruncator): + def truncate(self, segment: Any) -> TruncationResult: + return super().truncate(segment) # type: ignore[misc] + + def truncate_variable_mapping(self, v: Mapping[str, Any]) -> tuple[Mapping[str, Any], bool]: + return super().truncate_variable_mapping(v) # type: ignore[misc] + + +class TestBaseTruncatorContract: + def test_base_truncator_methods_should_execute_abstract_placeholders(self) -> None: + passthrough = _AbstractPassthrough() + + truncate_result = passthrough.truncate(StringSegment(value="x")) + mapping_result = passthrough.truncate_variable_mapping({"a": 1}) + + assert truncate_result is None + assert mapping_result is None + + +class TestVariableTruncatorAdditionalBehavior: + def test_default_should_use_dify_config_limits(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE", 111) + monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_ARRAY_LENGTH", 7) + monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH", 33) + + truncator = VariableTruncator.default() + + assert truncator._max_size_bytes == 111 + assert truncator._array_element_limit == 7 + assert truncator._string_length_limit == 33 + + def test_truncate_variable_mapping_should_mark_over_budget_keys_with_ellipsis(self) -> None: + truncator = VariableTruncator(max_size_bytes=5) + mapping = {"very_long_key": "value"} + + result, truncated = truncator.truncate_variable_mapping(mapping) + + assert result == {"very_long_key": "..."} + assert truncated is True + + def test_truncate_variable_mapping_should_handle_segment_values(self) -> None: + truncator = VariableTruncator(max_size_bytes=100) + mapping = {"seg": StringSegment(value="hello")} + + result, truncated = truncator.truncate_variable_mapping(mapping) + + assert isinstance(result["seg"], StringSegment) + assert result["seg"].value == "hello" + assert truncated is False + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (None, False), + (True, False), + (1, False), + (1.5, False), + ("x", True), + ({"k": "v"}, True), + ], + ) + def test_json_value_needs_truncation_should_match_expected_rules( + self, + value: Any, + expected: bool, + ) -> None: + result = VariableTruncator._json_value_needs_truncation(value) + assert result is expected + + def test_truncate_should_use_string_fallback_when_truncated_value_size_exceeds_limit( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + truncator = VariableTruncator(max_size_bytes=10) + forced_result = truncator_module._PartResult( + value=StringSegment(value="this is too long"), + value_size=100, + truncated=True, + ) + monkeypatch.setattr(truncator, "_truncate_segment", lambda *_args, **_kwargs: forced_result) + + result = truncator.truncate(StringSegment(value="input")) + + assert result.truncated is True + assert isinstance(result.result, StringSegment) + assert not result.result.value.startswith('"') + + def test_truncate_segment_should_raise_assertion_for_unexpected_truncatable_segment( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + truncator = VariableTruncator() + monkeypatch.setattr(VariableTruncator, "_segment_need_truncation", lambda _segment: True) + + with pytest.raises(AssertionError): + truncator._truncate_segment(IntegerSegment(value=1), 10) + + def test_calculate_json_size_should_unwrap_segment_values(self) -> None: + segment = StringSegment(value="abc") + + size = VariableTruncator.calculate_json_size(segment) + + assert size == VariableTruncator.calculate_json_size("abc") + + def test_calculate_json_size_should_handle_updated_variable_instances(self) -> None: + updated = UpdatedVariable(name="n", selector=["node", "var"], value_type=SegmentType.STRING, new_value="v") + + size = VariableTruncator.calculate_json_size(updated) + + assert size > 0 + + def test_maybe_qa_structure_should_validate_shape(self) -> None: + assert VariableTruncator._maybe_qa_structure({"qa_chunks": []}) is True + assert VariableTruncator._maybe_qa_structure({"qa_chunks": "not-list"}) is False + assert VariableTruncator._maybe_qa_structure({}) is False + + def test_maybe_parent_child_structure_should_validate_shape(self) -> None: + assert ( + VariableTruncator._maybe_parent_child_structure({"parent_mode": "full", "parent_child_chunks": []}) is True + ) + assert VariableTruncator._maybe_parent_child_structure({"parent_mode": 1, "parent_child_chunks": []}) is False + assert ( + VariableTruncator._maybe_parent_child_structure({"parent_mode": "full", "parent_child_chunks": "bad"}) + is False + ) + + def test_truncate_object_should_truncate_segment_values_inside_object(self) -> None: + truncator = VariableTruncator(string_length_limit=8, max_size_bytes=30) + mapping = {"s": StringSegment(value="long-content")} + + result = truncator._truncate_object(mapping, 20) + + assert result.truncated is True + assert isinstance(result.value["s"], StringSegment) + + def test_truncate_json_primitives_should_handle_updated_variable_input(self) -> None: + truncator = VariableTruncator(max_size_bytes=100) + updated = UpdatedVariable(name="n", selector=["node", "var"], value_type=SegmentType.STRING, new_value="v") + + result = truncator._truncate_json_primitives(updated, 100) + + assert isinstance(result.value, dict) + + def test_truncate_json_primitives_should_raise_assertion_for_unsupported_value_type(self) -> None: + truncator = VariableTruncator() + + with pytest.raises(AssertionError): + truncator._truncate_json_primitives(object(), 100) # type: ignore[arg-type] + + def test_truncate_should_apply_json_string_fallback_for_large_non_string_segment( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + truncator = VariableTruncator(max_size_bytes=10) + forced_segment = ObjectSegment(value={"k": "v"}) + forced_result = truncator_module._PartResult(value=forced_segment, value_size=100, truncated=True) + monkeypatch.setattr(truncator, "_truncate_segment", lambda *_args, **_kwargs: forced_result) + + result = truncator.truncate(ObjectSegment(value={"a": "b"})) + + assert result.truncated is True + assert isinstance(result.result, StringSegment) diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index 78049182ad..ffdcc046f9 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -559,757 +559,3 @@ class TestWebhookServiceUnit: result = _prepare_webhook_execution("test_webhook", is_debug=True) assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None) - - -# === Merged from test_webhook_service_additional.py === - - -from types import SimpleNamespace -from typing import Any, cast -from unittest.mock import MagicMock - -import pytest -from flask import Flask -from graphon.variables.types import SegmentType -from werkzeug.datastructures import FileStorage -from werkzeug.exceptions import RequestEntityTooLarge - -from core.workflow.nodes.trigger_webhook.entities import ( - ContentType, - WebhookBodyParameter, - WebhookData, - WebhookParameter, -) -from models.enums import AppTriggerStatus -from models.model import App -from models.trigger import WorkflowWebhookTrigger -from models.workflow import Workflow -from services.errors.app import QuotaExceededError -from services.trigger import webhook_service as service_module -from services.trigger.webhook_service import WebhookService - - -class _FakeQuery: - def __init__(self, result: Any) -> None: - self._result = result - - def where(self, *args: Any, **kwargs: Any) -> "_FakeQuery": - return self - - def filter(self, *args: Any, **kwargs: Any) -> "_FakeQuery": - return self - - def order_by(self, *args: Any, **kwargs: Any) -> "_FakeQuery": - return self - - def first(self) -> Any: - return self._result - - -class _SessionContext: - def __init__(self, session: Any) -> None: - self._session = session - - def __enter__(self) -> Any: - return self._session - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: - return False - - -@pytest.fixture -def flask_app() -> Flask: - return Flask(__name__) - - -def _patch_session(monkeypatch: pytest.MonkeyPatch, session: Any) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=MagicMock(), session=MagicMock())) - monkeypatch.setattr(service_module, "Session", lambda *args, **kwargs: _SessionContext(session)) - - -def _workflow_trigger(**kwargs: Any) -> WorkflowWebhookTrigger: - return cast(WorkflowWebhookTrigger, SimpleNamespace(**kwargs)) - - -def _workflow(**kwargs: Any) -> Workflow: - return cast(Workflow, SimpleNamespace(**kwargs)) - - -def _app(**kwargs: Any) -> App: - return cast(App, SimpleNamespace(**kwargs)) - - -def test_get_webhook_trigger_and_workflow_should_raise_when_webhook_not_found(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - fake_session = MagicMock() - fake_session.query.return_value = _FakeQuery(None) - _patch_session(monkeypatch, fake_session) - - # Act / Assert - with pytest.raises(ValueError, match="Webhook not found"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - -def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_not_found( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - fake_session = MagicMock() - fake_session.query.side_effect = [_FakeQuery(webhook_trigger), _FakeQuery(None)] - _patch_session(monkeypatch, fake_session) - - # Act / Assert - with pytest.raises(ValueError, match="App trigger not found"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - -def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_rate_limited( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - app_trigger = SimpleNamespace(status=AppTriggerStatus.RATE_LIMITED) - fake_session = MagicMock() - fake_session.query.side_effect = [_FakeQuery(webhook_trigger), _FakeQuery(app_trigger)] - _patch_session(monkeypatch, fake_session) - - # Act / Assert - with pytest.raises(ValueError, match="rate limited"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - -def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_disabled( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - app_trigger = SimpleNamespace(status=AppTriggerStatus.DISABLED) - fake_session = MagicMock() - fake_session.query.side_effect = [_FakeQuery(webhook_trigger), _FakeQuery(app_trigger)] - _patch_session(monkeypatch, fake_session) - - # Act / Assert - with pytest.raises(ValueError, match="disabled"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - -def test_get_webhook_trigger_and_workflow_should_raise_when_workflow_not_found(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - app_trigger = SimpleNamespace(status=AppTriggerStatus.ENABLED) - fake_session = MagicMock() - fake_session.query.side_effect = [_FakeQuery(webhook_trigger), _FakeQuery(app_trigger), _FakeQuery(None)] - _patch_session(monkeypatch, fake_session) - - # Act / Assert - with pytest.raises(ValueError, match="Workflow not found"): - WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - -def test_get_webhook_trigger_and_workflow_should_return_values_for_non_debug_mode( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - app_trigger = SimpleNamespace(status=AppTriggerStatus.ENABLED) - workflow = MagicMock() - workflow.get_node_config_by_id.return_value = {"data": {"key": "value"}} - - fake_session = MagicMock() - fake_session.query.side_effect = [_FakeQuery(webhook_trigger), _FakeQuery(app_trigger), _FakeQuery(workflow)] - _patch_session(monkeypatch, fake_session) - - # Act - got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow("webhook-1") - - # Assert - assert got_trigger is webhook_trigger - assert got_workflow is workflow - assert got_node_config == {"data": {"key": "value"}} - - -def test_get_webhook_trigger_and_workflow_should_return_values_for_debug_mode(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") - workflow = MagicMock() - workflow.get_node_config_by_id.return_value = {"data": {"mode": "debug"}} - - fake_session = MagicMock() - fake_session.query.side_effect = [_FakeQuery(webhook_trigger), _FakeQuery(workflow)] - _patch_session(monkeypatch, fake_session) - - # Act - got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow( - "webhook-1", is_debug=True - ) - - # Assert - assert got_trigger is webhook_trigger - assert got_workflow is workflow - assert got_node_config == {"data": {"mode": "debug"}} - - -def test_extract_webhook_data_should_use_text_fallback_for_unknown_content_type( - flask_app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - warning_mock = MagicMock() - monkeypatch.setattr(service_module.logger, "warning", warning_mock) - webhook_trigger = MagicMock() - - # Act - with flask_app.test_request_context( - "/webhook", - method="POST", - headers={"Content-Type": "application/vnd.custom"}, - data="plain content", - ): - result = WebhookService.extract_webhook_data(webhook_trigger) - - # Assert - assert result["body"] == {"raw": "plain content"} - warning_mock.assert_called_once() - - -def test_extract_webhook_data_should_raise_for_request_too_large( - flask_app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - monkeypatch.setattr(service_module.dify_config, "WEBHOOK_REQUEST_BODY_MAX_SIZE", 1) - - # Act / Assert - with flask_app.test_request_context("/webhook", method="POST", data="ab"): - with pytest.raises(RequestEntityTooLarge): - WebhookService.extract_webhook_data(MagicMock()) - - -def test_extract_octet_stream_body_should_return_none_when_empty_payload(flask_app: Flask) -> None: - # Arrange - webhook_trigger = MagicMock() - - # Act - with flask_app.test_request_context("/webhook", method="POST", data=b""): - body, files = WebhookService._extract_octet_stream_body(webhook_trigger) - - # Assert - assert body == {"raw": None} - assert files == {} - - -def test_extract_octet_stream_body_should_return_none_when_processing_raises( - flask_app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - webhook_trigger = MagicMock() - monkeypatch.setattr(WebhookService, "_detect_binary_mimetype", MagicMock(return_value="application/octet-stream")) - monkeypatch.setattr(WebhookService, "_create_file_from_binary", MagicMock(side_effect=RuntimeError("boom"))) - - # Act - with flask_app.test_request_context("/webhook", method="POST", data=b"abc"): - body, files = WebhookService._extract_octet_stream_body(webhook_trigger) - - # Assert - assert body == {"raw": None} - assert files == {} - - -def test_extract_text_body_should_return_empty_string_when_request_read_fails( - flask_app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - monkeypatch.setattr("flask.wrappers.Request.get_data", MagicMock(side_effect=RuntimeError("read error"))) - - # Act - with flask_app.test_request_context("/webhook", method="POST", data="abc"): - body, files = WebhookService._extract_text_body() - - # Assert - assert body == {"raw": ""} - assert files == {} - - -def test_detect_binary_mimetype_should_fallback_when_magic_raises(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - fake_magic = MagicMock() - fake_magic.from_buffer.side_effect = RuntimeError("magic failed") - monkeypatch.setattr(service_module, "magic", fake_magic) - - # Act - result = WebhookService._detect_binary_mimetype(b"binary") - - # Assert - assert result == "application/octet-stream" - - -def test_process_file_uploads_should_use_octet_stream_fallback_when_mimetype_unknown( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - webhook_trigger = _workflow_trigger(created_by="user-1", tenant_id="tenant-1") - file_obj = MagicMock() - file_obj.to_dict.return_value = {"id": "f-1"} - monkeypatch.setattr(WebhookService, "_create_file_from_binary", MagicMock(return_value=file_obj)) - monkeypatch.setattr(service_module.mimetypes, "guess_type", MagicMock(return_value=(None, None))) - - uploaded = MagicMock() - uploaded.filename = "file.unknown" - uploaded.content_type = None - uploaded.read.return_value = b"content" - - # Act - result = WebhookService._process_file_uploads({"f": uploaded}, webhook_trigger) - - # Assert - assert result == {"f": {"id": "f-1"}} - - -def test_create_file_from_binary_should_call_tool_file_manager_and_file_factory( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - webhook_trigger = _workflow_trigger(created_by="user-1", tenant_id="tenant-1") - manager = MagicMock() - manager.create_file_by_raw.return_value = SimpleNamespace(id="tool-file-1") - monkeypatch.setattr(service_module, "ToolFileManager", MagicMock(return_value=manager)) - expected_file = MagicMock() - monkeypatch.setattr(service_module.file_factory, "build_from_mapping", MagicMock(return_value=expected_file)) - - # Act - result = WebhookService._create_file_from_binary(b"abc", "text/plain", webhook_trigger) - - # Assert - assert result is expected_file - manager.create_file_by_raw.assert_called_once() - - -@pytest.mark.parametrize( - ("raw_value", "param_type", "expected"), - [ - ("42", SegmentType.NUMBER, 42), - ("3.14", SegmentType.NUMBER, 3.14), - ("yes", SegmentType.BOOLEAN, True), - ("no", SegmentType.BOOLEAN, False), - ], -) -def test_convert_form_value_should_convert_supported_types( - raw_value: str, - param_type: str, - expected: Any, -) -> None: - # Arrange - - # Act - result = WebhookService._convert_form_value("param", raw_value, param_type) - - # Assert - assert result == expected - - -def test_convert_form_value_should_raise_for_unsupported_type() -> None: - # Arrange - - # Act / Assert - with pytest.raises(ValueError, match="Unsupported type"): - WebhookService._convert_form_value("p", "x", SegmentType.FILE) - - -def test_validate_json_value_should_return_original_for_unmapped_supported_segment_type( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - warning_mock = MagicMock() - monkeypatch.setattr(service_module.logger, "warning", warning_mock) - - # Act - result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type") - - # Assert - assert result == {"x": 1} - warning_mock.assert_called_once() - - -def test_validate_and_convert_value_should_wrap_conversion_errors() -> None: - # Arrange - - # Act / Assert - with pytest.raises(ValueError, match="validation failed"): - WebhookService._validate_and_convert_value("param", "bad", SegmentType.NUMBER, is_form_data=True) - - -def test_process_parameters_should_raise_when_required_parameter_missing() -> None: - # Arrange - raw_params = {"optional": "x"} - config = [WebhookParameter(name="required_param", type=SegmentType.STRING, required=True)] - - # Act / Assert - with pytest.raises(ValueError, match="Required parameter missing"): - WebhookService._process_parameters(raw_params, config, is_form_data=True) - - -def test_process_parameters_should_include_unconfigured_parameters() -> None: - # Arrange - raw_params = {"known": "1", "unknown": "x"} - config = [WebhookParameter(name="known", type=SegmentType.NUMBER, required=False)] - - # Act - result = WebhookService._process_parameters(raw_params, config, is_form_data=True) - - # Assert - assert result == {"known": 1, "unknown": "x"} - - -def test_process_body_parameters_should_raise_when_required_text_raw_is_missing() -> None: - # Arrange - - # Act / Assert - with pytest.raises(ValueError, match="Required body content missing"): - WebhookService._process_body_parameters( - raw_body={"raw": ""}, - body_configs=[WebhookBodyParameter(name="raw", required=True)], - content_type=ContentType.TEXT, - ) - - -def test_process_body_parameters_should_skip_file_config_for_multipart_form_data() -> None: - # Arrange - raw_body = {"message": "hello", "extra": "x"} - body_configs = [ - WebhookBodyParameter(name="upload", type=SegmentType.FILE, required=True), - WebhookBodyParameter(name="message", type=SegmentType.STRING, required=True), - ] - - # Act - result = WebhookService._process_body_parameters(raw_body, body_configs, ContentType.FORM_DATA) - - # Assert - assert result == {"message": "hello", "extra": "x"} - - -def test_validate_required_headers_should_accept_sanitized_header_names() -> None: - # Arrange - headers = {"x_api_key": "123"} - configs = [WebhookParameter(name="x-api-key", required=True)] - - # Act - WebhookService._validate_required_headers(headers, configs) - - # Assert - assert True - - -def test_validate_required_headers_should_raise_when_required_header_missing() -> None: - # Arrange - headers = {"x-other": "123"} - configs = [WebhookParameter(name="x-api-key", required=True)] - - # Act / Assert - with pytest.raises(ValueError, match="Required header missing"): - WebhookService._validate_required_headers(headers, configs) - - -def test_validate_http_metadata_should_return_content_type_mismatch_error() -> None: - # Arrange - webhook_data = {"method": "POST", "headers": {"Content-Type": "application/json"}} - node_data = WebhookData(method="post", content_type=ContentType.TEXT) - - # Act - result = WebhookService._validate_http_metadata(webhook_data, node_data) - - # Assert - assert result["valid"] is False - assert "Content-type mismatch" in result["error"] - - -def test_extract_content_type_should_fallback_to_lowercase_header_key() -> None: - # Arrange - headers = {"content-type": "application/json; charset=utf-8"} - - # Act - result = WebhookService._extract_content_type(headers) - - # Assert - assert result == "application/json" - - -def test_build_workflow_inputs_should_include_expected_keys() -> None: - # Arrange - webhook_data = {"headers": {"h": "v"}, "query_params": {"q": 1}, "body": {"b": 2}} - - # Act - result = WebhookService.build_workflow_inputs(webhook_data) - - # Assert - assert result["webhook_data"] == webhook_data - assert result["webhook_headers"] == {"h": "v"} - assert result["webhook_query_params"] == {"q": 1} - assert result["webhook_body"] == {"b": 2} - - -def test_trigger_workflow_execution_should_trigger_async_workflow_successfully(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - webhook_trigger = _workflow_trigger( - app_id="app-1", - node_id="node-1", - tenant_id="tenant-1", - webhook_id="webhook-1", - ) - workflow = _workflow(id="wf-1") - webhook_data = {"body": {"x": 1}} - - session = MagicMock() - _patch_session(monkeypatch, session) - - end_user = SimpleNamespace(id="end-user-1") - monkeypatch.setattr( - service_module.EndUserService, "get_or_create_end_user_by_type", MagicMock(return_value=end_user) - ) - quota_type = SimpleNamespace(TRIGGER=SimpleNamespace(consume=MagicMock())) - monkeypatch.setattr(service_module, "QuotaType", quota_type) - trigger_async_mock = MagicMock() - monkeypatch.setattr(service_module.AsyncWorkflowService, "trigger_workflow_async", trigger_async_mock) - - # Act - WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) - - # Assert - trigger_async_mock.assert_called_once() - - -def test_trigger_workflow_execution_should_mark_tenant_rate_limited_when_quota_exceeded( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - webhook_trigger = _workflow_trigger( - app_id="app-1", - node_id="node-1", - tenant_id="tenant-1", - webhook_id="webhook-1", - ) - workflow = _workflow(id="wf-1") - - session = MagicMock() - _patch_session(monkeypatch, session) - - monkeypatch.setattr( - service_module.EndUserService, - "get_or_create_end_user_by_type", - MagicMock(return_value=SimpleNamespace(id="end-user-1")), - ) - quota_type = SimpleNamespace( - TRIGGER=SimpleNamespace( - consume=MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1)) - ) - ) - monkeypatch.setattr(service_module, "QuotaType", quota_type) - mark_rate_limited_mock = MagicMock() - monkeypatch.setattr(service_module.AppTriggerService, "mark_tenant_triggers_rate_limited", mark_rate_limited_mock) - - # Act / Assert - with pytest.raises(QuotaExceededError): - WebhookService.trigger_workflow_execution(webhook_trigger, {"body": {}}, workflow) - mark_rate_limited_mock.assert_called_once_with("tenant-1") - - -def test_trigger_workflow_execution_should_log_and_reraise_unexpected_errors(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - webhook_trigger = _workflow_trigger( - app_id="app-1", - node_id="node-1", - tenant_id="tenant-1", - webhook_id="webhook-1", - ) - workflow = _workflow(id="wf-1") - - session = MagicMock() - _patch_session(monkeypatch, session) - - monkeypatch.setattr( - service_module.EndUserService, "get_or_create_end_user_by_type", MagicMock(side_effect=RuntimeError("boom")) - ) - logger_exception_mock = MagicMock() - monkeypatch.setattr(service_module.logger, "exception", logger_exception_mock) - - # Act / Assert - with pytest.raises(RuntimeError, match="boom"): - WebhookService.trigger_workflow_execution(webhook_trigger, {"body": {}}, workflow) - logger_exception_mock.assert_called_once() - - -def test_sync_webhook_relationships_should_raise_when_workflow_exceeds_node_limit() -> None: - # Arrange - app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") - workflow = _workflow( - walk_nodes=lambda _node_type: [ - (f"node-{i}", {}) for i in range(WebhookService.MAX_WEBHOOK_NODES_PER_WORKFLOW + 1) - ] - ) - - # Act / Assert - with pytest.raises(ValueError, match="maximum webhook node limit"): - WebhookService.sync_webhook_relationships(app, workflow) - - -def test_sync_webhook_relationships_should_raise_when_lock_not_acquired(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") - workflow = _workflow(walk_nodes=lambda _node_type: [("node-1", {})]) - - lock = MagicMock() - lock.acquire.return_value = False - monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) - monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) - - # Act / Assert - with pytest.raises(RuntimeError, match="Failed to acquire lock"): - WebhookService.sync_webhook_relationships(app, workflow) - - -def test_sync_webhook_relationships_should_create_missing_records_and_delete_stale_records( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") - workflow = _workflow(walk_nodes=lambda _node_type: [("node-new", {})]) - - class _WorkflowWebhookTrigger: - app_id = "app_id" - tenant_id = "tenant_id" - webhook_id = "webhook_id" - node_id = "node_id" - - def __init__(self, app_id: str, tenant_id: str, node_id: str, webhook_id: str, created_by: str) -> None: - self.id = None - self.app_id = app_id - self.tenant_id = tenant_id - self.node_id = node_id - self.webhook_id = webhook_id - self.created_by = created_by - - class _Select: - def where(self, *args: Any, **kwargs: Any) -> "_Select": - return self - - class _Session: - def __init__(self) -> None: - self.added: list[Any] = [] - self.deleted: list[Any] = [] - self.commit_count = 0 - self.existing_records = [SimpleNamespace(node_id="node-stale")] - - def scalars(self, _stmt: Any) -> Any: - return SimpleNamespace(all=lambda: self.existing_records) - - def add(self, obj: Any) -> None: - self.added.append(obj) - - def flush(self) -> None: - for idx, obj in enumerate(self.added, start=1): - if obj.id is None: - obj.id = f"rec-{idx}" - - def commit(self) -> None: - self.commit_count += 1 - - def delete(self, obj: Any) -> None: - self.deleted.append(obj) - - lock = MagicMock() - lock.acquire.return_value = True - lock.release.return_value = None - - fake_session = _Session() - - monkeypatch.setattr(service_module, "WorkflowWebhookTrigger", _WorkflowWebhookTrigger) - monkeypatch.setattr(service_module, "select", MagicMock(return_value=_Select())) - monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) - monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) - redis_set_mock = MagicMock() - redis_delete_mock = MagicMock() - monkeypatch.setattr(service_module.redis_client, "set", redis_set_mock) - monkeypatch.setattr(service_module.redis_client, "delete", redis_delete_mock) - monkeypatch.setattr(WebhookService, "generate_webhook_id", MagicMock(return_value="generated-webhook-id")) - _patch_session(monkeypatch, fake_session) - - # Act - WebhookService.sync_webhook_relationships(app, workflow) - - # Assert - assert len(fake_session.added) == 1 - assert len(fake_session.deleted) == 1 - assert fake_session.commit_count == 2 - redis_set_mock.assert_called_once() - redis_delete_mock.assert_called_once() - lock.release.assert_called_once() - - -def test_sync_webhook_relationships_should_log_when_lock_release_fails(monkeypatch: pytest.MonkeyPatch) -> None: - # Arrange - app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") - workflow = _workflow(walk_nodes=lambda _node_type: []) - - class _Select: - def where(self, *args: Any, **kwargs: Any) -> "_Select": - return self - - class _Session: - def scalars(self, _stmt: Any) -> Any: - return SimpleNamespace(all=lambda: []) - - def commit(self) -> None: - return None - - lock = MagicMock() - lock.acquire.return_value = True - lock.release.side_effect = RuntimeError("release failed") - - logger_exception_mock = MagicMock() - - monkeypatch.setattr(service_module, "select", MagicMock(return_value=_Select())) - monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) - monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) - monkeypatch.setattr(service_module.logger, "exception", logger_exception_mock) - _patch_session(monkeypatch, _Session()) - - # Act - WebhookService.sync_webhook_relationships(app, workflow) - - # Assert - assert logger_exception_mock.call_count == 1 - - -def test_generate_webhook_response_should_fallback_when_response_body_is_not_json() -> None: - # Arrange - node_config = {"data": {"status_code": 200, "response_body": "{bad-json"}} - - # Act - body, status = WebhookService.generate_webhook_response(node_config) - - # Assert - assert status == 200 - assert "message" in body - - -def test_generate_webhook_id_should_return_24_character_identifier() -> None: - # Arrange - - # Act - webhook_id = WebhookService.generate_webhook_id() - - # Assert - assert isinstance(webhook_id, str) - assert len(webhook_id) == 24 - - -def test_sanitize_key_should_return_original_value_for_non_string_input() -> None: - # Arrange - - # Act - result = WebhookService._sanitize_key(123) # type: ignore[arg-type] - - # Assert - assert result == 123 diff --git a/api/tests/unit_tests/services/test_webhook_service_additional.py b/api/tests/unit_tests/services/test_webhook_service_additional.py new file mode 100644 index 0000000000..92f8a3fcc0 --- /dev/null +++ b/api/tests/unit_tests/services/test_webhook_service_additional.py @@ -0,0 +1,671 @@ +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock + +import pytest +from flask import Flask +from graphon.variables.types import SegmentType +from werkzeug.exceptions import RequestEntityTooLarge + +from core.workflow.nodes.trigger_webhook.entities import ( + ContentType, + WebhookBodyParameter, + WebhookData, + WebhookParameter, +) +from models.enums import AppTriggerStatus +from models.model import App +from models.trigger import WorkflowWebhookTrigger +from models.workflow import Workflow +from services.errors.app import QuotaExceededError +from services.trigger import webhook_service as service_module +from services.trigger.webhook_service import WebhookService + + +class _FakeQuery: + def __init__(self, result: Any) -> None: + self._result = result + + def where(self, *args: Any, **kwargs: Any) -> "_FakeQuery": + return self + + def filter(self, *args: Any, **kwargs: Any) -> "_FakeQuery": + return self + + def order_by(self, *args: Any, **kwargs: Any) -> "_FakeQuery": + return self + + def first(self) -> Any: + return self._result + + +class _SessionContext: + def __init__(self, session: Any) -> None: + self._session = session + + def __enter__(self) -> Any: + return self._session + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +class _SessionmakerContext: + def __init__(self, session: Any) -> None: + self._session = session + + def begin(self) -> "_SessionmakerContext": + return self + + def __enter__(self) -> Any: + return self._session + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +@pytest.fixture +def flask_app() -> Flask: + return Flask(__name__) + + +def _patch_session(monkeypatch: pytest.MonkeyPatch, session: Any) -> None: + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=MagicMock(), session=MagicMock())) + monkeypatch.setattr(service_module, "Session", lambda *args, **kwargs: _SessionContext(session)) + monkeypatch.setattr(service_module, "sessionmaker", lambda *args, **kwargs: _SessionmakerContext(session)) + + +def _workflow_trigger(**kwargs: Any) -> WorkflowWebhookTrigger: + return cast(WorkflowWebhookTrigger, SimpleNamespace(**kwargs)) + + +def _workflow(**kwargs: Any) -> Workflow: + return cast(Workflow, SimpleNamespace(**kwargs)) + + +def _app(**kwargs: Any) -> App: + return cast(App, SimpleNamespace(**kwargs)) + + +class TestWebhookServiceLookup: + def test_get_webhook_trigger_and_workflow_should_raise_when_webhook_not_found( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + fake_session = MagicMock() + fake_session.scalar.return_value = None + _patch_session(monkeypatch, fake_session) + + with pytest.raises(ValueError, match="Webhook not found"): + WebhookService.get_webhook_trigger_and_workflow("webhook-1") + + def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_not_found( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") + fake_session = MagicMock() + fake_session.scalar.side_effect = [webhook_trigger, None] + _patch_session(monkeypatch, fake_session) + + with pytest.raises(ValueError, match="App trigger not found"): + WebhookService.get_webhook_trigger_and_workflow("webhook-1") + + def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_rate_limited( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") + app_trigger = SimpleNamespace(status=AppTriggerStatus.RATE_LIMITED) + fake_session = MagicMock() + fake_session.scalar.side_effect = [webhook_trigger, app_trigger] + _patch_session(monkeypatch, fake_session) + + with pytest.raises(ValueError, match="rate limited"): + WebhookService.get_webhook_trigger_and_workflow("webhook-1") + + def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_disabled( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") + app_trigger = SimpleNamespace(status=AppTriggerStatus.DISABLED) + fake_session = MagicMock() + fake_session.scalar.side_effect = [webhook_trigger, app_trigger] + _patch_session(monkeypatch, fake_session) + + with pytest.raises(ValueError, match="disabled"): + WebhookService.get_webhook_trigger_and_workflow("webhook-1") + + def test_get_webhook_trigger_and_workflow_should_raise_when_workflow_not_found( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") + app_trigger = SimpleNamespace(status=AppTriggerStatus.ENABLED) + fake_session = MagicMock() + fake_session.scalar.side_effect = [webhook_trigger, app_trigger, None] + _patch_session(monkeypatch, fake_session) + + with pytest.raises(ValueError, match="Workflow not found"): + WebhookService.get_webhook_trigger_and_workflow("webhook-1") + + def test_get_webhook_trigger_and_workflow_should_return_values_for_non_debug_mode( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") + app_trigger = SimpleNamespace(status=AppTriggerStatus.ENABLED) + workflow = MagicMock() + workflow.get_node_config_by_id.return_value = {"data": {"key": "value"}} + + fake_session = MagicMock() + fake_session.scalar.side_effect = [webhook_trigger, app_trigger, workflow] + _patch_session(monkeypatch, fake_session) + + got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow("webhook-1") + + assert got_trigger is webhook_trigger + assert got_workflow is workflow + assert got_node_config == {"data": {"key": "value"}} + + def test_get_webhook_trigger_and_workflow_should_return_values_for_debug_mode( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1") + workflow = MagicMock() + workflow.get_node_config_by_id.return_value = {"data": {"mode": "debug"}} + + fake_session = MagicMock() + fake_session.scalar.side_effect = [webhook_trigger, workflow] + _patch_session(monkeypatch, fake_session) + + got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow( + "webhook-1", + is_debug=True, + ) + + assert got_trigger is webhook_trigger + assert got_workflow is workflow + assert got_node_config == {"data": {"mode": "debug"}} + + +class TestWebhookServiceExtractionFallbacks: + def test_extract_webhook_data_should_use_text_fallback_for_unknown_content_type( + self, + flask_app: Flask, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + warning_mock = MagicMock() + monkeypatch.setattr(service_module.logger, "warning", warning_mock) + webhook_trigger = MagicMock() + + with flask_app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/vnd.custom"}, + data="plain content", + ): + result = WebhookService.extract_webhook_data(webhook_trigger) + + assert result["body"] == {"raw": "plain content"} + warning_mock.assert_called_once() + + def test_extract_webhook_data_should_raise_for_request_too_large( + self, + flask_app: Flask, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(service_module.dify_config, "WEBHOOK_REQUEST_BODY_MAX_SIZE", 1) + + with flask_app.test_request_context("/webhook", method="POST", data="ab"): + with pytest.raises(RequestEntityTooLarge): + WebhookService.extract_webhook_data(MagicMock()) + + def test_extract_octet_stream_body_should_return_none_when_empty_payload(self, flask_app: Flask) -> None: + webhook_trigger = MagicMock() + + with flask_app.test_request_context("/webhook", method="POST", data=b""): + body, files = WebhookService._extract_octet_stream_body(webhook_trigger) + + assert body == {"raw": None} + assert files == {} + + def test_extract_octet_stream_body_should_return_none_when_processing_raises( + self, + flask_app: Flask, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = MagicMock() + monkeypatch.setattr( + WebhookService, "_detect_binary_mimetype", MagicMock(return_value="application/octet-stream") + ) + monkeypatch.setattr(WebhookService, "_create_file_from_binary", MagicMock(side_effect=RuntimeError("boom"))) + + with flask_app.test_request_context("/webhook", method="POST", data=b"abc"): + body, files = WebhookService._extract_octet_stream_body(webhook_trigger) + + assert body == {"raw": None} + assert files == {} + + def test_extract_text_body_should_return_empty_string_when_request_read_fails( + self, + flask_app: Flask, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr("flask.wrappers.Request.get_data", MagicMock(side_effect=RuntimeError("read error"))) + + with flask_app.test_request_context("/webhook", method="POST", data="abc"): + body, files = WebhookService._extract_text_body() + + assert body == {"raw": ""} + assert files == {} + + def test_detect_binary_mimetype_should_fallback_when_magic_raises( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + fake_magic = MagicMock() + fake_magic.from_buffer.side_effect = RuntimeError("magic failed") + monkeypatch.setattr(service_module, "magic", fake_magic) + + result = WebhookService._detect_binary_mimetype(b"binary") + + assert result == "application/octet-stream" + + def test_process_file_uploads_should_use_octet_stream_fallback_when_mimetype_unknown( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = _workflow_trigger(created_by="user-1", tenant_id="tenant-1") + file_obj = MagicMock() + file_obj.to_dict.return_value = {"id": "f-1"} + monkeypatch.setattr(WebhookService, "_create_file_from_binary", MagicMock(return_value=file_obj)) + monkeypatch.setattr(service_module.mimetypes, "guess_type", MagicMock(return_value=(None, None))) + + uploaded = MagicMock() + uploaded.filename = "file.unknown" + uploaded.content_type = None + uploaded.read.return_value = b"content" + + result = WebhookService._process_file_uploads({"f": uploaded}, webhook_trigger) + + assert result == {"f": {"id": "f-1"}} + + def test_create_file_from_binary_should_call_tool_file_manager_and_file_factory( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = _workflow_trigger(created_by="user-1", tenant_id="tenant-1") + manager = MagicMock() + manager.create_file_by_raw.return_value = SimpleNamespace(id="tool-file-1") + monkeypatch.setattr(service_module, "ToolFileManager", MagicMock(return_value=manager)) + expected_file = MagicMock() + monkeypatch.setattr(service_module.file_factory, "build_from_mapping", MagicMock(return_value=expected_file)) + + result = WebhookService._create_file_from_binary(b"abc", "text/plain", webhook_trigger) + + assert result is expected_file + manager.create_file_by_raw.assert_called_once() + + +class TestWebhookServiceValidationAndConversion: + @pytest.mark.parametrize( + ("raw_value", "param_type", "expected"), + [ + ("42", SegmentType.NUMBER, 42), + ("3.14", SegmentType.NUMBER, 3.14), + ("yes", SegmentType.BOOLEAN, True), + ("no", SegmentType.BOOLEAN, False), + ], + ) + def test_convert_form_value_should_convert_supported_types( + self, + raw_value: str, + param_type: str, + expected: Any, + ) -> None: + result = WebhookService._convert_form_value("param", raw_value, param_type) + assert result == expected + + def test_convert_form_value_should_raise_for_unsupported_type(self) -> None: + with pytest.raises(ValueError, match="Unsupported type"): + WebhookService._convert_form_value("p", "x", SegmentType.FILE) + + def test_validate_json_value_should_return_original_for_unmapped_supported_segment_type( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + warning_mock = MagicMock() + monkeypatch.setattr(service_module.logger, "warning", warning_mock) + + result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type") + + assert result == {"x": 1} + warning_mock.assert_called_once() + + def test_validate_and_convert_value_should_wrap_conversion_errors(self) -> None: + with pytest.raises(ValueError, match="validation failed"): + WebhookService._validate_and_convert_value("param", "bad", SegmentType.NUMBER, is_form_data=True) + + def test_process_parameters_should_raise_when_required_parameter_missing(self) -> None: + raw_params = {"optional": "x"} + config = [WebhookParameter(name="required_param", type=SegmentType.STRING, required=True)] + + with pytest.raises(ValueError, match="Required parameter missing"): + WebhookService._process_parameters(raw_params, config, is_form_data=True) + + def test_process_parameters_should_include_unconfigured_parameters(self) -> None: + raw_params = {"known": "1", "unknown": "x"} + config = [WebhookParameter(name="known", type=SegmentType.NUMBER, required=False)] + + result = WebhookService._process_parameters(raw_params, config, is_form_data=True) + + assert result == {"known": 1, "unknown": "x"} + + def test_process_body_parameters_should_raise_when_required_text_raw_is_missing(self) -> None: + with pytest.raises(ValueError, match="Required body content missing"): + WebhookService._process_body_parameters( + raw_body={"raw": ""}, + body_configs=[WebhookBodyParameter(name="raw", required=True)], + content_type=ContentType.TEXT, + ) + + def test_process_body_parameters_should_skip_file_config_for_multipart_form_data(self) -> None: + raw_body = {"message": "hello", "extra": "x"} + body_configs = [ + WebhookBodyParameter(name="upload", type=SegmentType.FILE, required=True), + WebhookBodyParameter(name="message", type=SegmentType.STRING, required=True), + ] + + result = WebhookService._process_body_parameters(raw_body, body_configs, ContentType.FORM_DATA) + + assert result == {"message": "hello", "extra": "x"} + + def test_validate_required_headers_should_accept_sanitized_header_names(self) -> None: + headers = {"x_api_key": "123"} + configs = [WebhookParameter(name="x-api-key", required=True)] + + WebhookService._validate_required_headers(headers, configs) + + def test_validate_required_headers_should_raise_when_required_header_missing(self) -> None: + headers = {"x-other": "123"} + configs = [WebhookParameter(name="x-api-key", required=True)] + + with pytest.raises(ValueError, match="Required header missing"): + WebhookService._validate_required_headers(headers, configs) + + def test_validate_http_metadata_should_return_content_type_mismatch_error(self) -> None: + webhook_data = {"method": "POST", "headers": {"Content-Type": "application/json"}} + node_data = WebhookData(method="post", content_type=ContentType.TEXT) + + result = WebhookService._validate_http_metadata(webhook_data, node_data) + + assert result["valid"] is False + assert "Content-type mismatch" in result["error"] + + def test_extract_content_type_should_fallback_to_lowercase_header_key(self) -> None: + headers = {"content-type": "application/json; charset=utf-8"} + assert WebhookService._extract_content_type(headers) == "application/json" + + def test_build_workflow_inputs_should_include_expected_keys(self) -> None: + webhook_data = {"headers": {"h": "v"}, "query_params": {"q": 1}, "body": {"b": 2}} + + result = WebhookService.build_workflow_inputs(webhook_data) + + assert result["webhook_data"] == webhook_data + assert result["webhook_headers"] == {"h": "v"} + assert result["webhook_query_params"] == {"q": 1} + assert result["webhook_body"] == {"b": 2} + + +class TestWebhookServiceExecutionAndSync: + def test_trigger_workflow_execution_should_trigger_async_workflow_successfully( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = _workflow_trigger( + app_id="app-1", + node_id="node-1", + tenant_id="tenant-1", + webhook_id="webhook-1", + ) + workflow = _workflow(id="wf-1") + webhook_data = {"body": {"x": 1}} + + session = MagicMock() + _patch_session(monkeypatch, session) + + end_user = SimpleNamespace(id="end-user-1") + monkeypatch.setattr( + service_module.EndUserService, + "get_or_create_end_user_by_type", + MagicMock(return_value=end_user), + ) + quota_type = SimpleNamespace(TRIGGER=SimpleNamespace(consume=MagicMock())) + monkeypatch.setattr(service_module, "QuotaType", quota_type) + trigger_async_mock = MagicMock() + monkeypatch.setattr(service_module.AsyncWorkflowService, "trigger_workflow_async", trigger_async_mock) + + WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) + + trigger_async_mock.assert_called_once() + + def test_trigger_workflow_execution_should_mark_tenant_rate_limited_when_quota_exceeded( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = _workflow_trigger( + app_id="app-1", + node_id="node-1", + tenant_id="tenant-1", + webhook_id="webhook-1", + ) + workflow = _workflow(id="wf-1") + + session = MagicMock() + _patch_session(monkeypatch, session) + + monkeypatch.setattr( + service_module.EndUserService, + "get_or_create_end_user_by_type", + MagicMock(return_value=SimpleNamespace(id="end-user-1")), + ) + quota_type = SimpleNamespace( + TRIGGER=SimpleNamespace( + consume=MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1)) + ) + ) + monkeypatch.setattr(service_module, "QuotaType", quota_type) + mark_rate_limited_mock = MagicMock() + monkeypatch.setattr( + service_module.AppTriggerService, "mark_tenant_triggers_rate_limited", mark_rate_limited_mock + ) + + with pytest.raises(QuotaExceededError): + WebhookService.trigger_workflow_execution(webhook_trigger, {"body": {}}, workflow) + + mark_rate_limited_mock.assert_called_once_with("tenant-1") + + def test_trigger_workflow_execution_should_log_and_reraise_unexpected_errors( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + webhook_trigger = _workflow_trigger( + app_id="app-1", + node_id="node-1", + tenant_id="tenant-1", + webhook_id="webhook-1", + ) + workflow = _workflow(id="wf-1") + + session = MagicMock() + _patch_session(monkeypatch, session) + + monkeypatch.setattr( + service_module.EndUserService, + "get_or_create_end_user_by_type", + MagicMock(side_effect=RuntimeError("boom")), + ) + logger_exception_mock = MagicMock() + monkeypatch.setattr(service_module.logger, "exception", logger_exception_mock) + + with pytest.raises(RuntimeError, match="boom"): + WebhookService.trigger_workflow_execution(webhook_trigger, {"body": {}}, workflow) + + logger_exception_mock.assert_called_once() + + def test_sync_webhook_relationships_should_raise_when_workflow_exceeds_node_limit(self) -> None: + app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") + workflow = _workflow( + walk_nodes=lambda _node_type: [ + (f"node-{i}", {}) for i in range(WebhookService.MAX_WEBHOOK_NODES_PER_WORKFLOW + 1) + ] + ) + + with pytest.raises(ValueError, match="maximum webhook node limit"): + WebhookService.sync_webhook_relationships(app, workflow) + + def test_sync_webhook_relationships_should_raise_when_lock_not_acquired( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") + workflow = _workflow(walk_nodes=lambda _node_type: [("node-1", {})]) + + lock = MagicMock() + lock.acquire.return_value = False + monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) + monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) + + with pytest.raises(RuntimeError, match="Failed to acquire lock"): + WebhookService.sync_webhook_relationships(app, workflow) + + def test_sync_webhook_relationships_should_create_missing_records_and_delete_stale_records( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") + workflow = _workflow(walk_nodes=lambda _node_type: [("node-new", {})]) + + class _WorkflowWebhookTrigger: + app_id = "app_id" + tenant_id = "tenant_id" + webhook_id = "webhook_id" + node_id = "node_id" + + def __init__(self, app_id: str, tenant_id: str, node_id: str, webhook_id: str, created_by: str) -> None: + self.id = None + self.app_id = app_id + self.tenant_id = tenant_id + self.node_id = node_id + self.webhook_id = webhook_id + self.created_by = created_by + + class _Select: + def where(self, *args: Any, **kwargs: Any) -> "_Select": + return self + + class _Session: + def __init__(self) -> None: + self.added: list[Any] = [] + self.deleted: list[Any] = [] + self.commit_count = 0 + self.existing_records = [SimpleNamespace(node_id="node-stale")] + + def scalars(self, _stmt: Any) -> Any: + return SimpleNamespace(all=lambda: self.existing_records) + + def add(self, obj: Any) -> None: + self.added.append(obj) + + def flush(self) -> None: + for idx, obj in enumerate(self.added, start=1): + if obj.id is None: + obj.id = f"rec-{idx}" + + def commit(self) -> None: + self.commit_count += 1 + + def delete(self, obj: Any) -> None: + self.deleted.append(obj) + + lock = MagicMock() + lock.acquire.return_value = True + lock.release.return_value = None + + fake_session = _Session() + + monkeypatch.setattr(service_module, "WorkflowWebhookTrigger", _WorkflowWebhookTrigger) + monkeypatch.setattr(service_module, "select", MagicMock(return_value=_Select())) + monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) + monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) + redis_set_mock = MagicMock() + redis_delete_mock = MagicMock() + monkeypatch.setattr(service_module.redis_client, "set", redis_set_mock) + monkeypatch.setattr(service_module.redis_client, "delete", redis_delete_mock) + monkeypatch.setattr(WebhookService, "generate_webhook_id", MagicMock(return_value="generated-webhook-id")) + _patch_session(monkeypatch, fake_session) + + WebhookService.sync_webhook_relationships(app, workflow) + + assert len(fake_session.added) == 1 + assert len(fake_session.deleted) == 1 + redis_set_mock.assert_called_once() + redis_delete_mock.assert_called_once() + lock.release.assert_called_once() + + def test_sync_webhook_relationships_should_log_when_lock_release_fails( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1") + workflow = _workflow(walk_nodes=lambda _node_type: []) + + class _Select: + def where(self, *args: Any, **kwargs: Any) -> "_Select": + return self + + class _Session: + def scalars(self, _stmt: Any) -> Any: + return SimpleNamespace(all=lambda: []) + + def commit(self) -> None: + return None + + lock = MagicMock() + lock.acquire.return_value = True + lock.release.side_effect = RuntimeError("release failed") + + logger_exception_mock = MagicMock() + + monkeypatch.setattr(service_module, "select", MagicMock(return_value=_Select())) + monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None)) + monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock)) + monkeypatch.setattr(service_module.logger, "exception", logger_exception_mock) + _patch_session(monkeypatch, _Session()) + + WebhookService.sync_webhook_relationships(app, workflow) + + assert logger_exception_mock.call_count == 1 + + +class TestWebhookServiceUtilities: + def test_generate_webhook_response_should_fallback_when_response_body_is_not_json(self) -> None: + node_config = {"data": {"status_code": 200, "response_body": "{bad-json"}} + + body, status = WebhookService.generate_webhook_response(node_config) + + assert status == 200 + assert "message" in body + + def test_generate_webhook_id_should_return_24_character_identifier(self) -> None: + webhook_id = WebhookService.generate_webhook_id() + + assert isinstance(webhook_id, str) + assert len(webhook_id) == 24 + + def test_sanitize_key_should_return_original_value_for_non_string_input(self) -> None: + result = WebhookService._sanitize_key(123) # type: ignore[arg-type] + assert result == 123 diff --git a/api/tests/unit_tests/services/test_workflow_run_service.py b/api/tests/unit_tests/services/test_workflow_run_service.py new file mode 100644 index 0000000000..03471389a6 --- /dev/null +++ b/api/tests/unit_tests/services/test_workflow_run_service.py @@ -0,0 +1,262 @@ +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import Engine + +from models import Account, App, EndUser, WorkflowRunTriggeredFrom +from services import workflow_run_service as service_module +from services.workflow_run_service import WorkflowRunService + + +@pytest.fixture +def repository_factory_mocks(monkeypatch: pytest.MonkeyPatch) -> tuple[MagicMock, MagicMock, Any]: + node_repo = MagicMock() + workflow_run_repo = MagicMock() + factory = SimpleNamespace( + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + return node_repo, workflow_run_repo, factory + + +def _app_model(**kwargs: Any) -> App: + return cast(App, SimpleNamespace(**kwargs)) + + +def _account(**kwargs: Any) -> Account: + return cast(Account, SimpleNamespace(**kwargs)) + + +def _end_user(**kwargs: Any) -> EndUser: + return cast(EndUser, SimpleNamespace(**kwargs)) + + +class TestWorkflowRunServiceInitialization: + def test___init___should_create_sessionmaker_from_db_engine_when_session_factory_missing( + self, + monkeypatch: pytest.MonkeyPatch, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + ) -> None: + session_factory = MagicMock(name="session_factory") + sessionmaker_mock = MagicMock(return_value=session_factory) + monkeypatch.setattr(service_module, "sessionmaker", sessionmaker_mock) + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine="db-engine")) + + service = WorkflowRunService() + + sessionmaker_mock.assert_called_once_with(bind="db-engine", expire_on_commit=False) + assert service._session_factory is session_factory + + def test___init___should_create_sessionmaker_when_engine_is_provided( + self, + monkeypatch: pytest.MonkeyPatch, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + ) -> None: + class FakeEngine: + pass + + session_factory = MagicMock(name="session_factory") + sessionmaker_mock = MagicMock(return_value=session_factory) + monkeypatch.setattr(service_module, "Engine", FakeEngine) + monkeypatch.setattr(service_module, "sessionmaker", sessionmaker_mock) + engine = cast(Engine, FakeEngine()) + + service = WorkflowRunService(session_factory=engine) + + sessionmaker_mock.assert_called_once_with(bind=engine, expire_on_commit=False) + assert service._session_factory is session_factory + + def test___init___should_keep_provided_sessionmaker_and_create_repositories( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + ) -> None: + node_repo, workflow_run_repo, factory = repository_factory_mocks + session_factory = MagicMock(name="session_factory") + + service = WorkflowRunService(session_factory=session_factory) + + assert service._session_factory is session_factory + assert service._node_execution_service_repo is node_repo + assert service._workflow_run_repo is workflow_run_repo + factory.create_api_workflow_node_execution_repository.assert_called_once_with(session_factory) + factory.create_api_workflow_run_repository.assert_called_once_with(session_factory) + + +class TestWorkflowRunServiceQueries: + def test_get_paginate_workflow_runs_should_forward_filters_and_parse_limit( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + ) -> None: + _, workflow_run_repo, _ = repository_factory_mocks + service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + app_model = _app_model(tenant_id="tenant-1", id="app-1") + expected = MagicMock(name="pagination") + workflow_run_repo.get_paginated_workflow_runs.return_value = expected + args = {"limit": "7", "last_id": "last-1", "status": "succeeded"} + + result = service.get_paginate_workflow_runs( + app_model=app_model, + args=args, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + ) + + assert result is expected + workflow_run_repo.get_paginated_workflow_runs.assert_called_once_with( + tenant_id="tenant-1", + app_id="app-1", + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + limit=7, + last_id="last-1", + status="succeeded", + ) + + def test_get_paginate_advanced_chat_workflow_runs_should_attach_message_fields_when_message_exists( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + app_model = _app_model(tenant_id="tenant-1", id="app-1") + run_with_message = SimpleNamespace( + id="run-1", + status="running", + message=SimpleNamespace(id="msg-1", conversation_id="conv-1"), + ) + run_without_message = SimpleNamespace(id="run-2", status="succeeded", message=None) + pagination = SimpleNamespace(data=[run_with_message, run_without_message]) + monkeypatch.setattr(service, "get_paginate_workflow_runs", MagicMock(return_value=pagination)) + + result = service.get_paginate_advanced_chat_workflow_runs(app_model=app_model, args={"limit": "2"}) + + assert result is pagination + assert len(result.data) == 2 + assert result.data[0].message_id == "msg-1" + assert result.data[0].conversation_id == "conv-1" + assert result.data[0].status == "running" + assert not hasattr(result.data[1], "message_id") + assert result.data[1].id == "run-2" + + def test_get_workflow_run_should_delegate_to_repository_by_tenant_and_app( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + ) -> None: + _, workflow_run_repo, _ = repository_factory_mocks + service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + app_model = _app_model(tenant_id="tenant-1", id="app-1") + expected = MagicMock(name="workflow_run") + workflow_run_repo.get_workflow_run_by_id.return_value = expected + + result = service.get_workflow_run(app_model=app_model, run_id="run-1") + + assert result is expected + workflow_run_repo.get_workflow_run_by_id.assert_called_once_with( + tenant_id="tenant-1", + app_id="app-1", + run_id="run-1", + ) + + def test_get_workflow_runs_count_should_forward_optional_filters( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + ) -> None: + _, workflow_run_repo, _ = repository_factory_mocks + service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + app_model = _app_model(tenant_id="tenant-1", id="app-1") + expected = {"total": 3, "succeeded": 2} + workflow_run_repo.get_workflow_runs_count.return_value = expected + + result = service.get_workflow_runs_count( + app_model=app_model, + status="succeeded", + time_range="7d", + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + ) + + assert result == expected + workflow_run_repo.get_workflow_runs_count.assert_called_once_with( + tenant_id="tenant-1", + app_id="app-1", + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + status="succeeded", + time_range="7d", + ) + + def test_get_workflow_run_node_executions_should_return_empty_list_when_run_not_found( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=None)) + app_model = _app_model(id="app-1") + user = _account(current_tenant_id="tenant-1") + + result = service.get_workflow_run_node_executions(app_model=app_model, run_id="run-1", user=user) + + assert result == [] + + def test_get_workflow_run_node_executions_should_use_end_user_tenant_id( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + node_repo, _, _ = repository_factory_mocks + service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) + + class FakeEndUser: + def __init__(self, tenant_id: str) -> None: + self.tenant_id = tenant_id + + monkeypatch.setattr(service_module, "EndUser", FakeEndUser) + user = cast(EndUser, FakeEndUser(tenant_id="tenant-end-user")) + app_model = _app_model(id="app-1") + expected = [SimpleNamespace(id="exec-1")] + node_repo.get_executions_by_workflow_run.return_value = expected + + result = service.get_workflow_run_node_executions(app_model=app_model, run_id="run-1", user=user) + + assert result == expected + node_repo.get_executions_by_workflow_run.assert_called_once_with( + tenant_id="tenant-end-user", + app_id="app-1", + workflow_run_id="run-1", + ) + + def test_get_workflow_run_node_executions_should_use_account_current_tenant_id( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + node_repo, _, _ = repository_factory_mocks + service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) + app_model = _app_model(id="app-1") + user = _account(current_tenant_id="tenant-account") + expected = [SimpleNamespace(id="exec-1"), SimpleNamespace(id="exec-2")] + node_repo.get_executions_by_workflow_run.return_value = expected + + result = service.get_workflow_run_node_executions(app_model=app_model, run_id="run-1", user=user) + + assert result == expected + node_repo.get_executions_by_workflow_run.assert_called_once_with( + tenant_id="tenant-account", + app_id="app-1", + workflow_run_id="run-1", + ) + + def test_get_workflow_run_node_executions_should_raise_when_resolved_tenant_id_is_none( + self, + repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) + app_model = _app_model(id="app-1") + user = _account(current_tenant_id=None) + + with pytest.raises(ValueError, match="tenant_id cannot be None"): + service.get_workflow_run_node_executions(app_model=app_model, run_id="run-1", user=user) diff --git a/api/tests/unit_tests/services/test_workflow_run_service_pause.py b/api/tests/unit_tests/services/test_workflow_run_service_pause.py index 64b21317ab..a62c9f4555 100644 --- a/api/tests/unit_tests/services/test_workflow_run_service_pause.py +++ b/api/tests/unit_tests/services/test_workflow_run_service_pause.py @@ -176,300 +176,3 @@ class TestWorkflowRunService: service = WorkflowRunService(session_factory) assert service._session_factory == session_factory - - -# === Merged from test_workflow_run_service.py === - - -from types import SimpleNamespace -from typing import Any, cast -from unittest.mock import MagicMock - -import pytest - -from models import Account, App, EndUser, WorkflowRunTriggeredFrom -from services import workflow_run_service as service_module -from services.workflow_run_service import WorkflowRunService - - -@pytest.fixture -def repository_factory_mocks(monkeypatch: pytest.MonkeyPatch) -> tuple[MagicMock, MagicMock, Any]: - # Arrange - node_repo = MagicMock() - workflow_run_repo = MagicMock() - factory = SimpleNamespace( - create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), - create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), - ) - monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) - - # Assert - return node_repo, workflow_run_repo, factory - - -def _app_model(**kwargs: Any) -> App: - return cast(App, SimpleNamespace(**kwargs)) - - -def _account(**kwargs: Any) -> Account: - return cast(Account, SimpleNamespace(**kwargs)) - - -def _end_user(**kwargs: Any) -> EndUser: - return cast(EndUser, SimpleNamespace(**kwargs)) - - -def test___init___should_create_sessionmaker_from_db_engine_when_session_factory_missing( - monkeypatch: pytest.MonkeyPatch, - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], -) -> None: - # Arrange - session_factory = MagicMock(name="session_factory") - sessionmaker_mock = MagicMock(return_value=session_factory) - monkeypatch.setattr(service_module, "sessionmaker", sessionmaker_mock) - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine="db-engine")) - - # Act - service = WorkflowRunService() - - # Assert - sessionmaker_mock.assert_called_once_with(bind="db-engine", expire_on_commit=False) - assert service._session_factory is session_factory - - -def test___init___should_create_sessionmaker_when_engine_is_provided( - monkeypatch: pytest.MonkeyPatch, - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], -) -> None: - # Arrange - class FakeEngine: - pass - - session_factory = MagicMock(name="session_factory") - sessionmaker_mock = MagicMock(return_value=session_factory) - monkeypatch.setattr(service_module, "Engine", FakeEngine) - monkeypatch.setattr(service_module, "sessionmaker", sessionmaker_mock) - engine = cast(Engine, FakeEngine()) - - # Act - service = WorkflowRunService(session_factory=engine) - - # Assert - sessionmaker_mock.assert_called_once_with(bind=engine, expire_on_commit=False) - assert service._session_factory is session_factory - - -def test___init___should_keep_provided_sessionmaker_and_create_repositories( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], -) -> None: - # Arrange - node_repo, workflow_run_repo, factory = repository_factory_mocks - session_factory = MagicMock(name="session_factory") - - # Act - service = WorkflowRunService(session_factory=session_factory) - - # Assert - assert service._session_factory is session_factory - assert service._node_execution_service_repo is node_repo - assert service._workflow_run_repo is workflow_run_repo - factory.create_api_workflow_node_execution_repository.assert_called_once_with(session_factory) - factory.create_api_workflow_run_repository.assert_called_once_with(session_factory) - - -def test_get_paginate_workflow_runs_should_forward_filters_and_parse_limit( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], -) -> None: - # Arrange - _, workflow_run_repo, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) - app_model = _app_model(tenant_id="tenant-1", id="app-1") - expected = MagicMock(name="pagination") - workflow_run_repo.get_paginated_workflow_runs.return_value = expected - args = {"limit": "7", "last_id": "last-1", "status": "succeeded"} - - # Act - result = service.get_paginate_workflow_runs( - app_model=app_model, - args=args, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN, - ) - - # Assert - assert result is expected - workflow_run_repo.get_paginated_workflow_runs.assert_called_once_with( - tenant_id="tenant-1", - app_id="app-1", - triggered_from=WorkflowRunTriggeredFrom.APP_RUN, - limit=7, - last_id="last-1", - status="succeeded", - ) - - -def test_get_paginate_advanced_chat_workflow_runs_should_attach_message_fields_when_message_exists( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) - app_model = _app_model(tenant_id="tenant-1", id="app-1") - run_with_message = SimpleNamespace( - id="run-1", - status="running", - message=SimpleNamespace(id="msg-1", conversation_id="conv-1"), - ) - run_without_message = SimpleNamespace(id="run-2", status="succeeded", message=None) - pagination = SimpleNamespace(data=[run_with_message, run_without_message]) - monkeypatch.setattr(service, "get_paginate_workflow_runs", MagicMock(return_value=pagination)) - - # Act - result = service.get_paginate_advanced_chat_workflow_runs(app_model=app_model, args={"limit": "2"}) - - # Assert - assert result is pagination - assert len(result.data) == 2 - assert result.data[0].message_id == "msg-1" - assert result.data[0].conversation_id == "conv-1" - assert result.data[0].status == "running" - assert not hasattr(result.data[1], "message_id") - assert result.data[1].id == "run-2" - - -def test_get_workflow_run_should_delegate_to_repository_by_tenant_and_app( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], -) -> None: - # Arrange - _, workflow_run_repo, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) - app_model = _app_model(tenant_id="tenant-1", id="app-1") - expected = MagicMock(name="workflow_run") - workflow_run_repo.get_workflow_run_by_id.return_value = expected - - # Act - result = service.get_workflow_run(app_model=app_model, run_id="run-1") - - # Assert - assert result is expected - workflow_run_repo.get_workflow_run_by_id.assert_called_once_with( - tenant_id="tenant-1", - app_id="app-1", - run_id="run-1", - ) - - -def test_get_workflow_runs_count_should_forward_optional_filters( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], -) -> None: - # Arrange - _, workflow_run_repo, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) - app_model = _app_model(tenant_id="tenant-1", id="app-1") - expected = {"total": 3, "succeeded": 2} - workflow_run_repo.get_workflow_runs_count.return_value = expected - - # Act - result = service.get_workflow_runs_count( - app_model=app_model, - status="succeeded", - time_range="7d", - triggered_from=WorkflowRunTriggeredFrom.APP_RUN, - ) - - # Assert - assert result == expected - workflow_run_repo.get_workflow_runs_count.assert_called_once_with( - tenant_id="tenant-1", - app_id="app-1", - triggered_from=WorkflowRunTriggeredFrom.APP_RUN, - status="succeeded", - time_range="7d", - ) - - -def test_get_workflow_run_node_executions_should_return_empty_list_when_run_not_found( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) - monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=None)) - app_model = _app_model(id="app-1") - user = _account(current_tenant_id="tenant-1") - - # Act - result = service.get_workflow_run_node_executions(app_model=app_model, run_id="run-1", user=user) - - # Assert - assert result == [] - - -def test_get_workflow_run_node_executions_should_use_end_user_tenant_id( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - node_repo, _, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) - monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) - - class FakeEndUser: - def __init__(self, tenant_id: str) -> None: - self.tenant_id = tenant_id - - monkeypatch.setattr(service_module, "EndUser", FakeEndUser) - user = cast(EndUser, FakeEndUser(tenant_id="tenant-end-user")) - app_model = _app_model(id="app-1") - expected = [SimpleNamespace(id="exec-1")] - node_repo.get_executions_by_workflow_run.return_value = expected - - # Act - result = service.get_workflow_run_node_executions(app_model=app_model, run_id="run-1", user=user) - - # Assert - assert result == expected - node_repo.get_executions_by_workflow_run.assert_called_once_with( - tenant_id="tenant-end-user", - app_id="app-1", - workflow_run_id="run-1", - ) - - -def test_get_workflow_run_node_executions_should_use_account_current_tenant_id( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - node_repo, _, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) - monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) - app_model = _app_model(id="app-1") - user = _account(current_tenant_id="tenant-account") - expected = [SimpleNamespace(id="exec-1"), SimpleNamespace(id="exec-2")] - node_repo.get_executions_by_workflow_run.return_value = expected - - # Act - result = service.get_workflow_run_node_executions(app_model=app_model, run_id="run-1", user=user) - - # Assert - assert result == expected - node_repo.get_executions_by_workflow_run.assert_called_once_with( - tenant_id="tenant-account", - app_id="app-1", - workflow_run_id="run-1", - ) - - -def test_get_workflow_run_node_executions_should_raise_when_resolved_tenant_id_is_none( - repository_factory_mocks: tuple[MagicMock, MagicMock, Any], - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) - monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) - app_model = _app_model(id="app-1") - user = _account(current_tenant_id=None) - - # Act / Assert - with pytest.raises(ValueError, match="tenant_id cannot be None"): - service.get_workflow_run_node_executions(app_model=app_model, run_id="run-1", user=user) diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 1b253eb2f1..406b4fb9d0 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -969,8 +969,7 @@ class TestWorkflowService: # 1. Workflow exists # 2. No app is currently using it # 3. Not published as a tool - mock_session.scalar.side_effect = [mock_workflow, None] # workflow exists, no app using it - mock_session.query.return_value.where.return_value.first.return_value = None # no tool provider + mock_session.scalar.side_effect = [mock_workflow, None, None] # workflow, no app using it, no tool provider with patch("services.workflow_service.select") as mock_select: mock_stmt = MagicMock() @@ -1045,8 +1044,7 @@ class TestWorkflowService: mock_tool_provider = MagicMock() mock_session = MagicMock() - mock_session.scalar.side_effect = [mock_workflow, None] # workflow exists, no app using it - mock_session.query.return_value.where.return_value.first.return_value = mock_tool_provider + mock_session.scalar.side_effect = [mock_workflow, None, mock_tool_provider] # workflow, no app, tool provider with patch("services.workflow_service.select") as mock_select: mock_stmt = MagicMock() @@ -2753,9 +2751,9 @@ class TestWorkflowServiceFreeNodeExecution: variable_pool = MagicMock() with ( - patch("services.workflow_service.GraphInitParams") as mock_graph_init_params, + patch("services.workflow_service.DifyGraphInitContext") as mock_graph_init_context_cls, patch("services.workflow_service.GraphRuntimeState"), - patch("services.workflow_service.build_dify_run_context"), + patch("services.workflow_service.build_dify_run_context") as mock_build_dify_run_context, patch("services.workflow_service.DifyHumanInputNodeRuntime") as mock_runtime_cls, patch("services.workflow_service.HumanInputNode") as mock_node_cls, ): @@ -2764,4 +2762,17 @@ class TestWorkflowServiceFreeNodeExecution: ) assert node == mock_node_cls.return_value mock_node_cls.assert_called_once() - mock_runtime_cls.assert_called_once_with(mock_graph_init_params.return_value.run_context) + mock_graph_init_context_cls.assert_called_once_with( + workflow_id="wf-1", + graph_config=workflow.graph_dict, + run_context=mock_build_dify_run_context.return_value, + call_depth=0, + ) + mock_runtime_cls.assert_called_once_with(mock_build_dify_run_context.return_value) + mock_node_cls.assert_called_once_with( + id="n-1", + config=node_config, + graph_init_params=mock_graph_init_context_cls.return_value.to_graph_init_params.return_value, + graph_runtime_state=ANY, + runtime=mock_runtime_cls.return_value, + ) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index 175900071b..79a2d30f57 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -15,17 +15,24 @@ def _mock_session(mock_session_cls): return session +def _mock_sessionmaker(mock_sm_cls): + """Helper: set up a sessionmaker().begin() context manager mock and return the inner session.""" + session = MagicMock() + mock_sm_cls.return_value.begin.return_value.__enter__ = MagicMock(return_value=session) + mock_sm_cls.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) + return session + + class TestDeleteCustomOauthClientParams: - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_deletes_and_returns_success(self, mock_db, mock_session_cls): - session = _mock_session(mock_session_cls) + def test_deletes_and_returns_success(self, mock_db, mock_sm_cls): + session = _mock_sessionmaker(mock_sm_cls) result = BuiltinToolManageService.delete_custom_oauth_client_params("tenant-1", "google") assert result == {"result": "success"} - session.query.return_value.filter_by.return_value.delete.assert_called_once() - session.commit.assert_called_once() + session.execute.assert_called_once() class TestListBuiltinToolProviderTools: @@ -104,7 +111,7 @@ class TestIsOauthSystemClientExists: @patch(f"{MODULE}.db") def test_true_when_exists(self, mock_db, mock_session_cls): session = _mock_session(mock_session_cls) - session.query.return_value.filter_by.return_value.first.return_value = MagicMock() + session.scalar.return_value = MagicMock() assert BuiltinToolManageService.is_oauth_system_client_exists("google") is True @@ -112,7 +119,7 @@ class TestIsOauthSystemClientExists: @patch(f"{MODULE}.db") def test_false_when_missing(self, mock_db, mock_session_cls): session = _mock_session(mock_session_cls) - session.query.return_value.filter_by.return_value.first.return_value = None + session.scalar.return_value = None assert BuiltinToolManageService.is_oauth_system_client_exists("google") is False @@ -122,7 +129,7 @@ class TestIsOauthCustomClientEnabled: @patch(f"{MODULE}.db") def test_true_when_enabled(self, mock_db, mock_session_cls): session = _mock_session(mock_session_cls) - session.query.return_value.filter_by.return_value.first.return_value = MagicMock(enabled=True) + session.scalar.return_value = MagicMock(enabled=True) assert BuiltinToolManageService.is_oauth_custom_client_enabled("t", "g") is True @@ -130,7 +137,7 @@ class TestIsOauthCustomClientEnabled: @patch(f"{MODULE}.db") def test_false_when_none(self, mock_db, mock_session_cls): session = _mock_session(mock_session_cls) - session.query.return_value.filter_by.return_value.first.return_value = None + session.scalar.return_value = None assert BuiltinToolManageService.is_oauth_custom_client_enabled("t", "g") is False @@ -138,23 +145,23 @@ class TestIsOauthCustomClientEnabled: class TestDeleteBuiltinToolProvider: @patch(f"{MODULE}.BuiltinToolManageService.create_tool_encrypter") @patch(f"{MODULE}.ToolManager") - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_raises_when_not_found(self, mock_db, mock_session_cls, mock_tm, mock_enc): - session = _mock_session(mock_session_cls) - session.query.return_value.where.return_value.first.return_value = None + def test_raises_when_not_found(self, mock_db, mock_sm_cls, mock_tm, mock_enc): + session = _mock_sessionmaker(mock_sm_cls) + session.scalar.return_value = None with pytest.raises(ValueError, match="you have not added provider"): BuiltinToolManageService.delete_builtin_tool_provider("t", "p", "id") @patch(f"{MODULE}.BuiltinToolManageService.create_tool_encrypter") @patch(f"{MODULE}.ToolManager") - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_deletes_provider_and_clears_cache(self, mock_db, mock_session_cls, mock_tm, mock_enc): - session = _mock_session(mock_session_cls) + def test_deletes_provider_and_clears_cache(self, mock_db, mock_sm_cls, mock_tm, mock_enc): + session = _mock_sessionmaker(mock_sm_cls) db_provider = MagicMock() - session.query.return_value.where.return_value.first.return_value = db_provider + session.scalar.return_value = db_provider mock_cache = MagicMock() mock_enc.return_value = (MagicMock(), mock_cache) @@ -162,40 +169,38 @@ class TestDeleteBuiltinToolProvider: assert result == {"result": "success"} session.delete.assert_called_once_with(db_provider) - session.commit.assert_called_once() mock_cache.delete.assert_called_once() class TestSetDefaultProvider: - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_raises_when_not_found(self, mock_db, mock_session_cls): - session = _mock_session(mock_session_cls) - session.query.return_value.filter_by.return_value.first.return_value = None + def test_raises_when_not_found(self, mock_db, mock_sm_cls): + session = _mock_sessionmaker(mock_sm_cls) + session.scalar.return_value = None with pytest.raises(ValueError, match="provider not found"): BuiltinToolManageService.set_default_provider("t", "u", "p", "id") - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_sets_default_and_clears_old(self, mock_db, mock_session_cls): - session = _mock_session(mock_session_cls) + def test_sets_default_and_clears_old(self, mock_db, mock_sm_cls): + session = _mock_sessionmaker(mock_sm_cls) target = MagicMock() - session.query.return_value.filter_by.return_value.first.return_value = target + session.scalar.return_value = target result = BuiltinToolManageService.set_default_provider("t", "u", "p", "id") assert result == {"result": "success"} assert target.is_default is True - session.commit.assert_called_once() class TestUpdateBuiltinToolProvider: - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_raises_when_provider_not_exists(self, mock_db, mock_session_cls): - session = _mock_session(mock_session_cls) - session.query.return_value.where.return_value.first.return_value = None + def test_raises_when_provider_not_exists(self, mock_db, mock_sm_cls): + session = _mock_sessionmaker(mock_sm_cls) + session.scalar.return_value = None with pytest.raises(ValueError, match="you have not added provider"): BuiltinToolManageService.update_builtin_tool_provider("u", "t", "p", "c") @@ -203,12 +208,12 @@ class TestUpdateBuiltinToolProvider: @patch(f"{MODULE}.BuiltinToolManageService.create_tool_encrypter") @patch(f"{MODULE}.CredentialType") @patch(f"{MODULE}.ToolManager") - @patch(f"{MODULE}.Session") + @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") - def test_updates_credentials_and_commits(self, mock_db, mock_session_cls, mock_tm, mock_cred_type, mock_enc): - session = _mock_session(mock_session_cls) + def test_updates_credentials_and_commits(self, mock_db, mock_sm_cls, mock_tm, mock_cred_type, mock_enc): + session = _mock_sessionmaker(mock_sm_cls) db_provider = MagicMock(credential_type="api_key", credentials="{}") - session.query.return_value.where.return_value.first.return_value = db_provider + session.scalar.return_value = db_provider mock_cred_instance = MagicMock() mock_cred_instance.is_editable.return_value = True @@ -227,7 +232,6 @@ class TestUpdateBuiltinToolProvider: result = BuiltinToolManageService.update_builtin_tool_provider("u", "t", "p", "c", credentials={"key": "val"}) assert result == {"result": "success"} - session.commit.assert_called_once() mock_cache.delete.assert_called_once() @@ -270,7 +274,7 @@ class TestGetOauthClient: mock_create_enc.return_value = (mock_encrypter, MagicMock()) user_client = MagicMock(oauth_params='{"encrypted": "data"}') - session.query.return_value.filter_by.return_value.first.return_value = user_client + session.scalar.return_value = user_client result = BuiltinToolManageService.get_oauth_client("t", "google") @@ -293,10 +297,7 @@ class TestGetOauthClient: mock_create_enc.return_value = (MagicMock(), MagicMock()) system_client = MagicMock(encrypted_oauth_params="enc") - session.query.return_value.filter_by.return_value.first.side_effect = [ - None, # user client - system_client, # system client - ] + session.scalar.side_effect = [None, system_client] result = BuiltinToolManageService.get_oauth_client("t", "google") @@ -321,7 +322,7 @@ class TestGetCustomOauthClientParams: @patch(f"{MODULE}.db") def test_returns_empty_when_none(self, mock_db, mock_session_cls): session = _mock_session(mock_session_cls) - session.query.return_value.filter_by.return_value.first.return_value = None + session.scalar.return_value = None result = BuiltinToolManageService.get_custom_oauth_client_params("t", "p") @@ -387,7 +388,7 @@ class TestGetBuiltinProvider: session = _mock_session(mock_session_cls) mock_prov_id.return_value.provider_name = "google" mock_prov_id.return_value.organization = "langgenius" - session.query.return_value.where.return_value.order_by.return_value.first.return_value = None + session.scalar.return_value = None result = BuiltinToolManageService.get_builtin_provider("google", "t") @@ -413,7 +414,7 @@ class TestGetBuiltinProvider: return m mock_prov_id.side_effect = prov_id_side_effect - session.query.return_value.where.return_value.order_by.return_value.first.return_value = db_provider + session.scalar.return_value = db_provider result = BuiltinToolManageService.get_builtin_provider("google", "t") @@ -435,7 +436,7 @@ class TestGetBuiltinProvider: mock_prov_id.side_effect = prov_id_side_effect db_provider = MagicMock(provider="third-party/custom/custom-tool") - session.query.return_value.where.return_value.order_by.return_value.first.return_value = db_provider + session.scalar.return_value = db_provider result = BuiltinToolManageService.get_builtin_provider("third-party/custom/custom-tool", "t") @@ -448,7 +449,7 @@ class TestGetBuiltinProvider: session = _mock_session(mock_session_cls) mock_prov_id.side_effect = Exception("parse error") fallback = MagicMock() - session.query.return_value.where.return_value.order_by.return_value.first.return_value = fallback + session.scalar.return_value = fallback result = BuiltinToolManageService.get_builtin_provider("old-provider", "t") diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index b8b073f75c..4146fd312b 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -3,7 +3,6 @@ import queue from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime -from itertools import cycle from threading import Event import pytest @@ -223,577 +222,3 @@ def test_resolve_task_id_priority(context_task_id, buffered_task_id, expected) - buffer_state.task_id_ready.set() task_id = _resolve_task_id(resumption_context, buffer_state, "run-1", wait_timeout=0.0) assert task_id == expected - - -# === Merged from test_workflow_event_snapshot_service_additional.py === - - -import json -import queue -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import UTC, datetime -from threading import Event -from types import SimpleNamespace -from typing import Any, cast -from unittest.mock import MagicMock - -import pytest -from graphon.enums import WorkflowExecutionStatus -from graphon.runtime import GraphRuntimeState, VariablePool -from sqlalchemy.orm import Session, sessionmaker - -from core.app.app_config.entities import WorkflowUIBasedAppConfig -from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity -from core.app.entities.task_entities import StreamEvent -from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper -from models.enums import CreatorUserRole -from models.model import AppMode -from models.workflow import WorkflowRun -from repositories.entities.workflow_pause import WorkflowPauseEntity -from services import workflow_event_snapshot_service as service_module -from services.workflow_event_snapshot_service import BufferState, MessageContext, build_workflow_event_stream - - -def _build_workflow_run_additional(status: WorkflowExecutionStatus = WorkflowExecutionStatus.RUNNING) -> WorkflowRun: - return WorkflowRun( - id="run-1", - tenant_id="tenant-1", - app_id="app-1", - workflow_id="workflow-1", - type="workflow", - triggered_from="app-run", - version="v1", - graph=None, - inputs=json.dumps({"query": "hello"}), - status=status, - outputs=json.dumps({}), - error=None, - elapsed_time=1.2, - total_tokens=5, - total_steps=2, - created_by_role=CreatorUserRole.END_USER, - created_by="user-1", - created_at=datetime(2024, 1, 1, tzinfo=UTC), - ) - - -def _build_resumption_context_additional(task_id: str) -> WorkflowResumptionContext: - app_config = WorkflowUIBasedAppConfig( - tenant_id="tenant-1", - app_id="app-1", - app_mode=AppMode.WORKFLOW, - workflow_id="workflow-1", - ) - generate_entity = WorkflowAppGenerateEntity( - task_id=task_id, - app_config=app_config, - inputs={}, - files=[], - user_id="user-1", - stream=True, - invoke_from=InvokeFrom.EXPLORE, - call_depth=0, - workflow_execution_id="run-1", - ) - runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) - runtime_state.outputs = {"answer": "ok"} - wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) - return WorkflowResumptionContext( - generate_entity=wrapper, - serialized_graph_runtime_state=runtime_state.dumps(), - ) - - -class _SessionContext: - def __init__(self, session: Any) -> None: - self._session = session - - def __enter__(self) -> Any: - return self._session - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: - return False - - -class _SessionMaker: - def __init__(self, session: Any) -> None: - self._session = session - - def __call__(self) -> _SessionContext: - return _SessionContext(self._session) - - -class _SubscriptionContext: - def __init__(self, subscription: Any) -> None: - self._subscription = subscription - - def __enter__(self) -> Any: - return self._subscription - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: - return False - - -class _Topic: - def __init__(self, subscription: Any) -> None: - self._subscription = subscription - - def subscribe(self) -> _SubscriptionContext: - return _SubscriptionContext(self._subscription) - - -class _StaticSubscription: - def receive(self, timeout: int = 1) -> None: - return None - - -@dataclass(frozen=True) -class _PauseEntity(WorkflowPauseEntity): - state: bytes - - @property - def id(self) -> str: - return "pause-1" - - @property - def workflow_execution_id(self) -> str: - return "run-1" - - @property - def resumed_at(self) -> datetime | None: - return None - - @property - def paused_at(self) -> datetime: - return datetime(2024, 1, 1, tzinfo=UTC) - - def get_state(self) -> bytes: - return self.state - - def get_pause_reasons(self) -> list[Any]: - return [] - - -def test_get_message_context_should_return_none_when_no_message() -> None: - # Arrange - session = SimpleNamespace(scalar=MagicMock(return_value=None)) - session_maker = _SessionMaker(session) - - # Act - result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1") - - # Assert - assert result is None - - -def test_get_message_context_should_default_created_at_to_zero_when_message_has_no_timestamp() -> None: - # Arrange - message = SimpleNamespace( - id="msg-1", - conversation_id="conv-1", - created_at=None, - answer="answer", - ) - session = SimpleNamespace(scalar=MagicMock(return_value=message)) - session_maker = _SessionMaker(session) - - # Act - result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1") - - # Assert - assert result is not None - assert result.created_at == 0 - assert result.message_id == "msg-1" - assert result.conversation_id == "conv-1" - assert result.answer == "answer" - - -def test_load_resumption_context_should_return_none_when_pause_entity_missing() -> None: - # Arrange - - # Act - result = service_module._load_resumption_context(None) - - # Assert - assert result is None - - -def test_load_resumption_context_should_return_none_when_pause_entity_state_is_invalid() -> None: - # Arrange - pause_entity = _PauseEntity(state=b"not-a-valid-state") - - # Act - result = service_module._load_resumption_context(pause_entity) - - # Assert - assert result is None - - -def test_load_resumption_context_should_parse_valid_state_into_context() -> None: - # Arrange - context = _build_resumption_context_additional(task_id="task-ctx") - pause_entity = _PauseEntity(state=context.dumps().encode()) - - # Act - result = service_module._load_resumption_context(pause_entity) - - # Assert - assert result is not None - assert result.get_generate_entity().task_id == "task-ctx" - - -def test_resolve_task_id_should_return_workflow_run_id_when_buffer_state_is_missing() -> None: - # Arrange - - # Act - result = service_module._resolve_task_id( - resumption_context=None, - buffer_state=None, - workflow_run_id="run-1", - ) - - # Assert - assert result == "run-1" - - -@pytest.mark.parametrize( - ("payload", "expected"), - [ - (b'{"event":"node_started"}', {"event": "node_started"}), - (b"invalid-json", None), - (b"[]", None), - ], -) -def test_parse_event_message_should_parse_only_json_object( - payload: bytes, - expected: dict[str, Any] | None, -) -> None: - # Arrange - - # Act - result = service_module._parse_event_message(payload) - - # Assert - assert result == expected - - -def test_is_terminal_event_should_recognize_finished_and_optional_paused_events() -> None: - # Arrange - finished_event = {"event": StreamEvent.WORKFLOW_FINISHED.value} - paused_event = {"event": StreamEvent.WORKFLOW_PAUSED.value} - - # Act - is_finished = service_module._is_terminal_event(finished_event, include_paused=False) - paused_without_flag = service_module._is_terminal_event(paused_event, include_paused=False) - paused_with_flag = service_module._is_terminal_event(paused_event, include_paused=True) - - # Assert - assert is_finished is True - assert paused_without_flag is False - assert paused_with_flag is True - assert service_module._is_terminal_event(StreamEvent.PING.value, include_paused=True) is False - - -def test_apply_message_context_should_update_payload_when_context_exists() -> None: - # Arrange - payload: dict[str, Any] = {"event": "workflow_started"} - context = MessageContext(conversation_id="conv-1", message_id="msg-1", created_at=1700000000) - - # Act - service_module._apply_message_context(payload, context) - - # Assert - assert payload["conversation_id"] == "conv-1" - assert payload["message_id"] == "msg-1" - assert payload["created_at"] == 1700000000 - - -def test_start_buffering_should_capture_task_id_and_enqueue_event() -> None: - # Arrange - class Subscription: - def __init__(self) -> None: - self._calls = 0 - - def receive(self, timeout: int = 1) -> bytes | None: - self._calls += 1 - if self._calls == 1: - return b'{"event":"node_started","task_id":"task-1"}' - return None - - subscription = Subscription() - - # Act - buffer_state = service_module._start_buffering(subscription) - ready = buffer_state.task_id_ready.wait(timeout=1) - event = buffer_state.queue.get(timeout=1) - buffer_state.stop_event.set() - finished = buffer_state.done_event.wait(timeout=1) - - # Assert - assert ready is True - assert finished is True - assert buffer_state.task_id_hint == "task-1" - assert event["event"] == "node_started" - - -def test_start_buffering_should_drop_old_event_when_queue_is_full( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - class QueueWithSingleFull: - def __init__(self) -> None: - self._first_put = True - self.items: list[dict[str, Any]] = [{"event": "old"}] - - def put_nowait(self, item: dict[str, Any]) -> None: - if self._first_put: - self._first_put = False - raise queue.Full - self.items.append(item) - - def get_nowait(self) -> dict[str, Any]: - if not self.items: - raise queue.Empty - return self.items.pop(0) - - def empty(self) -> bool: - return len(self.items) == 0 - - fake_queue = QueueWithSingleFull() - monkeypatch.setattr(service_module.queue, "Queue", lambda maxsize=2048: fake_queue) - - class Subscription: - def __init__(self) -> None: - self._calls = 0 - - def receive(self, timeout: int = 1) -> bytes | None: - self._calls += 1 - if self._calls == 1: - return b'{"event":"node_started","task_id":"task-2"}' - return None - - subscription = Subscription() - - # Act - buffer_state = service_module._start_buffering(subscription) - ready = buffer_state.task_id_ready.wait(timeout=1) - buffer_state.stop_event.set() - finished = buffer_state.done_event.wait(timeout=1) - - # Assert - assert ready is True - assert finished is True - assert fake_queue.items[-1]["task_id"] == "task-2" - - -def test_start_buffering_should_set_done_event_when_subscription_raises() -> None: - # Arrange - class Subscription: - def receive(self, timeout: int = 1) -> bytes | None: - raise RuntimeError("subscription failure") - - subscription = Subscription() - - # Act - buffer_state = service_module._start_buffering(subscription) - finished = buffer_state.done_event.wait(timeout=1) - - # Assert - assert finished is True - - -def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_event( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) - topic = _Topic(_StaticSubscription()) - workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) - node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) - factory = SimpleNamespace( - create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), - create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), - ) - monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) - monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) - monkeypatch.setattr( - service_module, - "_get_message_context", - MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000)), - ) - monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) - buffer_state = BufferState( - queue=queue.Queue(), - stop_event=Event(), - done_event=Event(), - task_id_ready=Event(), - task_id_hint="task-1", - ) - monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) - monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) - monkeypatch.setattr( - service_module, - "_build_snapshot_events", - MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value, "task_id": "task-1"}]), - ) - - # Act - events = list( - build_workflow_event_stream( - app_mode=AppMode.ADVANCED_CHAT, - workflow_run=workflow_run, - tenant_id="tenant-1", - app_id="app-1", - session_maker=MagicMock(), - ) - ) - - # Assert - assert events[0] == StreamEvent.PING.value - finished_event = cast(Mapping[str, Any], events[1]) - assert finished_event["event"] == StreamEvent.WORKFLOW_FINISHED.value - assert buffer_state.stop_event.is_set() is True - node_repo.get_execution_snapshots_by_workflow_run.assert_called_once() - called_kwargs = node_repo.get_execution_snapshots_by_workflow_run.call_args.kwargs - assert called_kwargs["workflow_run_id"] == "run-1" - - -def test_build_workflow_event_stream_should_emit_periodic_ping_and_stop_after_idle_timeout( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) - topic = _Topic(_StaticSubscription()) - workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) - node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) - factory = SimpleNamespace( - create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), - create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), - ) - monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) - monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) - monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) - monkeypatch.setattr(service_module, "_build_snapshot_events", MagicMock(return_value=[])) - monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) - - class AlwaysEmptyQueue: - def empty(self) -> bool: - return False - - def get(self, timeout: int = 1) -> None: - raise queue.Empty - - buffer_state = BufferState( - queue=AlwaysEmptyQueue(), # type: ignore[arg-type] - stop_event=Event(), - done_event=Event(), - task_id_ready=Event(), - task_id_hint="task-1", - ) - monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) - time_values = cycle([0.0, 6.0, 21.0, 26.0]) - monkeypatch.setattr(service_module.time, "time", lambda: next(time_values)) - - # Act - events = list( - build_workflow_event_stream( - app_mode=AppMode.WORKFLOW, - workflow_run=workflow_run, - tenant_id="tenant-1", - app_id="app-1", - session_maker=MagicMock(), - idle_timeout=20.0, - ping_interval=5.0, - ) - ) - - # Assert - assert events == [StreamEvent.PING.value, StreamEvent.PING.value] - assert buffer_state.stop_event.is_set() is True - - -def test_build_workflow_event_stream_should_exit_when_buffer_done_and_empty( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) - topic = _Topic(_StaticSubscription()) - workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) - node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) - factory = SimpleNamespace( - create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), - create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), - ) - monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) - monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) - monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) - monkeypatch.setattr(service_module, "_build_snapshot_events", MagicMock(return_value=[])) - monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) - buffer_state = BufferState( - queue=queue.Queue(), - stop_event=Event(), - done_event=Event(), - task_id_ready=Event(), - task_id_hint="task-1", - ) - buffer_state.done_event.set() - monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) - - # Act - events = list( - build_workflow_event_stream( - app_mode=AppMode.WORKFLOW, - workflow_run=workflow_run, - tenant_id="tenant-1", - app_id="app-1", - session_maker=MagicMock(), - ) - ) - - # Assert - assert events == [StreamEvent.PING.value] - assert buffer_state.stop_event.is_set() is True - - -def test_build_workflow_event_stream_should_continue_when_pause_loading_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Arrange - workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.PAUSED) - topic = _Topic(_StaticSubscription()) - workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(side_effect=RuntimeError("boom"))) - node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) - factory = SimpleNamespace( - create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), - create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), - ) - monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) - monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) - monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) - monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) - snapshot_builder = MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value}]) - monkeypatch.setattr(service_module, "_build_snapshot_events", snapshot_builder) - buffer_state = BufferState( - queue=queue.Queue(), - stop_event=Event(), - done_event=Event(), - task_id_ready=Event(), - task_id_hint="task-1", - ) - monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) - - # Act - events = list( - build_workflow_event_stream( - app_mode=AppMode.WORKFLOW, - workflow_run=workflow_run, - tenant_id="tenant-1", - app_id="app-1", - session_maker=MagicMock(), - ) - ) - - # Assert - assert events[0] == StreamEvent.PING.value - assert snapshot_builder.call_args.kwargs["pause_entity"] is None diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py new file mode 100644 index 0000000000..5e96eb4518 --- /dev/null +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py @@ -0,0 +1,505 @@ +import json +import queue +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from itertools import cycle +from threading import Event +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock + +import pytest +from graphon.enums import WorkflowExecutionStatus +from graphon.runtime import GraphRuntimeState, VariablePool +from sqlalchemy.orm import Session, sessionmaker + +from core.app.app_config.entities import WorkflowUIBasedAppConfig +from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.app.entities.task_entities import StreamEvent +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper +from models.enums import CreatorUserRole +from models.model import AppMode +from models.workflow import WorkflowRun +from repositories.entities.workflow_pause import WorkflowPauseEntity +from services import workflow_event_snapshot_service as service_module +from services.workflow_event_snapshot_service import BufferState, MessageContext, build_workflow_event_stream + + +def _build_workflow_run(status: WorkflowExecutionStatus = WorkflowExecutionStatus.RUNNING) -> WorkflowRun: + return WorkflowRun( + id="run-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + type="workflow", + triggered_from="app-run", + version="v1", + graph=None, + inputs=json.dumps({"query": "hello"}), + status=status, + outputs=json.dumps({}), + error=None, + elapsed_time=1.2, + total_tokens=5, + total_steps=2, + created_by_role=CreatorUserRole.END_USER, + created_by="user-1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant-1", + app_id="app-1", + app_mode=AppMode.WORKFLOW, + workflow_id="workflow-1", + ) + generate_entity = WorkflowAppGenerateEntity( + task_id=task_id, + app_config=app_config, + inputs={}, + files=[], + user_id="user-1", + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id="run-1", + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.outputs = {"answer": "ok"} + wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) + return WorkflowResumptionContext( + generate_entity=wrapper, + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +class _SessionContext: + def __init__(self, session: Any) -> None: + self._session = session + + def __enter__(self) -> Any: + return self._session + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +class _SessionMaker: + def __init__(self, session: Any) -> None: + self._session = session + + def __call__(self) -> _SessionContext: + return _SessionContext(self._session) + + +class _SubscriptionContext: + def __init__(self, subscription: Any) -> None: + self._subscription = subscription + + def __enter__(self) -> Any: + return self._subscription + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +class _Topic: + def __init__(self, subscription: Any) -> None: + self._subscription = subscription + + def subscribe(self) -> _SubscriptionContext: + return _SubscriptionContext(self._subscription) + + +class _StaticSubscription: + def receive(self, timeout: int = 1) -> None: + return None + + +@dataclass(frozen=True) +class _PauseEntity(WorkflowPauseEntity): + state: bytes + + @property + def id(self) -> str: + return "pause-1" + + @property + def workflow_execution_id(self) -> str: + return "run-1" + + @property + def resumed_at(self) -> datetime | None: + return None + + @property + def paused_at(self) -> datetime: + return datetime(2024, 1, 1, tzinfo=UTC) + + def get_state(self) -> bytes: + return self.state + + def get_pause_reasons(self) -> list[Any]: + return [] + + +class TestWorkflowEventSnapshotHelpers: + def test_get_message_context_should_return_none_when_no_message(self) -> None: + session = SimpleNamespace(scalar=MagicMock(return_value=None)) + session_maker = _SessionMaker(session) + + result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1") + + assert result is None + + def test_get_message_context_should_default_created_at_to_zero_when_message_has_no_timestamp(self) -> None: + message = SimpleNamespace( + id="msg-1", + conversation_id="conv-1", + created_at=None, + answer="answer", + ) + session = SimpleNamespace(scalar=MagicMock(return_value=message)) + session_maker = _SessionMaker(session) + + result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1") + + assert result is not None + assert result.created_at == 0 + assert result.message_id == "msg-1" + assert result.conversation_id == "conv-1" + assert result.answer == "answer" + + def test_load_resumption_context_should_return_none_when_pause_entity_missing(self) -> None: + assert service_module._load_resumption_context(None) is None + + def test_load_resumption_context_should_return_none_when_pause_entity_state_is_invalid(self) -> None: + pause_entity = _PauseEntity(state=b"not-a-valid-state") + assert service_module._load_resumption_context(pause_entity) is None + + def test_load_resumption_context_should_parse_valid_state_into_context(self) -> None: + context = _build_resumption_context(task_id="task-ctx") + pause_entity = _PauseEntity(state=context.dumps().encode()) + + result = service_module._load_resumption_context(pause_entity) + + assert result is not None + assert result.get_generate_entity().task_id == "task-ctx" + + def test_resolve_task_id_should_return_workflow_run_id_when_buffer_state_is_missing(self) -> None: + result = service_module._resolve_task_id( + resumption_context=None, + buffer_state=None, + workflow_run_id="run-1", + ) + + assert result == "run-1" + + @pytest.mark.parametrize( + ("payload", "expected"), + [ + (b'{"event":"node_started"}', {"event": "node_started"}), + (b"invalid-json", None), + (b"[]", None), + ], + ) + def test_parse_event_message_should_parse_only_json_object( + self, + payload: bytes, + expected: dict[str, Any] | None, + ) -> None: + result = service_module._parse_event_message(payload) + assert result == expected + + def test_is_terminal_event_should_recognize_finished_and_optional_paused_events(self) -> None: + finished_event = {"event": StreamEvent.WORKFLOW_FINISHED.value} + paused_event = {"event": StreamEvent.WORKFLOW_PAUSED.value} + + is_finished = service_module._is_terminal_event(finished_event, include_paused=False) + paused_without_flag = service_module._is_terminal_event(paused_event, include_paused=False) + paused_with_flag = service_module._is_terminal_event(paused_event, include_paused=True) + + assert is_finished is True + assert paused_without_flag is False + assert paused_with_flag is True + assert service_module._is_terminal_event(StreamEvent.PING.value, include_paused=True) is False + + def test_apply_message_context_should_update_payload_when_context_exists(self) -> None: + payload: dict[str, Any] = {"event": "workflow_started"} + context = MessageContext(conversation_id="conv-1", message_id="msg-1", created_at=1700000000) + + service_module._apply_message_context(payload, context) + + assert payload["conversation_id"] == "conv-1" + assert payload["message_id"] == "msg-1" + assert payload["created_at"] == 1700000000 + + def test_start_buffering_should_capture_task_id_and_enqueue_event(self) -> None: + class Subscription: + def __init__(self) -> None: + self._calls = 0 + + def receive(self, timeout: int = 1) -> bytes | None: + self._calls += 1 + if self._calls == 1: + return b'{"event":"node_started","task_id":"task-1"}' + return None + + subscription = Subscription() + + buffer_state = service_module._start_buffering(subscription) + ready = buffer_state.task_id_ready.wait(timeout=1) + event = buffer_state.queue.get(timeout=1) + buffer_state.stop_event.set() + finished = buffer_state.done_event.wait(timeout=1) + + assert ready is True + assert finished is True + assert buffer_state.task_id_hint == "task-1" + assert event["event"] == "node_started" + + def test_start_buffering_should_drop_old_event_when_queue_is_full( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + class QueueWithSingleFull: + def __init__(self) -> None: + self._first_put = True + self.items: list[dict[str, Any]] = [{"event": "old"}] + + def put_nowait(self, item: dict[str, Any]) -> None: + if self._first_put: + self._first_put = False + raise queue.Full + self.items.append(item) + + def get_nowait(self) -> dict[str, Any]: + if not self.items: + raise queue.Empty + return self.items.pop(0) + + def empty(self) -> bool: + return len(self.items) == 0 + + fake_queue = QueueWithSingleFull() + monkeypatch.setattr(service_module.queue, "Queue", lambda maxsize=2048: fake_queue) + + class Subscription: + def __init__(self) -> None: + self._calls = 0 + + def receive(self, timeout: int = 1) -> bytes | None: + self._calls += 1 + if self._calls == 1: + return b'{"event":"node_started","task_id":"task-2"}' + return None + + subscription = Subscription() + + buffer_state = service_module._start_buffering(subscription) + ready = buffer_state.task_id_ready.wait(timeout=1) + buffer_state.stop_event.set() + finished = buffer_state.done_event.wait(timeout=1) + + assert ready is True + assert finished is True + assert fake_queue.items[-1]["task_id"] == "task-2" + + def test_start_buffering_should_set_done_event_when_subscription_raises(self) -> None: + class Subscription: + def receive(self, timeout: int = 1) -> bytes | None: + raise RuntimeError("subscription failure") + + subscription = Subscription() + buffer_state = service_module._start_buffering(subscription) + + assert buffer_state.done_event.wait(timeout=1) is True + + +class TestBuildWorkflowEventStream: + def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_event( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + workflow_run = _build_workflow_run(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr( + service_module, + "_get_message_context", + MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000)), + ) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + monkeypatch.setattr( + service_module, + "_build_snapshot_events", + MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value, "task_id": "task-1"}]), + ) + + events = list( + build_workflow_event_stream( + app_mode=AppMode.ADVANCED_CHAT, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + assert events[0] == StreamEvent.PING.value + finished_event = cast(Mapping[str, Any], events[1]) + assert finished_event["event"] == StreamEvent.WORKFLOW_FINISHED.value + assert buffer_state.stop_event.is_set() is True + node_repo.get_execution_snapshots_by_workflow_run.assert_called_once() + called_kwargs = node_repo.get_execution_snapshots_by_workflow_run.call_args.kwargs + assert called_kwargs["workflow_run_id"] == "run-1" + + def test_build_workflow_event_stream_should_emit_periodic_ping_and_stop_after_idle_timeout( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + workflow_run = _build_workflow_run(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_build_snapshot_events", MagicMock(return_value=[])) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + + class AlwaysEmptyQueue: + def empty(self) -> bool: + return False + + def get(self, timeout: int = 1) -> None: + raise queue.Empty + + buffer_state = BufferState( + queue=AlwaysEmptyQueue(), # type: ignore[arg-type] + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + time_values = cycle([0.0, 6.0, 21.0, 26.0]) + monkeypatch.setattr(service_module.time, "time", lambda: next(time_values)) + + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + idle_timeout=20.0, + ping_interval=5.0, + ) + ) + + assert events == [StreamEvent.PING.value, StreamEvent.PING.value] + assert buffer_state.stop_event.is_set() is True + + def test_build_workflow_event_stream_should_exit_when_buffer_done_and_empty( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + workflow_run = _build_workflow_run(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_build_snapshot_events", MagicMock(return_value=[])) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + buffer_state.done_event.set() + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + assert events == [StreamEvent.PING.value] + assert buffer_state.stop_event.is_set() is True + + def test_build_workflow_event_stream_should_continue_when_pause_loading_fails( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + workflow_run = _build_workflow_run(status=WorkflowExecutionStatus.PAUSED) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(side_effect=RuntimeError("boom"))) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + snapshot_builder = MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value}]) + monkeypatch.setattr(service_module, "_build_snapshot_events", snapshot_builder) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + assert events[0] == StreamEvent.PING.value + assert snapshot_builder.call_args.kwargs["pause_entity"] is None diff --git a/api/tests/unit_tests/tasks/test_clean_dataset_task.py b/api/tests/unit_tests/tasks/test_clean_dataset_task.py index 936a10d6c5..b4332334ab 100644 --- a/api/tests/unit_tests/tasks/test_clean_dataset_task.py +++ b/api/tests/unit_tests/tasks/test_clean_dataset_task.py @@ -60,12 +60,6 @@ def mock_db_session(): cm.__exit__.return_value = None mock_sf.create_session.return_value = cm - # Setup query chain - mock_query = MagicMock() - mock_session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.delete.return_value = 0 - # Setup scalars for select queries mock_session.scalars.return_value.all.return_value = [] @@ -220,11 +214,6 @@ class TestPipelineAndWorkflowDeletion: - Pipeline record is deleted - Related workflow record is deleted """ - # Arrange - mock_query = mock_db_session.session.query.return_value - mock_query.where.return_value = mock_query - mock_query.delete.return_value = 1 - # Act clean_dataset_task( dataset_id=dataset_id, @@ -236,9 +225,9 @@ class TestPipelineAndWorkflowDeletion: pipeline_id=pipeline_id, ) - # Assert - verify delete was called for pipeline-related queries - # The actual count depends on total queries, but pipeline deletion should add 2 more - assert mock_query.delete.call_count >= 7 # 5 base + 2 pipeline/workflow + # Assert - verify execute was called for delete operations + # 1 attachment JOIN query + 5 base deletes + 2 pipeline/workflow deletes = 8 + assert mock_db_session.session.execute.call_count >= 8 def test_clean_dataset_task_without_pipeline_id( self, @@ -256,11 +245,6 @@ class TestPipelineAndWorkflowDeletion: Expected behavior: - Pipeline and workflow deletion queries are not executed """ - # Arrange - mock_query = mock_db_session.session.query.return_value - mock_query.where.return_value = mock_query - mock_query.delete.return_value = 1 - # Act clean_dataset_task( dataset_id=dataset_id, @@ -272,8 +256,9 @@ class TestPipelineAndWorkflowDeletion: pipeline_id=None, ) - # Assert - verify delete was called only for base queries (5 times) - assert mock_query.delete.call_count == 5 + # Assert - verify execute was called for delete operations + # 1 attachment JOIN query + 5 base deletes = 6 + assert mock_db_session.session.execute.call_count == 6 # ============================================================================ diff --git a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py index 34e474c921..5dad58b8f1 100644 --- a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py +++ b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py @@ -82,8 +82,8 @@ def mock_db_session(): """Mock session_factory.create_session() to return a session whose queries use shared test data. Tests set session._shared_data = {"dataset": , "documents": [, ...]} - This fixture makes session.query(Dataset).first() return the shared dataset, - and session.query(Document).all()/first() return from the shared documents. + This fixture makes session.scalar(select(Dataset)...) return the shared dataset, + and session.scalars(select(Document)...).all() return the shared documents. """ with patch("tasks.document_indexing_task.session_factory") as mock_sf: session = MagicMock() @@ -92,93 +92,68 @@ def mock_db_session(): # Keep a pointer so repeated Document.first() calls iterate across provided docs session._doc_first_idx = 0 - def _query_side_effect(model): - q = MagicMock() + def _get_entity(stmt) -> type | None: + """Extract the mapped entity class from a SQLAlchemy select statement.""" + try: + descs = stmt.column_descriptions + if descs: + return descs[0].get("entity") + except (AttributeError, TypeError): + pass + return None - # Capture filters passed via where(...) so first()/all() can honor them. - q._filters = {} + def _extract_id_from_where(stmt) -> str | None: + """Return the value bound to the 'id' column in the WHERE clause, if present.""" + try: + where = stmt.whereclause + if where is None: + return None + # Both single-clause and AND-clause-list cases + clauses = list(getattr(where, "clauses", [where])) + for clause in clauses: + left = getattr(clause, "left", None) + right = getattr(clause, "right", None) + if left is not None and right is not None: + if getattr(left, "key", None) == "id": + return getattr(right, "value", None) + except Exception: + pass + return None - def _extract_filters(*conds, **kw): - # Support both SQLAlchemy expressions (BinaryExpression) and kwargs - # We only need the simple fields used by production code: id, dataset_id, and id.in_(...) - for cond in conds: - left = getattr(cond, "left", None) - right = getattr(cond, "right", None) - key = None - if left is not None: - key = getattr(left, "key", None) or getattr(left, "name", None) - if not key: - continue - # Right side might be a BindParameter with .value, or a raw value/sequence - val = getattr(right, "value", right) - q._filters[key] = val - # Also accept kwargs (e.g., where(id=...)) just in case - for k, v in kw.items(): - q._filters[k] = v - - def _where_side_effect(*conds, **kw): - _extract_filters(*conds, **kw) - return q - - q.where.side_effect = _where_side_effect - - # Dataset queries - if model.__name__ == "Dataset": - - def _dataset_first(): - ds = session._shared_data.get("dataset") - if not ds: - return None - if "id" in q._filters: - val = q._filters["id"] - if isinstance(val, (list, tuple, set)): - return ds if ds.id in val else None - return ds if ds.id == val else None - return ds - - def _dataset_all(): - ds = session._shared_data.get("dataset") - if not ds: - return [] - first = _dataset_first() - return [first] if first else [] - - q.first.side_effect = _dataset_first - q.all.side_effect = _dataset_all - return q - - # Document queries - if model.__name__ == "Document": - - def _apply_doc_filters(docs): - result = list(docs) - for key in ("id", "dataset_id"): - if key in q._filters: - val = q._filters[key] - if isinstance(val, (list, tuple, set)): - result = [d for d in result if getattr(d, key, None) in val] - else: - result = [d for d in result if getattr(d, key, None) == val] - return result - - def _docs_all(): + def _scalar_side_effect(stmt): + entity = _get_entity(stmt) + if entity is not None: + if entity.__name__ == "Dataset": + return session._shared_data.get("dataset") + elif entity.__name__ == "Document": docs = session._shared_data.get("documents", []) - return _apply_doc_filters(docs) + if not docs: + return None + # When the WHERE clause filters by id, return the matching document + queried_id = _extract_id_from_where(stmt) + if queried_id: + doc_map = {d.id: d for d in docs} + return doc_map.get(queried_id, docs[0]) + return docs[0] + return None - def _docs_first(): - docs = _docs_all() - return docs[0] if docs else None + def _scalars_side_effect(stmt): + entity = _get_entity(stmt) + result = MagicMock() + if entity is not None: + if entity.__name__ == "Document": + result.all.return_value = list(session._shared_data.get("documents", [])) + elif entity.__name__ == "Dataset": + ds = session._shared_data.get("dataset") + result.all.return_value = [ds] if ds else [] + else: + result.all.return_value = [] + else: + result.all.return_value = [] + return result - q.all.side_effect = _docs_all - q.first.side_effect = _docs_first - return q - - # Default fallback - q.first.return_value = None - q.all.return_value = [] - return q - - session.query.side_effect = _query_side_effect + session.scalar.side_effect = _scalar_side_effect + session.scalars.side_effect = _scalars_side_effect # Implement session.begin() context manager that commits on exit session.commit = MagicMock() @@ -638,8 +613,6 @@ class TestProgressTracking: wrapper = TaskWrapper(data=next_task_data) mock_redis.rpop.return_value = wrapper.serialize() - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset - with patch("tasks.document_indexing_task.FeatureService.get_features") as mock_features: mock_features.return_value.billing.enabled = False @@ -662,7 +635,6 @@ class TestProgressTracking: """ # Arrange mock_redis.rpop.return_value = None # No more tasks - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset with patch("tasks.document_indexing_task.FeatureService.get_features") as mock_features: mock_features.return_value.billing.enabled = False @@ -780,8 +752,7 @@ class TestErrorHandling: If the dataset doesn't exist, the task should exit gracefully. """ - # Arrange - mock_db_session.query.return_value.where.return_value.first.return_value = None + # Arrange - dataset is not in _shared_data (None by default), so scalar() returns None # Act _document_indexing(dataset_id, document_ids) @@ -806,8 +777,6 @@ class TestErrorHandling: # Set up rpop to return task once for concurrency check mock_redis.rpop.side_effect = [wrapper.serialize(), None] - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset - # Make _document_indexing raise an error with patch("tasks.document_indexing_task._document_indexing") as mock_indexing: mock_indexing.side_effect = Exception("Processing failed") @@ -844,7 +813,7 @@ class TestErrorHandling: # Mock rpop to return tasks one by one mock_redis.rpop.side_effect = tasks[:concurrency_limit] + [None] - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset + mock_db_session._shared_data["dataset"] = mock_dataset with patch("tasks.document_indexing_task.dify_config.TENANT_ISOLATED_TASK_CONCURRENCY", concurrency_limit): with patch("tasks.document_indexing_task.normal_document_indexing_task") as mock_task: @@ -977,7 +946,7 @@ class TestAdvancedScenarios: # Mock rpop to return tasks up to concurrency limit mock_redis.rpop.side_effect = waiting_tasks[:concurrency_limit] + [None] - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset + mock_db_session._shared_data["dataset"] = mock_dataset with patch("tasks.document_indexing_task.dify_config.TENANT_ISOLATED_TASK_CONCURRENCY", concurrency_limit): with patch("tasks.document_indexing_task.normal_document_indexing_task") as mock_task: @@ -1070,7 +1039,7 @@ class TestAdvancedScenarios: # Mock rpop to return tasks in FIFO order mock_redis.rpop.side_effect = tasks + [None] - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset + mock_db_session._shared_data["dataset"] = mock_dataset with patch("tasks.document_indexing_task.dify_config.TENANT_ISOLATED_TASK_CONCURRENCY", 3): with patch("tasks.document_indexing_task.normal_document_indexing_task") as mock_task: @@ -1108,7 +1077,7 @@ class TestAdvancedScenarios: """ # Arrange mock_redis.rpop.return_value = None # Empty queue - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset + mock_db_session._shared_data["dataset"] = mock_dataset with patch("tasks.document_indexing_task.normal_document_indexing_task") as mock_task: # Act @@ -1276,7 +1245,7 @@ class TestIntegration: # First call returns task 2, second call returns None mock_redis.rpop.side_effect = [wrapper.serialize(), None] - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset + mock_db_session._shared_data["dataset"] = mock_dataset with patch("tasks.document_indexing_task.FeatureService.get_features") as mock_features: mock_features.return_value.billing.enabled = False @@ -1433,7 +1402,7 @@ class TestPerformanceScenarios: # Mock rpop to return tasks up to concurrency limit mock_redis.rpop.side_effect = waiting_tasks[:concurrency_limit] + [None] - mock_db_session.query.return_value.where.return_value.first.return_value = mock_dataset + mock_db_session._shared_data["dataset"] = mock_dataset with patch("tasks.document_indexing_task.dify_config.TENANT_ISOLATED_TASK_CONCURRENCY", concurrency_limit): with patch("tasks.document_indexing_task.normal_document_indexing_task") as mock_task: @@ -1536,10 +1505,8 @@ class TestDocumentIndexingTaskSummaryFlow: """Test early return when dataset does not exist.""" # Arrange session = MagicMock() - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.return_value = None - session.query.side_effect = lambda model: dataset_query + session = MagicMock() + session.scalar.return_value = None # dataset not found create_session_mock = MagicMock(return_value=_SessionContext(session)) monkeypatch.setattr("tasks.document_indexing_task.session_factory.create_session", create_session_mock) @@ -1560,16 +1527,15 @@ class TestDocumentIndexingTaskSummaryFlow: dataset = SimpleNamespace(id="dataset-1", tenant_id="tenant-1") document = SimpleNamespace(id="doc-1", indexing_status=None, error=None, stopped_at=None) - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.return_value = dataset - - document_query = MagicMock() - document_query.where.return_value = document_query - document_query.first.return_value = document - session = MagicMock() - session.query.side_effect = lambda model: dataset_query if model is Dataset else document_query + + def _scalar_se(stmt): + entity = stmt.column_descriptions[0].get("entity") + if entity is Dataset: + return dataset + return document + + session.scalar.side_effect = _scalar_se monkeypatch.setattr( "tasks.document_indexing_task.session_factory.create_session", @@ -1643,9 +1609,12 @@ class TestDocumentIndexingTaskSummaryFlow: session2.begin.return_value = nullcontext() session3 = MagicMock() - session1.query.side_effect = lambda model: dataset_query - session2.query.side_effect = lambda model: phase1_document_query - session3.query.side_effect = lambda model: summary_document_query if model is Document else dataset_query + session1.scalar.return_value = dataset + session2.scalars.return_value = MagicMock(all=MagicMock(return_value=phase1_docs)) + session3.scalar.return_value = dataset + session3.scalars.return_value = MagicMock( + all=MagicMock(return_value=[doc_eligible, doc_skip_form, doc_skip_status]) + ) create_session_mock = MagicMock( side_effect=[_SessionContext(session1), _SessionContext(session2), _SessionContext(session3)] @@ -1704,9 +1673,11 @@ class TestDocumentIndexingTaskSummaryFlow: session2 = MagicMock() session2.begin.return_value = nullcontext() session3 = MagicMock() - session1.query.side_effect = lambda model: dataset_query - session2.query.side_effect = lambda model: phase1_query - session3.query.side_effect = lambda model: summary_query if model is Document else dataset_query + + session1.scalar.return_value = dataset + session2.scalars.return_value = MagicMock(all=MagicMock(return_value=[SimpleNamespace(id="doc-1")])) + session3.scalar.return_value = dataset + session3.scalars.return_value = MagicMock(all=MagicMock(return_value=[doc_eligible])) monkeypatch.setattr( "tasks.document_indexing_task.session_factory.create_session", @@ -1736,21 +1707,14 @@ class TestDocumentIndexingTaskSummaryFlow: """Test early return when dataset is missing after indexing.""" # Arrange dataset = SimpleNamespace(id="dataset-1", tenant_id="tenant-1") - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.side_effect = [dataset, None] - - document_query = MagicMock() - document_query.where.return_value = document_query - document_query.all.return_value = [SimpleNamespace(id="doc-1")] session1 = MagicMock() session2 = MagicMock() session2.begin.return_value = nullcontext() session3 = MagicMock() - session1.query.side_effect = lambda model: dataset_query - session2.query.side_effect = lambda model: document_query - session3.query.side_effect = lambda model: dataset_query + session1.scalar.return_value = dataset + session2.scalars.return_value = MagicMock(all=MagicMock(return_value=[SimpleNamespace(id="doc-1")])) + session3.scalar.return_value = None # dataset not found on second query monkeypatch.setattr( "tasks.document_indexing_task.session_factory.create_session", @@ -1770,7 +1734,7 @@ class TestDocumentIndexingTaskSummaryFlow: _document_indexing("dataset-1", ["doc-1"]) # Assert - session3.query.assert_called() + session3.scalar.assert_called() def test_should_skip_summary_when_not_high_quality(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test summary generation skipped when indexing_technique is not high_quality.""" @@ -1781,21 +1745,14 @@ class TestDocumentIndexingTaskSummaryFlow: indexing_technique="economy", summary_index_setting={"enable": True}, ) - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.return_value = dataset - - document_query = MagicMock() - document_query.where.return_value = document_query - document_query.all.return_value = [SimpleNamespace(id="doc-1")] - session1 = MagicMock() session2 = MagicMock() session2.begin.return_value = nullcontext() session3 = MagicMock() - session1.query.side_effect = lambda model: dataset_query - session2.query.side_effect = lambda model: document_query - session3.query.side_effect = lambda model: dataset_query + + session1.scalar.return_value = dataset + session2.scalars.return_value = MagicMock(all=MagicMock(return_value=[SimpleNamespace(id="doc-1")])) + session3.scalar.return_value = dataset monkeypatch.setattr( "tasks.document_indexing_task.session_factory.create_session", @@ -1824,19 +1781,12 @@ class TestDocumentIndexingTaskSummaryFlow: """Test summary generation is skipped when indexing is paused.""" # Arrange dataset = SimpleNamespace(id="dataset-1", tenant_id="tenant-1") - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.return_value = dataset - - document_query = MagicMock() - document_query.where.return_value = document_query - document_query.all.return_value = [SimpleNamespace(id="doc-1")] session1 = MagicMock() session2 = MagicMock() session2.begin.return_value = nullcontext() - session1.query.side_effect = lambda model: dataset_query - session2.query.side_effect = lambda model: document_query + session1.scalar.return_value = dataset + session2.scalars.return_value = MagicMock(all=MagicMock(return_value=[SimpleNamespace(id="doc-1")])) create_session_mock = MagicMock(side_effect=[_SessionContext(session1), _SessionContext(session2)]) monkeypatch.setattr("tasks.document_indexing_task.session_factory.create_session", create_session_mock) @@ -1865,19 +1815,12 @@ class TestDocumentIndexingTaskSummaryFlow: """Test generic indexing runner exception is handled.""" # Arrange dataset = SimpleNamespace(id="dataset-1", tenant_id="tenant-1") - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.return_value = dataset - - document_query = MagicMock() - document_query.where.return_value = document_query - document_query.all.return_value = [SimpleNamespace(id="doc-1")] session1 = MagicMock() session2 = MagicMock() session2.begin.return_value = nullcontext() - session1.query.side_effect = lambda model: dataset_query - session2.query.side_effect = lambda model: document_query + session1.scalar.return_value = dataset + session2.scalars.return_value = MagicMock(all=MagicMock(return_value=[SimpleNamespace(id="doc-1")])) monkeypatch.setattr( "tasks.document_indexing_task.session_factory.create_session", @@ -1922,25 +1865,15 @@ class TestDocumentIndexingTaskSummaryFlow: indexing_technique="high_quality", summary_index_setting={"enable": True}, ) - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.return_value = dataset - - phase1_query = MagicMock() - phase1_query.where.return_value = phase1_query - phase1_query.all.return_value = [SimpleNamespace(id="doc-1")] - - summary_query = MagicMock() - summary_query.where.return_value = summary_query - summary_query.all.return_value = [_FalseyDocument("missing-doc")] - session1 = MagicMock() session2 = MagicMock() session2.begin.return_value = nullcontext() session3 = MagicMock() - session1.query.side_effect = lambda model: dataset_query - session2.query.side_effect = lambda model: phase1_query - session3.query.side_effect = lambda model: summary_query if model is Document else dataset_query + + session1.scalar.return_value = dataset + session2.scalars.return_value = MagicMock(all=MagicMock(return_value=[SimpleNamespace(id="doc-1")])) + session3.scalar.return_value = dataset + session3.scalars.return_value = MagicMock(all=MagicMock(return_value=[_FalseyDocument("missing-doc")])) monkeypatch.setattr( "tasks.document_indexing_task.session_factory.create_session", diff --git a/api/tests/unit_tests/tasks/test_document_indexing_sync_task.py b/api/tests/unit_tests/tasks/test_document_indexing_sync_task.py index f49f4535af..41d3068a10 100644 --- a/api/tests/unit_tests/tasks/test_document_indexing_sync_task.py +++ b/api/tests/unit_tests/tasks/test_document_indexing_sync_task.py @@ -80,7 +80,7 @@ def mock_db_session(mock_document, mock_dataset): with patch("tasks.document_indexing_sync_task.session_factory", autospec=True) as mock_session_factory: session = MagicMock() session.scalars.return_value.all.return_value = [] - session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset] + session.scalar.side_effect = [mock_document, mock_dataset] begin_cm = MagicMock() begin_cm.__enter__.return_value = session @@ -242,14 +242,13 @@ class TestDataSourceInfoSerialization: # DB session mock — shared across all ``session_factory.create_session()`` calls session = MagicMock() session.scalars.return_value.all.return_value = [] - # .where() path: session 1 reads document + dataset, session 2 reads dataset - session.query.return_value.where.return_value.first.side_effect = [ + # All .first() calls are now session.scalar() — ordered by call sequence: + # session 1: document + dataset, session 2: dataset (clean), session 3: document (update), + # session 4: document (indexing) + session.scalar.side_effect = [ mock_document, mock_dataset, mock_dataset, - ] - # .filter_by() path: session 3 (update), session 4 (indexing) - session.query.return_value.filter_by.return_value.first.side_effect = [ mock_document, mock_document, ] diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 0ed4ca05fa..626d1ee0a8 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock, call, patch import pytest from libs.archive_storage import ArchiveStorageNotConfiguredError -from models.workflow import WorkflowArchiveLog from tasks.remove_app_and_related_data_task import ( _delete_app_workflow_archive_logs, _delete_archived_workflow_run_files, @@ -83,16 +82,11 @@ class TestDeleteWorkflowArchiveLogs: assert params == {"tenant_id": tenant_id, "app_id": app_id} assert name == "workflow archive log" - mock_query = MagicMock() - mock_delete_query = MagicMock() - mock_query.where.return_value = mock_delete_query - mock_db.session.query.return_value = mock_query + mock_session = MagicMock() - delete_func(mock_db.session, "log-1") + delete_func(mock_session, "log-1") - mock_db.session.query.assert_called_once_with(WorkflowArchiveLog) - mock_query.where.assert_called_once() - mock_delete_query.delete.assert_called_once_with(synchronize_session=False) + mock_session.execute.assert_called_once() class TestDeleteArchivedWorkflowRunFiles: diff --git a/api/tests/unit_tests/utils/test_text_processing.py b/api/tests/unit_tests/utils/test_text_processing.py index bf61162a66..5f6ccbcdff 100644 --- a/api/tests/unit_tests/utils/test_text_processing.py +++ b/api/tests/unit_tests/utils/test_text_processing.py @@ -19,7 +19,57 @@ from core.tools.utils.text_processing_utils import remove_leading_symbols ("[Google](https://google.com) is a search engine", "[Google](https://google.com) is a search engine"), ("[Example](http://example.com) some text", "[Example](http://example.com) some text"), # Leading symbols before markdown link are removed, including the opening bracket [ - ("@[Test](https://example.com)", "Test](https://example.com)"), + ("@[Test](https://example.com)", "[Test](https://example.com)"), + ("~~标题~~", "标题~~"), + ('""quoted', "quoted"), + ("''test", "test"), + ("##话题", "话题"), + ("$$价格", "价格"), + ("%%百分比", "百分比"), + ("&&与逻辑", "与逻辑"), + ("((括号))", "括号))"), + ("**强调**", "强调**"), + ("++自增", "自增"), + (",,逗号", "逗号"), + ("..省略", "省略"), + ("//注释", "注释"), + ("::范围", "范围"), + (";;分号", "分号"), + ("<<左移", "左移"), + ("==等于", "等于"), + (">>右移", "右移"), + ("??疑问", "疑问"), + ("@@提及", "提及"), + ("^^上标", "上标"), + ("__下划线", "下划线"), + ("``代码", "代码"), + ("~~删除线", "删除线"), + (" 全角空格开头", "全角空格开头"), + ("、顿号开头", "顿号开头"), + ("。句号开头", "句号开头"), + ("「引号」测试", "引号」测试"), + ("『书名号』", "书名号』"), + ("【保留】测试", "【保留】测试"), + ("〖括号〗测试", "括号〗测试"), + ("〔括号〕测试", "括号〕测试"), + ("~~【保留】~~", "【保留】~~"), + ('"[公告]"', '[公告]"'), + ("[公告] 更新", "[公告] 更新"), + ("【通知】重要", "【通知】重要"), + ("[[嵌套]]", "[[嵌套]]"), + ("【【嵌套】】", "【【嵌套】】"), + ("[【混合】]", "[【混合】]"), + ("normal text", "normal text"), + ("123数字", "123数字"), + ("中文开头", "中文开头"), + ("alpha", "alpha"), + ("~", ""), + ("【", "【"), + ("[", "["), + ("~~~", ""), + ("【【【", "【【【"), + ("\t制表符", "\t制表符"), + ("\n换行", "\n换行"), ], ) def test_remove_leading_symbols(input_text, expected_output): diff --git a/api/uv.lock b/api/uv.lock index 51424fc502..9ed8d16107 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -8,6 +8,42 @@ resolution-markers = [ "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", ] +[manifest] +members = [ + "dify-api", + "dify-vdb-alibabacloud-mysql", + "dify-vdb-analyticdb", + "dify-vdb-baidu", + "dify-vdb-chroma", + "dify-vdb-clickzetta", + "dify-vdb-couchbase", + "dify-vdb-elasticsearch", + "dify-vdb-hologres", + "dify-vdb-huawei-cloud", + "dify-vdb-iris", + "dify-vdb-lindorm", + "dify-vdb-matrixone", + "dify-vdb-milvus", + "dify-vdb-myscale", + "dify-vdb-oceanbase", + "dify-vdb-opengauss", + "dify-vdb-opensearch", + "dify-vdb-oracle", + "dify-vdb-pgvecto-rs", + "dify-vdb-pgvector", + "dify-vdb-qdrant", + "dify-vdb-relyt", + "dify-vdb-tablestore", + "dify-vdb-tencent", + "dify-vdb-tidb-on-qdrant", + "dify-vdb-tidb-vector", + "dify-vdb-upstash", + "dify-vdb-vastbase", + "dify-vdb-vikingdb", + "dify-vdb-weaviate", +] +overrides = [{ name = "pyarrow", specifier = ">=18.0.0" }] + [[package]] name = "abnf" version = "2.2.0" @@ -195,7 +231,7 @@ wheels = [ [[package]] name = "aliyun-log-python-sdk" -version = "0.9.37" +version = "0.9.44" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dateparser" }, @@ -207,7 +243,7 @@ dependencies = [ { name = "requests" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/70/291d494619bb7b0cbcc00689ad995945737c2c9e0bff2733e0aa7dbaee14/aliyun_log_python_sdk-0.9.37.tar.gz", hash = "sha256:ea65c9cca3a7377cef87d568e897820338328a53a7acb1b02f1383910e103f68", size = 152549, upload-time = "2025-11-27T07:56:06.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/5c/f4076b129fe9168f5424f9d89afc587baf36a844f4ae7b619a951a97c76c/aliyun_log_python_sdk-0.9.44.tar.gz", hash = "sha256:891d0ba91cdce8e5e6b430a50512e092751621680bc9f0b7c7325aaa7c1944f1", size = 154147, upload-time = "2026-03-30T08:40:59.04Z" } [[package]] name = "aliyun-python-sdk-core" @@ -286,14 +322,14 @@ wheels = [ [[package]] name = "apscheduler" -version = "3.11.1" +version = "3.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzlocal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] [[package]] @@ -437,7 +473,7 @@ wheels = [ [[package]] name = "bce-python-sdk" -version = "0.9.68" +version = "0.9.69" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "crc32c" }, @@ -445,9 +481,9 @@ dependencies = [ { name = "pycryptodome" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/7c/8b4d9128e571f898f9f177dc9f41e31692d8ddb08a963b0c576f219d1304/bce_python_sdk-0.9.68.tar.gz", hash = "sha256:adf182868ed25e53cc3c1573dad9a2b1e9b72ed1ffd0d3ef326f5fa93da7cfa6", size = 296349, upload-time = "2026-03-30T02:57:32.948Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/9c/8fdaf7f9259002b5aa9101bb88252f6d05f65c4535bbca567457da84d765/bce_python_sdk-0.9.69.tar.gz", hash = "sha256:2aaa9f4fc118b3efb720a66d7a541789b7d838a1ddacb9f3c6faa6b75e1c7d23", size = 300008, upload-time = "2026-04-10T08:13:29.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/4e/eaaba9264667d675c3de76485dc511f0f233c31bada8752411f7fc5170be/bce_python_sdk-0.9.68-py3-none-any.whl", hash = "sha256:fcb484db4a54aa2c4675834c10bc6c37d42929fd138faaf6c01f933d8fa927ed", size = 411932, upload-time = "2026-03-30T02:57:27.847Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3b/41c2985d1b3b3bd5cdf103b4156b08320268ee7a0617f2a40c34fdd377e9/bce_python_sdk-0.9.69-py3-none-any.whl", hash = "sha256:50fb94833b5f4931255296396081b85143101bd9a7a894efbf20d1f759779de5", size = 415659, upload-time = "2026-04-10T08:13:27.958Z" }, ] [[package]] @@ -551,29 +587,29 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.83" +version = "1.42.88" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/87/1ed88eaa1e814841a37e71fee74c2b74341d14b791c0c6038b7ba914bea1/boto3-1.42.83.tar.gz", hash = "sha256:cc5621e603982cb3145b7f6c9970e02e297a1a0eb94637cc7f7b69d3017640ee", size = 112719, upload-time = "2026-04-03T19:34:21.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/bb/7d4435cca6fccf235dd40c891c731bcb9078e815917b57ebadd1e0ffabaf/boto3-1.42.88.tar.gz", hash = "sha256:2d22c70de5726918676a06f1a03acfb4d5d9ea92fc759354800b67b22aaeef19", size = 113238, upload-time = "2026-04-10T19:41:06.912Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/8a066bc8f02937d49783c0b3948ab951d8284e6fde436cab9f359dbd4d93/boto3-1.42.83-py3-none-any.whl", hash = "sha256:544846fdb10585bb7837e409868e8e04c6b372fa04479ba1597ce82cf1242076", size = 140555, upload-time = "2026-04-03T19:34:17.935Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2b/8bfddb39a19f5fbc16a869f1a394771e6223f07160dbc0ff6b38e05ea0ae/boto3-1.42.88-py3-none-any.whl", hash = "sha256:2d0f52c971503377e4370d2a83edee6f077ddb8e684366ff38df4f13581d9cfc", size = 140557, upload-time = "2026-04-10T19:41:05.309Z" }, ] [[package]] name = "boto3-stubs" -version = "1.42.83" +version = "1.42.88" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/fe/6c43a048074d8567db38befe51bf0b770e8456aa2b91ce8fe6758f29ec3d/boto3_stubs-1.42.83.tar.gz", hash = "sha256:1ecbd88f4ae35764b9ea3579ca1e851b67ea0a73a442cb406de277fc1478daeb", size = 102188, upload-time = "2026-04-03T19:54:20.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c7/d4dfbb4757cd72fd350ba666902ec3ac19e04d6be639e96cdad4543d4726/boto3_stubs-1.42.88.tar.gz", hash = "sha256:85215fb4938a94d1cf83cd8632f46ae7728b5ec88187d83468f393bbe64236d6", size = 102495, upload-time = "2026-04-10T19:55:57.526Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/4d/eee0444fd466ebe69fdb61cc1f24b97d8e21e9e545865f7c1d846294a413/boto3_stubs-1.42.83-py3-none-any.whl", hash = "sha256:06185ca5f11a1edc880286f5f33779a2b08be356bf270bf1ec128d0819782a20", size = 70448, upload-time = "2026-04-03T19:54:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6f/3befd72080aedbb4ad26b353a6e364645668664930ce49668fd0bab8f2b5/boto3_stubs-1.42.88-py3-none-any.whl", hash = "sha256:9e74350715ca8ccd63fc250f8eca9fa3161b3d1704339554344d72e4e21c5ed1", size = 70603, upload-time = "2026-04-10T19:55:49.921Z" }, ] [package.optional-dependencies] @@ -583,16 +619,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.42.83" +version = "1.42.88" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/01/b46a3f8b6e9362258f78f1890db1a96d4ed73214d6a36420dc158dcfd221/botocore-1.42.83.tar.gz", hash = "sha256:34bc8cb64b17ac17f8901f073fe4fc9572a5cac9393a37b2b3ea372a83b87f4a", size = 15140337, upload-time = "2026-04-03T19:34:08.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/50/87966238f7aa3f7e5f87081185d5a407a95ede8b551e11bbe134ca3306dc/botocore-1.42.88.tar.gz", hash = "sha256:cbb59ee464662039b0c2c95a520cdf85b1e8ce00b72375ab9cd9f842cc001301", size = 15195331, upload-time = "2026-04-10T19:40:57.012Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/97/0d6f50822dc8c1df7f3eadb0bc6822fc0f98f02287c4efc7c7c88fde129a/botocore-1.42.83-py3-none-any.whl", hash = "sha256:ec0c3ecb3772936ed22a3bdda09883b34858933f71004686d460d829bab39d8e", size = 14818388, upload-time = "2026-04-03T19:34:03.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/46/ad14e41245adb8b0c83663ba13e822b68a0df08999dd250e75b0750fdf6c/botocore-1.42.88-py3-none-any.whl", hash = "sha256:032375b213305b6b81eedb269eaeefdf96f674620799bbf96117dca86052cc1a", size = 14876640, upload-time = "2026-04-10T19:40:53.663Z" }, ] [[package]] @@ -687,11 +723,11 @@ wheels = [ [[package]] name = "cachetools" -version = "5.3.3" +version = "7.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/4d/27a3e6dd09011649ad5210bdf963765bc8fa81a0827a4fc01bafd2705c5b/cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105", size = 26522, upload-time = "2024-02-26T20:33:23.386Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/2b/a64c2d25a37aeb921fddb929111413049fc5f8b9a4c1aefaffaafe768d54/cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945", size = 9325, upload-time = "2024-02-26T20:33:20.308Z" }, + { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, ] [[package]] @@ -705,7 +741,7 @@ wheels = [ [[package]] name = "celery" -version = "5.6.2" +version = "5.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "billiard" }, @@ -718,9 +754,9 @@ dependencies = [ { name = "tzlocal" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/9d/3d13596519cfa7207a6f9834f4b082554845eb3cd2684b5f8535d50c7c44/celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b", size = 1718802, upload-time = "2026-01-04T12:35:58.012Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/bd/9ecd619e456ae4ba73b6583cc313f26152afae13e9a82ac4fe7f8856bfd1/celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5", size = 445502, upload-time = "2026-01-04T12:35:55.894Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, ] [[package]] @@ -778,27 +814,27 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -950,7 +986,7 @@ wheels = [ [[package]] name = "clickzetta-connector-python" -version = "0.8.106" +version = "0.8.104" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "future" }, @@ -964,7 +1000,7 @@ dependencies = [ { name = "urllib3" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/23/38/749c708619f402d4d582dfa73fbeb64ade77b1f250a93bd064d2a1aa3776/clickzetta_connector_python-0.8.106-py3-none-any.whl", hash = "sha256:120d6700051d97609dbd6655c002ab3bc260b7c8e67d39dfc7191e749563f7b4", size = 78121, upload-time = "2025-10-29T02:38:15.014Z" }, + { url = "https://files.pythonhosted.org/packages/8f/94/c7eee2224bdab39d16dfe5bb7687f5525c7ed345b7fe8812e18a2d9a6335/clickzetta_connector_python-0.8.104-py3-none-any.whl", hash = "sha256:ae3e466d990677f96c769ec1c29318237df80c80fe9c1e21ba1eaf42bdef0207", size = 79382, upload-time = "2025-09-10T08:46:39.731Z" }, ] [[package]] @@ -1115,54 +1151,53 @@ sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c [[package]] name = "croniter" -version = "6.0.0" +version = "6.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, - { name = "pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, ] [[package]] name = "cryptography" -version = "46.0.6" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] @@ -1355,7 +1390,6 @@ dependencies = [ { name = "transformers" }, { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "weave" }, - { name = "weaviate-client" }, { name = "yarl" }, ] @@ -1423,6 +1457,7 @@ dev = [ { name = "types-tensorflow" }, { name = "types-tqdm" }, { name = "types-ujson" }, + { name = "xinference-client" }, ] storage = [ { name = "azure-storage-blob" }, @@ -1439,105 +1474,202 @@ tools = [ { name = "cloudscraper" }, { name = "nltk" }, ] -vdb = [ - { name = "alibabacloud-gpdb20160503" }, - { name = "alibabacloud-tea-openapi" }, - { name = "chromadb" }, - { name = "clickhouse-connect" }, - { name = "clickzetta-connector-python" }, - { name = "couchbase" }, - { name = "elasticsearch" }, - { name = "holo-search-sdk" }, - { name = "intersystems-irispython" }, - { name = "mo-vector" }, - { name = "mysql-connector-python" }, - { name = "opensearch-py" }, - { name = "oracledb" }, - { name = "pgvecto-rs", extra = ["sqlalchemy"] }, - { name = "pgvector" }, - { name = "pymilvus" }, - { name = "pymochow" }, - { name = "pyobvector" }, - { name = "qdrant-client" }, - { name = "tablestore" }, - { name = "tcvectordb" }, - { name = "tidb-vector" }, - { name = "upstash-vector" }, - { name = "volcengine-compat" }, - { name = "weaviate-client" }, +vdb-alibabacloud-mysql = [ + { name = "dify-vdb-alibabacloud-mysql" }, +] +vdb-all = [ + { name = "dify-vdb-alibabacloud-mysql" }, + { name = "dify-vdb-analyticdb" }, + { name = "dify-vdb-baidu" }, + { name = "dify-vdb-chroma" }, + { name = "dify-vdb-clickzetta" }, + { name = "dify-vdb-couchbase" }, + { name = "dify-vdb-elasticsearch" }, + { name = "dify-vdb-hologres" }, + { name = "dify-vdb-huawei-cloud" }, + { name = "dify-vdb-iris" }, + { name = "dify-vdb-lindorm" }, + { name = "dify-vdb-matrixone" }, + { name = "dify-vdb-milvus" }, + { name = "dify-vdb-myscale" }, + { name = "dify-vdb-oceanbase" }, + { name = "dify-vdb-opengauss" }, + { name = "dify-vdb-opensearch" }, + { name = "dify-vdb-oracle" }, + { name = "dify-vdb-pgvecto-rs" }, + { name = "dify-vdb-pgvector" }, + { name = "dify-vdb-qdrant" }, + { name = "dify-vdb-relyt" }, + { name = "dify-vdb-tablestore" }, + { name = "dify-vdb-tencent" }, + { name = "dify-vdb-tidb-on-qdrant" }, + { name = "dify-vdb-tidb-vector" }, + { name = "dify-vdb-upstash" }, + { name = "dify-vdb-vastbase" }, + { name = "dify-vdb-vikingdb" }, + { name = "dify-vdb-weaviate" }, +] +vdb-analyticdb = [ + { name = "dify-vdb-analyticdb" }, +] +vdb-baidu = [ + { name = "dify-vdb-baidu" }, +] +vdb-chroma = [ + { name = "dify-vdb-chroma" }, +] +vdb-clickzetta = [ + { name = "dify-vdb-clickzetta" }, +] +vdb-couchbase = [ + { name = "dify-vdb-couchbase" }, +] +vdb-elasticsearch = [ + { name = "dify-vdb-elasticsearch" }, +] +vdb-hologres = [ + { name = "dify-vdb-hologres" }, +] +vdb-huawei-cloud = [ + { name = "dify-vdb-huawei-cloud" }, +] +vdb-iris = [ + { name = "dify-vdb-iris" }, +] +vdb-lindorm = [ + { name = "dify-vdb-lindorm" }, +] +vdb-matrixone = [ + { name = "dify-vdb-matrixone" }, +] +vdb-milvus = [ + { name = "dify-vdb-milvus" }, +] +vdb-myscale = [ + { name = "dify-vdb-myscale" }, +] +vdb-oceanbase = [ + { name = "dify-vdb-oceanbase" }, +] +vdb-opengauss = [ + { name = "dify-vdb-opengauss" }, +] +vdb-opensearch = [ + { name = "dify-vdb-opensearch" }, +] +vdb-oracle = [ + { name = "dify-vdb-oracle" }, +] +vdb-pgvecto-rs = [ + { name = "dify-vdb-pgvecto-rs" }, +] +vdb-pgvector = [ + { name = "dify-vdb-pgvector" }, +] +vdb-qdrant = [ + { name = "dify-vdb-qdrant" }, +] +vdb-relyt = [ + { name = "dify-vdb-relyt" }, +] +vdb-tablestore = [ + { name = "dify-vdb-tablestore" }, +] +vdb-tencent = [ + { name = "dify-vdb-tencent" }, +] +vdb-tidb-on-qdrant = [ + { name = "dify-vdb-tidb-on-qdrant" }, +] +vdb-tidb-vector = [ + { name = "dify-vdb-tidb-vector" }, +] +vdb-upstash = [ + { name = "dify-vdb-upstash" }, +] +vdb-vastbase = [ + { name = "dify-vdb-vastbase" }, +] +vdb-vikingdb = [ + { name = "dify-vdb-vikingdb" }, +] +vdb-weaviate = [ + { name = "dify-vdb-weaviate" }, +] +vdb-xinference = [ { name = "xinference-client" }, ] [package.metadata] requires-dist = [ - { name = "aliyun-log-python-sdk", specifier = "~=0.9.37" }, - { name = "apscheduler", specifier = ">=3.11.0" }, + { name = "aliyun-log-python-sdk", specifier = "~=0.9.44" }, + { name = "apscheduler", specifier = ">=3.11.2" }, { name = "arize-phoenix-otel", specifier = "~=0.15.0" }, { name = "azure-identity", specifier = "==1.25.3" }, { name = "beautifulsoup4", specifier = "==4.14.3" }, { name = "bleach", specifier = "~=6.3.0" }, - { name = "boto3", specifier = "==1.42.83" }, + { name = "boto3", specifier = "==1.42.88" }, { name = "bs4", specifier = "~=0.0.1" }, - { name = "cachetools", specifier = "~=5.3.0" }, - { name = "celery", specifier = "~=5.6.2" }, - { name = "charset-normalizer", specifier = ">=3.4.4" }, - { name = "croniter", specifier = ">=6.0.0" }, + { name = "cachetools", specifier = "~=7.0.5" }, + { name = "celery", specifier = "~=5.6.3" }, + { name = "charset-normalizer", specifier = ">=3.4.7" }, + { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = ">=0.7.0" }, - { name = "flask", specifier = "~=3.1.2" }, - { name = "flask-compress", specifier = ">=1.17,<1.25" }, - { name = "flask-cors", specifier = "~=6.0.0" }, + { name = "flask", specifier = "~=3.1.3" }, + { name = "flask-compress", specifier = ">=1.24,<1.25" }, + { name = "flask-cors", specifier = "~=6.0.2" }, { name = "flask-login", specifier = "~=0.6.3" }, { name = "flask-migrate", specifier = "~=4.1.0" }, { name = "flask-orjson", specifier = "~=2.0.0" }, { name = "flask-restx", specifier = "~=1.3.2" }, { name = "flask-sqlalchemy", specifier = "~=3.1.1" }, - { name = "gevent", specifier = "~=25.9.1" }, + { name = "gevent", specifier = "~=26.4.0" }, { name = "gmpy2", specifier = "~=2.3.0" }, - { name = "google-api-core", specifier = ">=2.19.1" }, - { name = "google-api-python-client", specifier = "==2.193.0" }, - { name = "google-auth", specifier = ">=2.47.0" }, + { name = "google-api-core", specifier = ">=2.30.3" }, + { name = "google-api-python-client", specifier = "==2.194.0" }, + { name = "google-auth", specifier = ">=2.49.2" }, { name = "google-auth-httplib2", specifier = "==0.3.1" }, - { name = "google-cloud-aiplatform", specifier = ">=1.123.0" }, - { name = "googleapis-common-protos", specifier = ">=1.65.0" }, + { name = "google-cloud-aiplatform", specifier = ">=1.147.0" }, + { name = "googleapis-common-protos", specifier = ">=1.74.0" }, { name = "graphon", specifier = ">=0.1.2" }, { name = "gunicorn", specifier = "~=25.3.0" }, - { name = "httpx", extras = ["socks"], specifier = "~=0.28.0" }, + { name = "httpx", extras = ["socks"], specifier = "~=0.28.1" }, { name = "httpx-sse", specifier = "~=0.4.0" }, { name = "jieba", specifier = "==0.42.1" }, - { name = "json-repair", specifier = ">=0.55.1" }, - { name = "langfuse", specifier = ">=3.0.0,<5.0.0" }, - { name = "langsmith", specifier = "~=0.7.16" }, - { name = "litellm", specifier = "==1.82.6" }, + { name = "json-repair", specifier = ">=0.59.2" }, + { name = "langfuse", specifier = ">=4.2.0,<5.0.0" }, + { name = "langsmith", specifier = "~=0.7.30" }, + { name = "litellm", specifier = "==1.83.0" }, { name = "markdown", specifier = "~=3.10.2" }, - { name = "mlflow-skinny", specifier = ">=3.0.0" }, - { name = "numpy", specifier = "~=1.26.4" }, + { name = "mlflow-skinny", specifier = ">=3.11.1" }, + { name = "numpy", specifier = "~=2.4.4" }, { name = "openpyxl", specifier = "~=3.1.5" }, - { name = "opentelemetry-api", specifier = "==1.40.0" }, - { name = "opentelemetry-distro", specifier = "==0.61b0" }, - { name = "opentelemetry-exporter-otlp", specifier = "==1.40.0" }, - { name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.40.0" }, - { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = "==1.40.0" }, - { name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.40.0" }, - { name = "opentelemetry-instrumentation", specifier = "==0.61b0" }, - { name = "opentelemetry-instrumentation-celery", specifier = "==0.61b0" }, - { name = "opentelemetry-instrumentation-flask", specifier = "==0.61b0" }, - { name = "opentelemetry-instrumentation-httpx", specifier = "==0.61b0" }, - { name = "opentelemetry-instrumentation-redis", specifier = "==0.61b0" }, - { name = "opentelemetry-instrumentation-sqlalchemy", specifier = "==0.61b0" }, - { name = "opentelemetry-propagator-b3", specifier = "==1.40.0" }, - { name = "opentelemetry-proto", specifier = "==1.40.0" }, - { name = "opentelemetry-sdk", specifier = "==1.40.0" }, - { name = "opentelemetry-semantic-conventions", specifier = "==0.61b0" }, - { name = "opentelemetry-util-http", specifier = "==0.61b0" }, - { name = "opik", specifier = "~=1.10.37" }, - { name = "packaging", specifier = "~=23.2" }, - { name = "pandas", extras = ["excel", "output-formatting", "performance"], specifier = "~=3.0.1" }, + { name = "opentelemetry-api", specifier = "==1.41.0" }, + { name = "opentelemetry-distro", specifier = "==0.62b0" }, + { name = "opentelemetry-exporter-otlp", specifier = "==1.41.0" }, + { name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.41.0" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = "==1.41.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.41.0" }, + { name = "opentelemetry-instrumentation", specifier = "==0.62b0" }, + { name = "opentelemetry-instrumentation-celery", specifier = "==0.62b0" }, + { name = "opentelemetry-instrumentation-flask", specifier = "==0.62b0" }, + { name = "opentelemetry-instrumentation-httpx", specifier = "==0.62b0" }, + { name = "opentelemetry-instrumentation-redis", specifier = "==0.62b0" }, + { name = "opentelemetry-instrumentation-sqlalchemy", specifier = "==0.62b0" }, + { name = "opentelemetry-propagator-b3", specifier = "==1.41.0" }, + { name = "opentelemetry-proto", specifier = "==1.41.0" }, + { name = "opentelemetry-sdk", specifier = "==1.41.0" }, + { name = "opentelemetry-semantic-conventions", specifier = "==0.62b0" }, + { name = "opentelemetry-util-http", specifier = "==0.62b0" }, + { name = "opik", specifier = "~=1.11.2" }, + { name = "packaging", specifier = "~=26.0" }, + { name = "pandas", extras = ["excel", "output-formatting", "performance"], specifier = "~=3.0.2" }, { name = "psycogreen", specifier = "~=1.0.2" }, - { name = "psycopg2-binary", specifier = "~=2.9.6" }, + { name = "psycopg2-binary", specifier = "~=2.9.11" }, { name = "pycryptodome", specifier = "==3.23.0" }, { name = "pydantic", specifier = "~=2.12.5" }, { name = "pydantic-settings", specifier = "~=2.13.1" }, - { name = "pyjwt", specifier = "~=2.12.0" }, + { name = "pyjwt", specifier = "~=2.12.1" }, { name = "pypandoc", specifier = "~=1.13" }, { name = "pypdfium2", specifier = "==5.6.0" }, { name = "python-docx", specifier = "~=1.2.0" }, @@ -1545,59 +1677,58 @@ requires-dist = [ { name = "pyyaml", specifier = "~=6.0.1" }, { name = "readabilipy", specifier = "~=0.3.0" }, { name = "redis", extras = ["hiredis"], specifier = "~=7.4.0" }, - { name = "resend", specifier = "~=2.26.0" }, - { name = "sendgrid", specifier = "~=6.12.3" }, - { name = "sentry-sdk", extras = ["flask"], specifier = "~=2.55.0" }, - { name = "sqlalchemy", specifier = "~=2.0.29" }, + { name = "resend", specifier = "~=2.27.0" }, + { name = "sendgrid", specifier = "~=6.12.5" }, + { name = "sentry-sdk", extras = ["flask"], specifier = "~=2.57.0" }, + { name = "sqlalchemy", specifier = "~=2.0.49" }, { name = "sseclient-py", specifier = "~=1.9.0" }, { name = "starlette", specifier = "==1.0.0" }, { name = "tiktoken", specifier = "~=0.12.0" }, { name = "transformers", specifier = "~=5.3.0" }, { name = "unstructured", extras = ["docx", "epub", "md", "ppt", "pptx"], specifier = "~=0.21.5" }, - { name = "weave", specifier = ">=0.52.16" }, - { name = "weaviate-client", specifier = "==4.20.4" }, + { name = "weave", specifier = ">=0.52.36" }, { name = "yarl", specifier = "~=1.23.0" }, ] [package.metadata.requires-dev] dev = [ { name = "basedpyright", specifier = "~=1.39.0" }, - { name = "boto3-stubs", specifier = ">=1.38.20" }, + { name = "boto3-stubs", specifier = ">=1.42.88" }, { name = "celery-types", specifier = ">=0.23.0" }, { name = "coverage", specifier = "~=7.13.4" }, { name = "dotenv-linter", specifier = "~=0.7.0" }, - { name = "faker", specifier = "~=40.12.0" }, - { name = "hypothesis", specifier = ">=6.131.15" }, + { name = "faker", specifier = "~=40.13.0" }, + { name = "hypothesis", specifier = ">=6.151.12" }, { name = "import-linter", specifier = ">=2.3" }, { name = "lxml-stubs", specifier = "~=0.5.1" }, - { name = "mypy", specifier = "~=1.20.0" }, + { name = "mypy", specifier = "~=1.20.1" }, { name = "pandas-stubs", specifier = "~=3.0.0" }, - { name = "pyrefly", specifier = ">=0.59.1" }, - { name = "pytest", specifier = "~=9.0.2" }, + { name = "pyrefly", specifier = ">=0.60.0" }, + { name = "pytest", specifier = "~=9.0.3" }, { name = "pytest-benchmark", specifier = "~=5.2.3" }, { name = "pytest-cov", specifier = "~=7.1.0" }, { name = "pytest-env", specifier = "~=1.6.0" }, { name = "pytest-mock", specifier = "~=3.15.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "ruff", specifier = "~=0.15.5" }, + { name = "ruff", specifier = "~=0.15.10" }, { name = "scipy-stubs", specifier = ">=1.15.3.0" }, { name = "sseclient-py", specifier = ">=1.8.0" }, - { name = "testcontainers", specifier = "~=4.14.1" }, + { name = "testcontainers", specifier = "~=4.14.2" }, { name = "types-aiofiles", specifier = "~=25.1.0" }, { name = "types-beautifulsoup4", specifier = "~=4.12.0" }, { name = "types-cachetools", specifier = "~=6.2.0" }, - { name = "types-cffi", specifier = ">=1.17.0" }, + { name = "types-cffi", specifier = ">=2.0.0.20260408" }, { name = "types-colorama", specifier = "~=0.4.15" }, { name = "types-defusedxml", specifier = "~=0.7.0" }, { name = "types-deprecated", specifier = "~=1.3.1" }, { name = "types-docutils", specifier = "~=0.22.3" }, { name = "types-flask-cors", specifier = "~=6.0.0" }, { name = "types-flask-migrate", specifier = "~=4.1.0" }, - { name = "types-gevent", specifier = "~=25.9.0" }, - { name = "types-greenlet", specifier = "~=3.3.0" }, + { name = "types-gevent", specifier = "~=26.4.0" }, + { name = "types-greenlet", specifier = "~=3.4.0" }, { name = "types-html5lib", specifier = "~=1.1.11" }, - { name = "types-jmespath", specifier = ">=1.0.2.20240106" }, + { name = "types-jmespath", specifier = ">=1.1.0.20260408" }, { name = "types-markdown", specifier = "~=3.10.2" }, { name = "types-oauthlib", specifier = "~=3.3.0" }, { name = "types-objgraph", specifier = "~=3.6.0" }, @@ -1611,25 +1742,26 @@ dev = [ { name = "types-pymysql", specifier = "~=1.1.0" }, { name = "types-pyopenssl", specifier = ">=24.1.0" }, { name = "types-python-dateutil", specifier = "~=2.9.0" }, - { name = "types-python-http-client", specifier = ">=3.3.7.20240910" }, + { name = "types-python-http-client", specifier = ">=3.3.7.20260408" }, { name = "types-pywin32", specifier = "~=311.0.0" }, { name = "types-pyyaml", specifier = "~=6.0.12" }, { name = "types-redis", specifier = ">=4.6.0.20241004" }, { name = "types-regex", specifier = "~=2026.4.4" }, - { name = "types-setuptools", specifier = ">=80.9.0" }, + { name = "types-setuptools", specifier = ">=82.0.0.20260408" }, { name = "types-shapely", specifier = "~=2.1.0" }, - { name = "types-simplejson", specifier = ">=3.20.0" }, - { name = "types-six", specifier = ">=1.17.0" }, - { name = "types-tensorflow", specifier = ">=2.18.0" }, - { name = "types-tqdm", specifier = ">=4.67.0" }, + { name = "types-simplejson", specifier = ">=3.20.0.20260408" }, + { name = "types-six", specifier = ">=1.17.0.20260408" }, + { name = "types-tensorflow", specifier = ">=2.18.0.20260408" }, + { name = "types-tqdm", specifier = ">=4.67.3.20260408" }, { name = "types-ujson", specifier = ">=5.10.0" }, + { name = "xinference-client", specifier = "~=2.4.0" }, ] storage = [ { name = "azure-storage-blob", specifier = "==12.28.0" }, - { name = "bce-python-sdk", specifier = "~=0.9.23" }, + { name = "bce-python-sdk", specifier = "~=0.9.69" }, { name = "cos-python-sdk-v5", specifier = "==1.9.41" }, { name = "esdk-obs-python", specifier = "==3.26.2" }, - { name = "google-cloud-storage", specifier = ">=3.0.0" }, + { name = "google-cloud-storage", specifier = ">=3.10.1" }, { name = "opendal", specifier = "~=0.46.0" }, { name = "oss2", specifier = "==2.19.1" }, { name = "supabase", specifier = "~=2.18.1" }, @@ -1639,42 +1771,409 @@ tools = [ { name = "cloudscraper", specifier = "~=1.2.71" }, { name = "nltk", specifier = "~=3.9.1" }, ] -vdb = [ +vdb-alibabacloud-mysql = [{ name = "dify-vdb-alibabacloud-mysql", editable = "providers/vdb/vdb-alibabacloud-mysql" }] +vdb-all = [ + { name = "dify-vdb-alibabacloud-mysql", editable = "providers/vdb/vdb-alibabacloud-mysql" }, + { name = "dify-vdb-analyticdb", editable = "providers/vdb/vdb-analyticdb" }, + { name = "dify-vdb-baidu", editable = "providers/vdb/vdb-baidu" }, + { name = "dify-vdb-chroma", editable = "providers/vdb/vdb-chroma" }, + { name = "dify-vdb-clickzetta", editable = "providers/vdb/vdb-clickzetta" }, + { name = "dify-vdb-couchbase", editable = "providers/vdb/vdb-couchbase" }, + { name = "dify-vdb-elasticsearch", editable = "providers/vdb/vdb-elasticsearch" }, + { name = "dify-vdb-hologres", editable = "providers/vdb/vdb-hologres" }, + { name = "dify-vdb-huawei-cloud", editable = "providers/vdb/vdb-huawei-cloud" }, + { name = "dify-vdb-iris", editable = "providers/vdb/vdb-iris" }, + { name = "dify-vdb-lindorm", editable = "providers/vdb/vdb-lindorm" }, + { name = "dify-vdb-matrixone", editable = "providers/vdb/vdb-matrixone" }, + { name = "dify-vdb-milvus", editable = "providers/vdb/vdb-milvus" }, + { name = "dify-vdb-myscale", editable = "providers/vdb/vdb-myscale" }, + { name = "dify-vdb-oceanbase", editable = "providers/vdb/vdb-oceanbase" }, + { name = "dify-vdb-opengauss", editable = "providers/vdb/vdb-opengauss" }, + { name = "dify-vdb-opensearch", editable = "providers/vdb/vdb-opensearch" }, + { name = "dify-vdb-oracle", editable = "providers/vdb/vdb-oracle" }, + { name = "dify-vdb-pgvecto-rs", editable = "providers/vdb/vdb-pgvecto-rs" }, + { name = "dify-vdb-pgvector", editable = "providers/vdb/vdb-pgvector" }, + { name = "dify-vdb-qdrant", editable = "providers/vdb/vdb-qdrant" }, + { name = "dify-vdb-relyt", editable = "providers/vdb/vdb-relyt" }, + { name = "dify-vdb-tablestore", editable = "providers/vdb/vdb-tablestore" }, + { name = "dify-vdb-tencent", editable = "providers/vdb/vdb-tencent" }, + { name = "dify-vdb-tidb-on-qdrant", editable = "providers/vdb/vdb-tidb-on-qdrant" }, + { name = "dify-vdb-tidb-vector", editable = "providers/vdb/vdb-tidb-vector" }, + { name = "dify-vdb-upstash", editable = "providers/vdb/vdb-upstash" }, + { name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vastbase" }, + { name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }, + { name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }, +] +vdb-analyticdb = [{ name = "dify-vdb-analyticdb", editable = "providers/vdb/vdb-analyticdb" }] +vdb-baidu = [{ name = "dify-vdb-baidu", editable = "providers/vdb/vdb-baidu" }] +vdb-chroma = [{ name = "dify-vdb-chroma", editable = "providers/vdb/vdb-chroma" }] +vdb-clickzetta = [{ name = "dify-vdb-clickzetta", editable = "providers/vdb/vdb-clickzetta" }] +vdb-couchbase = [{ name = "dify-vdb-couchbase", editable = "providers/vdb/vdb-couchbase" }] +vdb-elasticsearch = [{ name = "dify-vdb-elasticsearch", editable = "providers/vdb/vdb-elasticsearch" }] +vdb-hologres = [{ name = "dify-vdb-hologres", editable = "providers/vdb/vdb-hologres" }] +vdb-huawei-cloud = [{ name = "dify-vdb-huawei-cloud", editable = "providers/vdb/vdb-huawei-cloud" }] +vdb-iris = [{ name = "dify-vdb-iris", editable = "providers/vdb/vdb-iris" }] +vdb-lindorm = [{ name = "dify-vdb-lindorm", editable = "providers/vdb/vdb-lindorm" }] +vdb-matrixone = [{ name = "dify-vdb-matrixone", editable = "providers/vdb/vdb-matrixone" }] +vdb-milvus = [{ name = "dify-vdb-milvus", editable = "providers/vdb/vdb-milvus" }] +vdb-myscale = [{ name = "dify-vdb-myscale", editable = "providers/vdb/vdb-myscale" }] +vdb-oceanbase = [{ name = "dify-vdb-oceanbase", editable = "providers/vdb/vdb-oceanbase" }] +vdb-opengauss = [{ name = "dify-vdb-opengauss", editable = "providers/vdb/vdb-opengauss" }] +vdb-opensearch = [{ name = "dify-vdb-opensearch", editable = "providers/vdb/vdb-opensearch" }] +vdb-oracle = [{ name = "dify-vdb-oracle", editable = "providers/vdb/vdb-oracle" }] +vdb-pgvecto-rs = [{ name = "dify-vdb-pgvecto-rs", editable = "providers/vdb/vdb-pgvecto-rs" }] +vdb-pgvector = [{ name = "dify-vdb-pgvector", editable = "providers/vdb/vdb-pgvector" }] +vdb-qdrant = [{ name = "dify-vdb-qdrant", editable = "providers/vdb/vdb-qdrant" }] +vdb-relyt = [{ name = "dify-vdb-relyt", editable = "providers/vdb/vdb-relyt" }] +vdb-tablestore = [{ name = "dify-vdb-tablestore", editable = "providers/vdb/vdb-tablestore" }] +vdb-tencent = [{ name = "dify-vdb-tencent", editable = "providers/vdb/vdb-tencent" }] +vdb-tidb-on-qdrant = [{ name = "dify-vdb-tidb-on-qdrant", editable = "providers/vdb/vdb-tidb-on-qdrant" }] +vdb-tidb-vector = [{ name = "dify-vdb-tidb-vector", editable = "providers/vdb/vdb-tidb-vector" }] +vdb-upstash = [{ name = "dify-vdb-upstash", editable = "providers/vdb/vdb-upstash" }] +vdb-vastbase = [{ name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vastbase" }] +vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }] +vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }] +vdb-xinference = [{ name = "xinference-client", specifier = "~=2.4.0" }] + +[[package]] +name = "dify-vdb-alibabacloud-mysql" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-alibabacloud-mysql" } +dependencies = [ + { name = "mysql-connector-python" }, +] + +[package.metadata] +requires-dist = [{ name = "mysql-connector-python", specifier = ">=9.3.0" }] + +[[package]] +name = "dify-vdb-analyticdb" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-analyticdb" } +dependencies = [ + { name = "alibabacloud-gpdb20160503" }, + { name = "alibabacloud-tea-openapi" }, + { name = "clickhouse-connect" }, +] + +[package.metadata] +requires-dist = [ { name = "alibabacloud-gpdb20160503", specifier = "~=5.2.0" }, { name = "alibabacloud-tea-openapi", specifier = "~=0.4.3" }, - { name = "chromadb", specifier = "==0.5.20" }, { name = "clickhouse-connect", specifier = "~=0.15.0" }, - { name = "clickzetta-connector-python", specifier = ">=0.8.102" }, - { name = "couchbase", specifier = "~=4.6.0" }, - { name = "elasticsearch", specifier = "==8.14.0" }, - { name = "holo-search-sdk", specifier = ">=0.4.1" }, - { name = "intersystems-irispython", specifier = ">=5.1.0" }, - { name = "mo-vector", specifier = "~=0.1.13" }, - { name = "mysql-connector-python", specifier = ">=9.3.0" }, - { name = "opensearch-py", specifier = "==3.1.0" }, - { name = "oracledb", specifier = "==3.4.2" }, - { name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.1" }, - { name = "pgvector", specifier = "==0.4.2" }, - { name = "pymilvus", specifier = "~=2.6.10" }, - { name = "pymochow", specifier = "==2.4.0" }, - { name = "pyobvector", specifier = "~=0.2.17" }, - { name = "qdrant-client", specifier = "==1.9.0" }, - { name = "tablestore", specifier = "==6.4.3" }, - { name = "tcvectordb", specifier = "~=2.1.0" }, - { name = "tidb-vector", specifier = "==0.0.15" }, - { name = "upstash-vector", specifier = "==0.8.0" }, - { name = "volcengine-compat", specifier = "~=1.0.0" }, - { name = "weaviate-client", specifier = "==4.20.4" }, - { name = "xinference-client", specifier = "~=2.4.0" }, ] [[package]] -name = "diskcache" -version = "5.6.3" +name = "dify-vdb-baidu" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-baidu" } +dependencies = [ + { name = "pymochow" }, +] + +[package.metadata] +requires-dist = [{ name = "pymochow", specifier = "==2.4.0" }] + +[[package]] +name = "dify-vdb-chroma" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-chroma" } +dependencies = [ + { name = "chromadb" }, +] + +[package.metadata] +requires-dist = [{ name = "chromadb", specifier = "==0.5.20" }] + +[[package]] +name = "dify-vdb-clickzetta" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-clickzetta" } +dependencies = [ + { name = "clickzetta-connector-python" }, +] + +[package.metadata] +requires-dist = [{ name = "clickzetta-connector-python", specifier = ">=0.8.102" }] + +[[package]] +name = "dify-vdb-couchbase" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-couchbase" } +dependencies = [ + { name = "couchbase" }, +] + +[package.metadata] +requires-dist = [{ name = "couchbase", specifier = "~=4.6.0" }] + +[[package]] +name = "dify-vdb-elasticsearch" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-elasticsearch" } +dependencies = [ + { name = "elasticsearch" }, +] + +[package.metadata] +requires-dist = [{ name = "elasticsearch", specifier = "==8.14.0" }] + +[[package]] +name = "dify-vdb-hologres" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-hologres" } +dependencies = [ + { name = "holo-search-sdk" }, +] + +[package.metadata] +requires-dist = [{ name = "holo-search-sdk", specifier = ">=0.4.2" }] + +[[package]] +name = "dify-vdb-huawei-cloud" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-huawei-cloud" } +dependencies = [ + { name = "elasticsearch" }, +] + +[package.metadata] +requires-dist = [{ name = "elasticsearch", specifier = "==8.14.0" }] + +[[package]] +name = "dify-vdb-iris" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-iris" } +dependencies = [ + { name = "intersystems-irispython" }, +] + +[package.metadata] +requires-dist = [{ name = "intersystems-irispython", specifier = ">=5.1.0" }] + +[[package]] +name = "dify-vdb-lindorm" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-lindorm" } +dependencies = [ + { name = "opensearch-py" }, + { name = "tenacity" }, +] + +[package.metadata] +requires-dist = [ + { name = "opensearch-py", specifier = "==3.1.0" }, + { name = "tenacity", specifier = ">=8.0.0" }, +] + +[[package]] +name = "dify-vdb-matrixone" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-matrixone" } +dependencies = [ + { name = "mo-vector" }, +] + +[package.metadata] +requires-dist = [{ name = "mo-vector", specifier = "~=0.1.13" }] + +[[package]] +name = "dify-vdb-milvus" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-milvus" } +dependencies = [ + { name = "pymilvus" }, +] + +[package.metadata] +requires-dist = [{ name = "pymilvus", specifier = "~=2.6.12" }] + +[[package]] +name = "dify-vdb-myscale" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-myscale" } +dependencies = [ + { name = "clickhouse-connect" }, +] + +[package.metadata] +requires-dist = [{ name = "clickhouse-connect", specifier = "~=0.15.0" }] + +[[package]] +name = "dify-vdb-oceanbase" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-oceanbase" } +dependencies = [ + { name = "mysql-connector-python" }, + { name = "pyobvector" }, +] + +[package.metadata] +requires-dist = [ + { name = "mysql-connector-python", specifier = ">=9.3.0" }, + { name = "pyobvector", specifier = "~=0.2.17" }, +] + +[[package]] +name = "dify-vdb-opengauss" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-opengauss" } + +[[package]] +name = "dify-vdb-opensearch" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-opensearch" } +dependencies = [ + { name = "opensearch-py" }, +] + +[package.metadata] +requires-dist = [{ name = "opensearch-py", specifier = "==3.1.0" }] + +[[package]] +name = "dify-vdb-oracle" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-oracle" } +dependencies = [ + { name = "oracledb" }, +] + +[package.metadata] +requires-dist = [{ name = "oracledb", specifier = "==3.4.2" }] + +[[package]] +name = "dify-vdb-pgvecto-rs" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-pgvecto-rs" } +dependencies = [ + { name = "pgvecto-rs", extra = ["sqlalchemy"] }, +] + +[package.metadata] +requires-dist = [{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.2" }] + +[[package]] +name = "dify-vdb-pgvector" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-pgvector" } +dependencies = [ + { name = "pgvector" }, +] + +[package.metadata] +requires-dist = [{ name = "pgvector", specifier = "==0.4.2" }] + +[[package]] +name = "dify-vdb-qdrant" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-qdrant" } +dependencies = [ + { name = "qdrant-client" }, +] + +[package.metadata] +requires-dist = [{ name = "qdrant-client", specifier = "==1.9.0" }] + +[[package]] +name = "dify-vdb-relyt" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-relyt" } + +[[package]] +name = "dify-vdb-tablestore" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-tablestore" } +dependencies = [ + { name = "tablestore" }, +] + +[package.metadata] +requires-dist = [{ name = "tablestore", specifier = "==6.4.4" }] + +[[package]] +name = "dify-vdb-tencent" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-tencent" } +dependencies = [ + { name = "tcvectordb" }, +] + +[package.metadata] +requires-dist = [{ name = "tcvectordb", specifier = "~=2.1.0" }] + +[[package]] +name = "dify-vdb-tidb-on-qdrant" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-tidb-on-qdrant" } +dependencies = [ + { name = "qdrant-client" }, +] + +[package.metadata] +requires-dist = [{ name = "qdrant-client", specifier = "==1.9.0" }] + +[[package]] +name = "dify-vdb-tidb-vector" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-tidb-vector" } +dependencies = [ + { name = "tidb-vector" }, +] + +[package.metadata] +requires-dist = [{ name = "tidb-vector", specifier = "==0.0.15" }] + +[[package]] +name = "dify-vdb-upstash" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-upstash" } +dependencies = [ + { name = "upstash-vector" }, +] + +[package.metadata] +requires-dist = [{ name = "upstash-vector", specifier = "==0.8.0" }] + +[[package]] +name = "dify-vdb-vastbase" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-vastbase" } +dependencies = [ + { name = "pyobvector" }, +] + +[package.metadata] +requires-dist = [{ name = "pyobvector", specifier = "~=0.2.17" }] + +[[package]] +name = "dify-vdb-vikingdb" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-vikingdb" } +dependencies = [ + { name = "volcengine-compat" }, +] + +[package.metadata] +requires-dist = [{ name = "volcengine-compat", specifier = "~=1.0.0" }] + +[[package]] +name = "dify-vdb-weaviate" +version = "0.0.1" +source = { editable = "providers/vdb/vdb-weaviate" } +dependencies = [ + { name = "weaviate-client" }, +] + +[package.metadata] +requires-dist = [{ name = "weaviate-client", specifier = "==4.20.5" }] + +[[package]] +name = "diskcache-weave" +version = "5.6.3.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/52/634e1f43486489fdaded1a7c9bd3524b7e0ca9bcc43af426afa511c541e2/diskcache_weave-5.6.3.post1.tar.gz", hash = "sha256:1fe7e648d1d85d517c05b296f1692e7c425a71714dc31a4b7a584a8f8f5604f2", size = 68297, upload-time = "2026-03-19T14:57:54.299Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/92887441bc338fb8d0b8ea75eb0392c00e20a85ec0bbe02f273188849568/diskcache_weave-5.6.3.post1-py3-none-any.whl", hash = "sha256:b00e9842b74eeecf314456f9c833a6d4f7792ed12b20297b4d3b9df7859ee66f", size = 45905, upload-time = "2026-03-19T14:57:52.819Z" }, ] [[package]] @@ -1734,18 +2233,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] -[[package]] -name = "ecdsa" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, -] - [[package]] name = "elastic-transport" version = "8.17.1" @@ -1800,15 +2287,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] -[[package]] -name = "eval-type-backport" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/23/079e39571d6dd8d90d7a369ecb55ad766efb6bae4e77389629e14458c280/eval_type_backport-0.3.0.tar.gz", hash = "sha256:1638210401e184ff17f877e9a2fa076b60b5838790f4532a21761cc2be67aea1", size = 9272, upload-time = "2025-11-13T20:56:50.845Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/d8/2a1c638d9e0aa7e269269a1a1bf423ddd94267f1a01bbe3ad03432b67dd4/eval_type_backport-0.3.0-py3-none-any.whl", hash = "sha256:975a10a0fe333c8b6260d7fdb637698c9a16c3a9e3b6eb943fee6a6f67a37fe8", size = 6061, upload-time = "2025-11-13T20:56:49.499Z" }, -] - [[package]] name = "events" version = "0.5" @@ -1828,14 +2306,14 @@ wheels = [ [[package]] name = "faker" -version = "40.12.0" +version = "40.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c1/f8224fe97fea2f98d455c22438c1b09b10e14ef2cb95ae4f7cec9aa59659/faker-40.12.0.tar.gz", hash = "sha256:58b5a9054c367bd5fb2e948634105364cc570e78a98a8e5161a74691c45f158f", size = 1962003, upload-time = "2026-03-30T18:00:56.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/5c/39452a6b6aa76ffa518fa7308e1975b37e9ba77caa6172a69d61e7180221/faker-40.12.0-py3-none-any.whl", hash = "sha256:6238a4058a8b581892e3d78fe5fdfa7568739e1c8283e4ede83f1dde0bfc1a3b", size = 1994601, upload-time = "2026-03-30T18:00:54.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" }, ] [[package]] @@ -2086,7 +2564,7 @@ wheels = [ [[package]] name = "gevent" -version = "25.9.1" +version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, @@ -2094,16 +2572,16 @@ dependencies = [ { name = "zope-event" }, { name = "zope-interface" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/27/1062fa31333dc3428a1f5f33cd6598b0552165ba679ca3ba116de42c9e8e/gevent-26.4.0.tar.gz", hash = "sha256:288d03addfccf0d1c67268358b6759b04392bf3bc35d26f3d9a45c82899c292d", size = 6242440, upload-time = "2026-04-09T12:08:19.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/49/e55930ba5259629eb28ac7ee1abbca971996a9165f902f0249b561602f24/gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86", size = 2955991, upload-time = "2025-09-17T14:52:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/aa/88/63dc9e903980e1da1e16541ec5c70f2b224ec0a8e34088cb42794f1c7f52/gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692", size = 1808503, upload-time = "2025-09-17T15:41:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/7a/8d/7236c3a8f6ef7e94c22e658397009596fa90f24c7d19da11ad7ab3a9248e/gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2", size = 1890001, upload-time = "2025-09-17T15:49:01.227Z" }, - { url = "https://files.pythonhosted.org/packages/4f/63/0d7f38c4a2085ecce26b50492fc6161aa67250d381e26d6a7322c309b00f/gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74", size = 1855335, upload-time = "2025-09-17T15:49:20.582Z" }, - { url = "https://files.pythonhosted.org/packages/95/18/da5211dfc54c7a57e7432fd9a6ffeae1ce36fe5a313fa782b1c96529ea3d/gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51", size = 2109046, upload-time = "2025-09-17T15:15:13.817Z" }, - { url = "https://files.pythonhosted.org/packages/a6/5a/7bb5ec8e43a2c6444853c4a9f955f3e72f479d7c24ea86c95fb264a2de65/gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5", size = 1827099, upload-time = "2025-09-17T15:52:41.384Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d4/b63a0a60635470d7d986ef19897e893c15326dd69e8fb342c76a4f07fe9e/gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f", size = 2172623, upload-time = "2025-09-17T15:24:12.03Z" }, - { url = "https://files.pythonhosted.org/packages/d5/98/caf06d5d22a7c129c1fb2fc1477306902a2c8ddfd399cd26bbbd4caf2141/gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3", size = 1682837, upload-time = "2025-09-17T19:48:47.318Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/131d3874f50974b355c90a061a12d3fe2292cde0f875a1fa3d8b224f1251/gevent-26.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:318a0a73f664113e8d86d0cb0e328e7650e2d7d9c2e045418ab6fb1285831ad3", size = 2928699, upload-time = "2026-04-08T21:25:36.215Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8b/199e59b303adaff7f7365def9ab569c7ecd863363c974548bce3ddc2c89d/gevent-26.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ce7aa033a3f68beb6732d1450a80c1af29e63e0c2d01abad7918cf2507f72fa6", size = 1783821, upload-time = "2026-04-08T22:23:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/b8249c9bd3f386191311c3a9bec4068e192a3f9df2fad92a71a15265ba15/gevent-26.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:a1b897c952baefd72232efaeb3bdb1ca2fa7ae94cbfe68ac21201b03e843190a", size = 1879424, upload-time = "2026-04-08T22:27:10.561Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/59216985c1f2c11f2f28bbc88e583588ad44cdde823c530ad4e307be6612/gevent-26.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:7eef2ea508ce41795e20587a5fc868ae4919543097c81a40fbdfd65bc479f54f", size = 1830575, upload-time = "2026-04-08T22:34:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a9/2d67d2b0aa0ca9d7bb7fe73c3bbb97b3695cb15c338a6ea7734f58da9add/gevent-26.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f7e12fdd28cc9f39a463d8df5172d698c64a8ed385a21d98e7092fd8308a139a", size = 2113898, upload-time = "2026-04-08T21:54:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/457d58d9b3e7da17c8456d841c37a32af8d231a1d71237ad201b19129317/gevent-26.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d48e3ee13d7678c24c22f19d441ad6bc220a79f23662d03ff36fae0d62efdb59", size = 1795890, upload-time = "2026-04-08T22:26:53.252Z" }, + { url = "https://files.pythonhosted.org/packages/a7/cc/cbe78f2626643b20275aaa41cd2cc45ba75056e3665bde36bc190af3cae0/gevent-26.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c58c8e034f94329be4dc0979fba3301005a433dbab42cea0b2c33fd736946872", size = 2139791, upload-time = "2026-04-08T22:00:02.375Z" }, + { url = "https://files.pythonhosted.org/packages/f6/df/7875e08b06a95f4577b71708ec470d029fadf873a66eb813a2861d79dfb5/gevent-26.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c737e6ac6ce1398df0e3f41c58d982e397c993cbe73ac05b7edbe39e128c9cb", size = 1680530, upload-time = "2026-04-08T23:15:38.714Z" }, ] [[package]] @@ -2160,7 +2638,7 @@ wheels = [ [[package]] name = "google-api-core" -version = "2.30.2" +version = "2.30.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -2169,9 +2647,9 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/2e/83ca41eb400eb228f9279ec14ed66f6475218b59af4c6daec2d5a509fe83/google_api_core-2.30.2.tar.gz", hash = "sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9", size = 176862, upload-time = "2026-04-02T21:23:44.876Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/e1/ebd5100cbb202e561c0c8b59e485ef3bd63fa9beb610f3fdcaea443f0288/google_api_core-2.30.2-py3-none-any.whl", hash = "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594", size = 173236, upload-time = "2026-04-02T21:23:06.395Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, ] [package.optional-dependencies] @@ -2182,7 +2660,7 @@ grpc = [ [[package]] name = "google-api-python-client" -version = "2.193.0" +version = "2.194.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -2191,22 +2669,22 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/f4/e14b6815d3b1885328dd209676a3a4c704882743ac94e18ef0093894f5c8/google_api_python_client-2.193.0.tar.gz", hash = "sha256:8f88d16e89d11341e0a8b199cafde0fb7e6b44260dffb88d451577cbd1bb5d33", size = 14281006, upload-time = "2026-03-17T18:25:29.415Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/ab/e83af0eb043e4ccc49571ca7a6a49984e9d00f4e9e6e6f1238d60bc84dce/google_api_python_client-2.194.0.tar.gz", hash = "sha256:db92647bd1a90f40b79c9618461553c2b20b6a43ce7395fa6de07132dc14f023", size = 14443469, upload-time = "2026-04-08T23:07:35.757Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/6d/fe75167797790a56d17799b75e1129bb93f7ff061efc7b36e9731bd4be2b/google_api_python_client-2.193.0-py3-none-any.whl", hash = "sha256:c42aa324b822109901cfecab5dc4fc3915d35a7b376835233c916c70610322db", size = 14856490, upload-time = "2026-03-17T18:25:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/b0/34/5a624e49f179aa5b0cb87b2ce8093960299030ff40423bfbde09360eb908/google_api_python_client-2.194.0-py3-none-any.whl", hash = "sha256:61eaaac3b8fc8fdf11c08af87abc3d1342d1b37319cc1b57405f86ef7697e717", size = 15016514, upload-time = "2026-04-08T23:07:33.093Z" }, ] [[package]] name = "google-auth" -version = "2.49.1" +version = "2.49.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, ] [package.optional-dependencies] @@ -2229,7 +2707,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.145.0" +version = "1.147.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -2245,9 +2723,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/e5/6442d9d2c019456638825d4665b1e87ec4eaf1d182950ba426d0f0210eab/google_cloud_aiplatform-1.145.0.tar.gz", hash = "sha256:7894c4f3d2684bdb60e9a122004c01678e3b585174a27298ae7a3ed1e5eaf3bd", size = 10222904, upload-time = "2026-04-02T14:06:58.322Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/93/9bfcaaf1ceab12999a881ccf69ebd9b30f467ec5623989c66894e81fc139/google_cloud_aiplatform-1.147.0.tar.gz", hash = "sha256:b2e1b669ba37f02426e03eb13187eebf4cbfeaa0a3bfed37b5578abb375ab689", size = 10235245, upload-time = "2026-04-09T17:14:49.179Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/c6/23e98d3407d5e2416a3dfaecb0a053da899848c50db69e5f2b61a555ce06/google_cloud_aiplatform-1.145.0-py2.py3-none-any.whl", hash = "sha256:4d1c31797a8bd8f3342ed5f186dd30d1f6bca73ddbee2bde452777100d2ddc11", size = 8396640, upload-time = "2026-04-02T14:06:54.125Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d2/1c1c582f6bbed9bbc0daa5acf3a5d98751ca8bc48584548d28569b8ce1a7/google_cloud_aiplatform-1.147.0-py2.py3-none-any.whl", hash = "sha256:29f7ae020718d3c45094f0475464e06a97f81b1572bea150ae6a1b22c5f45997", size = 8408951, upload-time = "2026-04-09T17:14:45.482Z" }, ] [[package]] @@ -2330,7 +2808,7 @@ wheels = [ [[package]] name = "google-genai" -version = "1.65.0" +version = "1.72.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2344,9 +2822,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/f9/cc1191c2540d6a4e24609a586c4ed45d2db57cfef47931c139ee70e5874a/google_genai-1.65.0.tar.gz", hash = "sha256:d470eb600af802d58a79c7f13342d9ea0d05d965007cae8f76c7adff3d7a4750", size = 497206, upload-time = "2026-02-26T00:20:33.824Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/20/2aff5ea3cd7459f85101d119c136d9ca4369fcda3dcf0cfee89b305611a4/google_genai-1.72.0.tar.gz", hash = "sha256:abe7d3aecfafb464b904e3a09c81b626fb425e160e123e71a5125a7021cea7b2", size = 522844, upload-time = "2026-04-09T21:35:46.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/3c/3fea4e7c91357c71782d7dcaad7a2577d636c90317e003386893c25bc62c/google_genai-1.65.0-py3-none-any.whl", hash = "sha256:68c025205856919bc03edb0155c11b4b833810b7ce17ad4b7a9eeba5158f6c44", size = 724429, upload-time = "2026-02-26T00:20:32.186Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3d/9f70246114cdf56a2615a40428ced08bc844f5a26247fe812b2f0dd4eaca/google_genai-1.72.0-py3-none-any.whl", hash = "sha256:ea861e4c6946e3185c24b40d95503e088fc230a73a71fec0ef78164b369a8489", size = 764230, upload-time = "2026-04-09T21:35:44.587Z" }, ] [[package]] @@ -2394,12 +2872,8 @@ wheels = [ ] [package.optional-dependencies] -aiohttp = [ - { name = "aiohttp" }, -] -requests = [ - { name = "requests" }, - { name = "requests-toolbelt" }, +httpx = [ + { name = "httpx" }, ] [[package]] @@ -2637,16 +3111,16 @@ wheels = [ [[package]] name = "holo-search-sdk" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "psycopg", extra = ["binary"] }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/b8/70a4999dabbba15e98d201a7399aab76ab96931ad1a27392ba5252cc9165/holo_search_sdk-0.4.1.tar.gz", hash = "sha256:9aea98b6078b9202abb568ed69d798d5e0505d2b4cc3a136a6aa84402bcd2133", size = 56701, upload-time = "2026-01-28T01:44:57.645Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/6d/62bc3f27002a6e1fa6aefdc17f9e95bec67eebb5348542637bf01c8caa6a/holo_search_sdk-0.4.2.tar.gz", hash = "sha256:630ade92c82d3d610a6e4f933f530045a6acbab4528512f5dc5d7f67dd743263", size = 57433, upload-time = "2026-03-25T05:59:25.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/30/3059a979272f90a96f31b167443cc27675e8cc8f970a3ac0cb80bf803c70/holo_search_sdk-0.4.1-py3-none-any.whl", hash = "sha256:ef1059895ea936ff6a087f68dac92bd1ae0320e51ec5b1d4e7bed7a5dd6beb45", size = 32647, upload-time = "2026-01-28T01:44:56.098Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9a/5021e499a1aa4fc1f1b8ca5dcbc9987d2ab7115da4fa9d1e464a6590d142/holo_search_sdk-0.4.2-py3-none-any.whl", hash = "sha256:b0ef8e6ee6a6980526317951ab0967d18dd2973500b7e3f38259f061471ac5da", size = 33488, upload-time = "2026-03-25T05:59:23.216Z" }, ] [[package]] @@ -2786,14 +3260,14 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.151.11" +version = "6.151.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/58/41af0d539b3c95644d1e4e353cbd6ac9473e892ea21802546a8886b79078/hypothesis-6.151.11.tar.gz", hash = "sha256:f33dcb68b62c7b07c9ac49664989be898fa8ce57583f0dc080259a197c6c7ff1", size = 463779, upload-time = "2026-04-05T17:35:55.935Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/67ca321d1ab96fd3828b12142f1c258e2d4a668a025d06cd50ab3409787f/hypothesis-6.151.12.tar.gz", hash = "sha256:be485f503979af4c3dfa19e3fc2b967d0458e7f8c4e28128d7e215e0a55102e0", size = 463900, upload-time = "2026-04-08T19:40:06.205Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/06/f49393eca84b87b17a67aaebf9f6251190ba1e9fe9f2236504049fc43fee/hypothesis-6.151.11-py3-none-any.whl", hash = "sha256:7ac05173206746cec8312f95164a30a4eb4916815413a278922e63ff1e404648", size = 529572, upload-time = "2026-04-05T17:35:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/6cecf134b631050a1f8605096adbe812483b60790d951470989d39b56860/hypothesis-6.151.12-py3-none-any.whl", hash = "sha256:37d4f3a768365c30571b11dfd7a6857a12173d933010b2c4ab65619f1b5952c5", size = 529656, upload-time = "2026-04-08T19:40:03.126Z" }, ] [[package]] @@ -2961,11 +3435,11 @@ wheels = [ [[package]] name = "json-repair" -version = "0.55.1" +version = "0.59.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/de/71d6bb078d167c0d0959776cee6b6bb8d2ad843f512a5222d7151dde4955/json_repair-0.55.1.tar.gz", hash = "sha256:b27aa0f6bf2e5bf58554037468690446ef26f32ca79c8753282adb3df25fb888", size = 39231, upload-time = "2026-01-23T09:37:20.93Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/cb/a49f1661737a78098ce33668350590c981a4163055bc9a01e0cc688d896a/json_repair-0.59.2.tar.gz", hash = "sha256:1d8abb2fa94c4035a66ef9892ea3785dace8dcf09c583e6de781cfd31b278b3d", size = 48341, upload-time = "2026-04-11T15:55:41.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/da/289ba9eb550ae420cfc457926f6c49b87cacf8083ee9927e96921888a665/json_repair-0.55.1-py3-none-any.whl", hash = "sha256:a1bcc151982a12bc3ef9e9528198229587b1074999cfe08921ab6333b0c8e206", size = 29743, upload-time = "2026-01-23T09:37:19.404Z" }, + { url = "https://files.pythonhosted.org/packages/e1/03/7afcecb4242d93b684708b47fb014abdc1922a01b38c0e30f1117ae74a83/json_repair-0.59.2-py3-none-any.whl", hash = "sha256:6ca6238519c24f671bcb05d1f38a0d6a452bb4ca5af82137595c5c2f1a0fb785", size = 46918, upload-time = "2026-04-11T15:55:39.817Z" }, ] [[package]] @@ -3052,7 +3526,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2 [[package]] name = "langfuse" -version = "4.0.6" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -3064,14 +3538,14 @@ dependencies = [ { name = "pydantic" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/d0/6d79ed5614f86f27f5df199cf10c6facf6874ff6f91b828ae4dad90aa86d/langfuse-4.0.6.tar.gz", hash = "sha256:83a6f8cc8f1431fa2958c91e2673bc4179f993297e9b1acd1dbf001785e6cf83", size = 274094, upload-time = "2026-04-01T20:04:15.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/9c/b912a00ffae92ff9955cdd9b74fb839be58f631d4329ae2a8a0376f697f2/langfuse-4.2.0.tar.gz", hash = "sha256:d0bd26d5065cf6a59d7d1093b08d8910e2458dc3da7ed8ccec160db114c18342", size = 275582, upload-time = "2026-04-10T11:55:25.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b4/088048e37b6d7ec1b52c6a11bc33101454285a22eaab8303dcccfd78344d/langfuse-4.0.6-py3-none-any.whl", hash = "sha256:0562b1dcf83247f9d8349f0f755eaed9a7f952fee67e66580970f0738bf3adbf", size = 472841, upload-time = "2026-04-01T20:04:16.451Z" }, + { url = "https://files.pythonhosted.org/packages/be/0a/b84e3e68a690ccfe6d64953c572772c685fcb0915b7f2ee3a87c22e388ab/langfuse-4.2.0-py3-none-any.whl", hash = "sha256:bfd760bf10fd0228f297f6369436620f76d16b589de46393d65706b27e4e4082", size = 475449, upload-time = "2026-04-10T11:55:23.624Z" }, ] [[package]] name = "langsmith" -version = "0.7.25" +version = "0.7.30" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3084,9 +3558,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/d7/21ffae5ccdc3c9b8de283e8f8bf48a92039681df0d39f15133d8ff8965bd/langsmith-0.7.25.tar.gz", hash = "sha256:d17da71f156ca69eafd28ac9627c8e0e93170260ec37cd27cedc83205a067598", size = 1145410, upload-time = "2026-04-03T13:11:42.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/e7/d27d952ce9824d684a3bb500a06541a2d55734bc4d849cdfcca2dfd4d93a/langsmith-0.7.30.tar.gz", hash = "sha256:d9df7ba5e42f818b63bda78776c8f2fc853388be3ae77b117e5d183a149321a2", size = 1106040, upload-time = "2026-04-09T21:12:01.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/13/67889d41baf7dbaf13ffd0b334a0f284e107fad1cc8782a1abb1e56e5eeb/langsmith-0.7.25-py3-none-any.whl", hash = "sha256:55ecc24c547f6c79b5a684ff8685c669eec34e52fcac5d2c0af7d613aef5a632", size = 359417, upload-time = "2026-04-03T13:11:40.729Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/96250cf58070c5563446651b03bb76c2eb5afbf08e754840ab639532d8c6/langsmith-0.7.30-py3-none-any.whl", hash = "sha256:43dd9f8d290e4d406606d6cc0bd62f5d1050963f05fe0ab6ffe50acf41f2f55a", size = 372682, upload-time = "2026-04-09T21:12:00.481Z" }, ] [[package]] @@ -3121,7 +3595,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.82.6" +version = "1.83.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3137,22 +3611,21 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/92/6ce9737554994ca8e536e5f4f6a87cc7c4774b656c9eb9add071caf7d54b/litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a", size = 17333062, upload-time = "2026-03-31T05:08:25.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" }, + { url = "https://files.pythonhosted.org/packages/19/2c/a670cc050fcd6f45c6199eb99e259c73aea92edba8d5c2fc1b3686d36217/litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8", size = 15610306, upload-time = "2026-03-31T05:08:21.987Z" }, ] [[package]] name = "llvmlite" -version = "0.45.1" +version = "0.47.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/8d/5baf1cef7f9c084fb35a8afbde88074f0d6a727bc63ef764fe0e7543ba40/llvmlite-0.45.1.tar.gz", hash = "sha256:09430bb9d0bb58fc45a45a57c7eae912850bedc095cd0810a57de109c69e1c32", size = 185600, upload-time = "2025-10-01T17:59:52.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/7c/82cbd5c656e8991bcc110c69d05913be2229302a92acb96109e166ae31fb/llvmlite-0.45.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:28e763aba92fe9c72296911e040231d486447c01d4f90027c8e893d89d49b20e", size = 43043524, upload-time = "2025-10-01T18:03:30.666Z" }, - { url = "https://files.pythonhosted.org/packages/9d/bc/5314005bb2c7ee9f33102c6456c18cc81745d7055155d1218f1624463774/llvmlite-0.45.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1a53f4b74ee9fd30cb3d27d904dadece67a7575198bd80e687ee76474620735f", size = 37253123, upload-time = "2025-10-01T18:04:18.177Z" }, - { url = "https://files.pythonhosted.org/packages/96/76/0f7154952f037cb320b83e1c952ec4a19d5d689cf7d27cb8a26887d7bbc1/llvmlite-0.45.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b3796b1b1e1c14dcae34285d2f4ea488402fbd2c400ccf7137603ca3800864f", size = 56288211, upload-time = "2025-10-01T18:01:24.079Z" }, - { url = "https://files.pythonhosted.org/packages/00/b1/0b581942be2683ceb6862d558979e87387e14ad65a1e4db0e7dd671fa315/llvmlite-0.45.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:779e2f2ceefef0f4368548685f0b4adde34e5f4b457e90391f570a10b348d433", size = 55140958, upload-time = "2025-10-01T18:02:30.482Z" }, - { url = "https://files.pythonhosted.org/packages/33/94/9ba4ebcf4d541a325fd8098ddc073b663af75cc8b065b6059848f7d4dce7/llvmlite-0.45.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e6c9949baf25d9aa9cd7cf0f6d011b9ca660dd17f5ba2b23bdbdb77cc86b116", size = 38132231, upload-time = "2025-10-01T18:05:03.664Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, ] [[package]] @@ -3269,7 +3742,7 @@ wheels = [ [[package]] name = "mlflow-skinny" -version = "3.10.1" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -3292,9 +3765,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/65/5b2c28e74c167ba8a5afe59399ef44291a0f140487f534db1900f09f59f6/mlflow_skinny-3.10.1.tar.gz", hash = "sha256:3d1c5c30245b6e7065b492b09dd47be7528e0a14c4266b782fe58f9bcd1e0be0", size = 2478631, upload-time = "2026-03-05T10:49:01.47Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/77/fe2027ddad9e52ed1ac360fbc262169e6366f6678632e350cbd0d901bb9b/mlflow_skinny-3.11.1.tar.gz", hash = "sha256:86ce63491349f6713afc8a4ef0bf77a8314d0e79e03753cb150d6c860a0b0475", size = 2642799, upload-time = "2026-04-07T14:26:43.818Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/52/17460157271e70b0d8444d27f8ad730ef7d95fb82fac59dc19f11519b921/mlflow_skinny-3.10.1-py3-none-any.whl", hash = "sha256:df1dd507d8ddadf53bfab2423c76cdcafc235cd1a46921a06d1a6b4dd04b023c", size = 2987098, upload-time = "2026-03-05T10:48:59.566Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/e61ec397b34dc3c9e91572f45e41617f429d5c524d38a4e1aa2316ee1b5e/mlflow_skinny-3.11.1-py3-none-any.whl", hash = "sha256:82ffd5f6980320b4ac19f741e7a754faa1d01707e632b002ea68e04fd25a0535", size = 3171551, upload-time = "2026-04-07T14:26:41.762Z" }, ] [[package]] @@ -3415,7 +3888,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.0" +version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -3423,16 +3896,16 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/3d/5b373635b3146264eb7a68d09e5ca11c305bbb058dfffbb47c47daf4f632/mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804", size = 3815892, upload-time = "2026-04-13T02:46:51.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, - { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, - { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, - { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, - { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, - { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, + { url = "https://files.pythonhosted.org/packages/69/1b/75a7c825a02781ca10bc2f2f12fba2af5202f6d6005aad8d2d1f264d8d78/mypy-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:36ee2b9c6599c230fea89bbd79f401f9f9f8e9fcf0c777827789b19b7da90f51", size = 14494077, upload-time = "2026-04-13T02:45:55.085Z" }, + { url = "https://files.pythonhosted.org/packages/b0/54/5e5a569ea5c2b4d48b729fb32aa936eeb4246e4fc3e6f5b3d36a2dfbefb9/mypy-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fba3fb0968a7b48806b0c90f38d39296f10766885a94c83bd21399de1e14eb28", size = 13319495, upload-time = "2026-04-13T02:45:29.674Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a4/a1945b19f33e91721b59deee3abb484f2fa5922adc33bb166daf5325d76d/mypy-1.20.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1415a637cd3627d6304dfbeddbadd21079dafc2a8a753c477ce4fc0c2af54f", size = 13696948, upload-time = "2026-04-13T02:46:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/75e969781c2359b2f9c15b061f28ec6d67c8b61865ceda176e85c8e7f2de/mypy-1.20.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef3461b1ad5cd446e540016e90b5984657edda39f982f4cc45ca317b628f5a37", size = 14706744, upload-time = "2026-04-13T02:46:00.482Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6e/b221b1de981fc4262fe3e0bf9ec272d292dfe42394a689c2d49765c144c4/mypy-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:542dd63c9e1339b6092eb25bd515f3a32a1453aee8c9521d2ddb17dacd840237", size = 14949035, upload-time = "2026-04-13T02:45:06.021Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4b/298ba2de0aafc0da3ff2288da06884aae7ba6489bc247c933f87847c41b3/mypy-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d55c7cd8ca22e31f93af2a01160a9e95465b5878de23dba7e48116052f20a8d", size = 10883216, upload-time = "2026-04-13T02:45:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f9/5e25b8f0b8cb92f080bfed9c21d3279b2a0b6a601cdca369a039ba84789d/mypy-1.20.1-cp312-cp312-win_arm64.whl", hash = "sha256:f5b84a79070586e0d353ee07b719d9d0a4aa7c8ee90c0ea97747e98cbe193019", size = 9814299, upload-time = "2026-04-13T02:45:21.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/28/926bd972388e65a39ee98e188ccf67e81beb3aacfd5d6b310051772d974b/mypy-1.20.1-py3-none-any.whl", hash = "sha256:1aae28507f253fe82d883790d1c0a0d35798a810117c88184097fe8881052f06", size = 2636553, upload-time = "2026-04-13T02:46:30.45Z" }, ] [[package]] @@ -3509,19 +3982,18 @@ wheels = [ [[package]] name = "numba" -version = "0.62.1" +version = "0.65.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llvmlite" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/20/33dbdbfe60e5fd8e3dbfde299d106279a33d9f8308346022316781368591/numba-0.62.1.tar.gz", hash = "sha256:7b774242aa890e34c21200a1fc62e5b5757d5286267e71103257f4e2af0d5161", size = 2749817, upload-time = "2025-09-29T10:46:31.551Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/61/7299643b9c18d669e04be7c5bcb64d985070d07553274817b45b049e7bfe/numba-0.65.0.tar.gz", hash = "sha256:edad0d9f6682e93624c00125a471ae4df186175d71fd604c983c377cdc03e68b", size = 2764131, upload-time = "2026-04-01T03:52:01.946Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/fa/30fa6873e9f821c0ae755915a3ca444e6ff8d6a7b6860b669a3d33377ac7/numba-0.62.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:1b743b32f8fa5fff22e19c2e906db2f0a340782caf024477b97801b918cf0494", size = 2685346, upload-time = "2025-09-29T10:43:43.677Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d5/504ce8dc46e0dba2790c77e6b878ee65b60fe3e7d6d0006483ef6fde5a97/numba-0.62.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:90fa21b0142bcf08ad8e32a97d25d0b84b1e921bc9423f8dda07d3652860eef6", size = 2688139, upload-time = "2025-09-29T10:44:04.894Z" }, - { url = "https://files.pythonhosted.org/packages/50/5f/6a802741176c93f2ebe97ad90751894c7b0c922b52ba99a4395e79492205/numba-0.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ef84d0ac19f1bf80431347b6f4ce3c39b7ec13f48f233a48c01e2ec06ecbc59", size = 3796453, upload-time = "2025-09-29T10:42:52.771Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/efd21527d25150c4544eccc9d0b7260a5dec4b7e98b5a581990e05a133c0/numba-0.62.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9315cc5e441300e0ca07c828a627d92a6802bcbf27c5487f31ae73783c58da53", size = 3496451, upload-time = "2025-09-29T10:43:19.279Z" }, - { url = "https://files.pythonhosted.org/packages/80/44/79bfdab12a02796bf4f1841630355c82b5a69933b1d50eb15c7fa37dabe8/numba-0.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:44e3aa6228039992f058f5ebfcfd372c83798e9464297bdad8cc79febcf7891e", size = 2745552, upload-time = "2025-09-29T10:44:26.399Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2f/8bd31a1ea43c01ac215283d83aa5f8d5acbe7a36c85b82f1757bfe9ccb31/numba-0.65.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b27ee4847e1bfb17e9604d100417ee7c1d10f15a6711c6213404b3da13a0b2aa", size = 2680705, upload-time = "2026-04-01T03:51:32.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/88406bd58600cc696417b8e5dd6a056478da808f3eaf48d18e2421e0c2d9/numba-0.65.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a52d92ffd297c10364bce60cd1fcb88f99284ab5df085f2c6bcd1cb33b529a6f", size = 3801411, upload-time = "2026-04-01T03:51:34.321Z" }, + { url = "https://files.pythonhosted.org/packages/0c/61/ce753a1d7646dd477e16d15e89473703faebb8995d2f71d7ad69a540b565/numba-0.65.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da8e371e328c06d0010c3d8b44b21858652831b85bcfba78cb22c042e22dbd8e", size = 3501622, upload-time = "2026-04-01T03:51:36.348Z" }, + { url = "https://files.pythonhosted.org/packages/7d/86/db87a5393f1b1fabef53ac3ba4e6b938bb27e40a04ad7cc512098fcae032/numba-0.65.0-cp312-cp312-win_amd64.whl", hash = "sha256:59bb9f2bb9f1238dfd8e927ba50645c18ae769fef4f3d58ea0ea22a2683b91f5", size = 2749979, upload-time = "2026-04-01T03:51:37.88Z" }, ] [[package]] @@ -3545,30 +4017,33 @@ wheels = [ [[package]] name = "numpy" -version = "1.26.4" +version = "2.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, - { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, - { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, - { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, - { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, - { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, - { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, ] [[package]] name = "numpy-typing-compat" -version = "20250818.1.25" +version = "20251206.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/a7/780dc00f4fed2f2b653f76a196b3a6807c7c667f30ae95a7fd082c1081d8/numpy_typing_compat-20250818.1.25.tar.gz", hash = "sha256:8ff461725af0b436e9b0445d07712f1e6e3a97540a3542810f65f936dcc587a5", size = 5027, upload-time = "2025-08-18T23:46:39.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/5f/29fd5f29b0a5d96e2def96ecba3112fc330ecd16e8c97c2b332563c5e201/numpy_typing_compat-20251206.2.4.tar.gz", hash = "sha256:59882d23aaff054a2536da80564012cdce33487657be4d79c5925bb8705fcabc", size = 5011, upload-time = "2025-12-06T20:02:04.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/71/30e8d317b6896acbc347d3089764b6209ba299095550773e14d27dcf035f/numpy_typing_compat-20250818.1.25-py3-none-any.whl", hash = "sha256:4f91427369583074b236c804dd27559134f08ec4243485034c8e7d258cbd9cd3", size = 6355, upload-time = "2025-08-18T23:46:30.927Z" }, + { url = "https://files.pythonhosted.org/packages/63/7c/5c2892e6bc0628a2ccf4e938e1e2db22794657ccb374672d66e20d73839e/numpy_typing_compat-20251206.2.4-py3-none-any.whl", hash = "sha256:a82e723bd20efaa4cf2886709d4264c144f1f2b609bda83d1545113b7e47a5b5", size = 6300, upload-time = "2025-12-06T20:01:57.578Z" }, ] [[package]] @@ -3720,59 +4195,59 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, + { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, ] [[package]] name = "opentelemetry-distro" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/00/1f8acc51326956a596fefaf67751380001af36029132a7a07d4debce3c06/opentelemetry_distro-0.61b0.tar.gz", hash = "sha256:975b845f50181ad53753becf4fd4b123b54fa04df5a9d78812264436d6518981", size = 2590, upload-time = "2026-03-04T14:20:12.453Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/c6/52b0dbcc8fbdecf179047921940516cbb8aaf05f6b737faa526ad76fec51/opentelemetry_distro-0.62b0.tar.gz", hash = "sha256:aa0308fbe50ad8f17d4446982dbf26870e20b8031ba38d8e1224ecf7aedd3184", size = 2611, upload-time = "2026-04-09T14:40:20.404Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/2c/efcc995cd7484e6e55b1d26bd7fa6c55ca96bd415ff94310b52c19f330b0/opentelemetry_distro-0.61b0-py3-none-any.whl", hash = "sha256:f21d1ac0627549795d75e332006dd068877f00e461b1b2e8fe4568d6eb7b9590", size = 3349, upload-time = "2026-03-04T14:18:57.788Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7e/5858bba1c7ed880c7b0fe7d9a1ea40ab8affd18c9ebc1e16c2d69c501da1/opentelemetry_distro-0.62b0-py3-none-any.whl", hash = "sha256:23e9065a35cef12868ad5efb18ce9c88a9103800256b318dec4c9c850c6c78c1", size = 3348, upload-time = "2026-04-09T14:39:17.406Z" }, ] [[package]] name = "opentelemetry-exporter-otlp" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/37/b6708e0eff5c5fb9aba2e0ea09f7f3bcbfd12a592d2a780241b5f6014df7/opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a", size = 6152, upload-time = "2026-03-04T14:17:23.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/b7/845565a2ab5d22c1486bc7729a06b05cd0964c61539d766e1f107c9eea0c/opentelemetry_exporter_otlp-1.41.0.tar.gz", hash = "sha256:97ff847321f8d4c919032a67d20d3137fb7b34eac0c47f13f71112858927fc5b", size = 6152, upload-time = "2026-04-09T14:38:35.895Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/fc/aea77c28d9f3ffef2fdafdc3f4a235aee4091d262ddabd25882f47ce5c5f/opentelemetry_exporter_otlp-1.40.0-py3-none-any.whl", hash = "sha256:48c87e539ec9afb30dc443775a1334cc5487de2f72a770a4c00b1610bf6c697d", size = 7023, upload-time = "2026-03-04T14:17:03.612Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/f1076fff152858773f22cda146713f9ae3661795af6bacd411a76f2151ac/opentelemetry_exporter_otlp-1.41.0-py3-none-any.whl", hash = "sha256:443b6a45c990ae4c55e147f97049a86c5f5b704f3d78b48b44a073a886ec4d6e", size = 7022, upload-time = "2026-04-09T14:38:13.934Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -3783,14 +4258,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/46/d75a3f8c91915f2e58f61d0a2e4ada63891e7c7a37a20ff7949ba184a6b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0.tar.gz", hash = "sha256:f704201251c6f65772b11bddea1c948000554459101bdbb0116e0a01b70592f6", size = 25754, upload-time = "2026-04-09T14:38:37.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/81/f6/b09e2e0c9f0b5750cebc6eaf31527b910821453cef40a5a0fe93550422b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0-py3-none-any.whl", hash = "sha256:3a1a86bd24806ccf136ec9737dbfa4c09b069f9130ff66b0acb014f9c5255fd1", size = 20299, upload-time = "2026-04-09T14:38:17.01Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -3801,14 +4276,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3816,14 +4291,14 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/fd/b8e90bb340957f059084376f94cff336b0e871a42feba7d3f7342365e987/opentelemetry_instrumentation-0.62b0.tar.gz", hash = "sha256:aa1b0b9ab2e1722c2a8a5384fb016fc28d30bba51826676c8036074790d2861e", size = 34042, upload-time = "2026-04-09T14:40:22.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/3356d2e335e3c449c5183e9b023f30f04f1b7073a6583c68745ea2e704b1/opentelemetry_instrumentation-0.62b0-py3-none-any.whl", hash = "sha256:30d4e76486eae64fb095264a70c2c809c4bed17b73373e53091470661f7d477c", size = 34158, upload-time = "2026-04-09T14:39:21.428Z" }, ] [[package]] name = "opentelemetry-instrumentation-asgi" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, @@ -3832,28 +4307,28 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/3e/143cf5c034e58037307e6a24f06e0dd64b2c49ae60a965fc580027581931/opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5", size = 26691, upload-time = "2026-03-04T14:20:21.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/38/999bf777774878971c2716de4b7a03cd57a7decb4af25090e703b79fa0e5/opentelemetry_instrumentation_asgi-0.62b0.tar.gz", hash = "sha256:93cde8c62e5918a3c1ff9ba020518127300e5e0816b7e8b14baf46a26ba619fc", size = 26779, upload-time = "2026-04-09T14:40:26.566Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/78/154470cf9d741a7487fbb5067357b87386475bbb77948a6707cae982e158/opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4", size = 16980, upload-time = "2026-03-04T14:19:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/25/cf/29df82f5870178143bdb5c9a7be044b9f78c71e1c5dcf995242e86d80158/opentelemetry_instrumentation_asgi-0.62b0-py3-none-any.whl", hash = "sha256:89b62a6f996b260b162f515c25e6d78e39286e4cbe2f935899e51b32f31027e2", size = 17011, upload-time = "2026-04-09T14:39:27.305Z" }, ] [[package]] name = "opentelemetry-instrumentation-celery" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/43/e79108a804d16b1dc8ff28edd0e94ac393cf6359a5adcd7cdd2ec4be85f4/opentelemetry_instrumentation_celery-0.61b0.tar.gz", hash = "sha256:0e352a567dc89ed8bc083fc635035ce3c5b96bbbd92831ffd676e93b87f8e94f", size = 14780, upload-time = "2026-03-04T14:20:27.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/b4/20a3c8c669dc45aa3703c0370041d67e8be613f1829523cdaf634a5f9626/opentelemetry_instrumentation_celery-0.62b0.tar.gz", hash = "sha256:55e8fa48e5b886bcca448fa32e28a6cc2165157745e8328de479a826d3903095", size = 14808, upload-time = "2026-04-09T14:40:31.603Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/ed/c05f3c84b455654eb6c047474ffde61ed92efc24030f64213c98bca9d44b/opentelemetry_instrumentation_celery-0.61b0-py3-none-any.whl", hash = "sha256:01235733ff0cdf571cb03b270645abb14b9c8d830313dc5842097ec90146320b", size = 13856, upload-time = "2026-03-04T14:19:20.98Z" }, + { url = "https://files.pythonhosted.org/packages/f6/60/cf951e6bd6ec62ec55bd2384e0ba9841ea38f2d128c773d85dc60da97172/opentelemetry_instrumentation_celery-0.62b0-py3-none-any.whl", hash = "sha256:cadfd3e65287a36099dce5ba7e05d98e4c5f9479a455241e01d140ecc5c10935", size = 13864, upload-time = "2026-04-09T14:39:35.009Z" }, ] [[package]] name = "opentelemetry-instrumentation-fastapi" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3862,14 +4337,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/35/aa727bb6e6ef930dcdc96a617b83748fece57b43c47d83ba8d83fbeca657/opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f", size = 24800, upload-time = "2026-03-04T14:20:32.759Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/09/92740c6d114d1bef392557a03ae6de64065c83c1b331dae9b57fe718497c/opentelemetry_instrumentation_fastapi-0.62b0.tar.gz", hash = "sha256:e4748e4e575077e08beaf2c5d2f369da63dd90882d89d73c4192a97356637dec", size = 25056, upload-time = "2026-04-09T14:40:36.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/05/acfeb2cccd434242a0a7d0ea29afaf077e04b42b35b485d89aee4e0d9340/opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c", size = 13485, upload-time = "2026-03-04T14:19:30.351Z" }, + { url = "https://files.pythonhosted.org/packages/64/bb/186ffe0fde0ad33ceb50e1d3596cc849b732d3b825592a6a507a40c8c49b/opentelemetry_instrumentation_fastapi-0.62b0-py3-none-any.whl", hash = "sha256:06d3272ad15f9daea5a0a27c32831aff376110a4b0394197120256ef6d610e6e", size = 13482, upload-time = "2026-04-09T14:39:43.446Z" }, ] [[package]] name = "opentelemetry-instrumentation-flask" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3879,14 +4354,14 @@ dependencies = [ { name = "opentelemetry-util-http" }, { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/33/d6852d8f2c3eef86f2f8c858d6f5315983c7063e07e595519e96d4c31c06/opentelemetry_instrumentation_flask-0.61b0.tar.gz", hash = "sha256:e9faf58dfd9860a1868442d180142645abdafc1a652dd73d469a5efd106a7d49", size = 24071, upload-time = "2026-03-04T14:20:33.437Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/86/522294f6a80d59560d8f722da59513d2ed2d53c6178fa109789dacc5dd50/opentelemetry_instrumentation_flask-0.62b0.tar.gz", hash = "sha256:330e903c0e92b06aae32f9eb7b8a923599d7a29440f50841a59dbba34ec6dd9f", size = 24100, upload-time = "2026-04-09T14:40:37.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/41/619f3530324a58491f2d20f216a10dd7393629b29db4610dda642a27f4ed/opentelemetry_instrumentation_flask-0.61b0-py3-none-any.whl", hash = "sha256:e8ce474d7ce543bfbbb3e93f8a6f8263348af9d7b45502f387420cf3afa71253", size = 15996, upload-time = "2026-03-04T14:19:31.304Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c8/9f3bb38281bcb50c93c3d2358b303645f6917bf972c167484c09f9a97ff1/opentelemetry_instrumentation_flask-0.62b0-py3-none-any.whl", hash = "sha256:8c1f8986ec3887d08899d2eb654625252c929105174911b3b50dcf12b1001807", size = 16006, upload-time = "2026-04-09T14:39:44.401Z" }, ] [[package]] name = "opentelemetry-instrumentation-httpx" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3895,14 +4370,14 @@ dependencies = [ { name = "opentelemetry-util-http" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/2a/e2becd55e33c29d1d9ef76e2579040ed1951cb33bacba259f6aff2fdd2a6/opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd", size = 24104, upload-time = "2026-03-04T14:20:34.752Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/a7/63e2c6325c8e99cd9b8e0229a8b61c37520ee537214a2c8d514e84486a94/opentelemetry_instrumentation_httpx-0.62b0.tar.gz", hash = "sha256:d865398db3f3c289ba226e355bf4d94460a4301c0c8916e3136caea55ae18000", size = 24182, upload-time = "2026-04-09T14:40:38.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/88/dde310dce56e2d85cf1a09507f5888544955309edc4b8d22971d6d3d1417/opentelemetry_instrumentation_httpx-0.61b0-py3-none-any.whl", hash = "sha256:dee05c93a6593a5dc3ae5d9d5c01df8b4e2c5d02e49275e5558534ee46343d5e", size = 17198, upload-time = "2026-03-04T14:19:33.585Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/7d5fc28487637871b015128cd5dbb3c36f6d343a9098b893bd803d5a9cca/opentelemetry_instrumentation_httpx-0.62b0-py3-none-any.whl", hash = "sha256:c7660b939c12608fec67743126e9b4dc23dceef0ed631c415924966b0d1579e3", size = 17200, upload-time = "2026-04-09T14:39:46.618Z" }, ] [[package]] name = "opentelemetry-instrumentation-redis" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3910,14 +4385,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/21/26205f89358a5f2be3ee5512d3d3bce16b622977f64aeaa9d3fa8887dd39/opentelemetry_instrumentation_redis-0.61b0.tar.gz", hash = "sha256:ae0fbb56be9a641e621d55b02a7d62977a2c77c5ee760addd79b9b266e46e523", size = 14781, upload-time = "2026-03-04T14:20:45.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5acdb4e4e36c522f9393cfa91f7a431ee089663c77855e524bc97f993020/opentelemetry_instrumentation_redis-0.62b0.tar.gz", hash = "sha256:513bc6679ee251436f0aff7be7ddab6186637dde09a795a8dc9659103f103bef", size = 14796, upload-time = "2026-04-09T14:40:48.391Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/e1/8f4c8e4194291dbe828aeabe779050a8497b379ad90040a5a0a7074b1d08/opentelemetry_instrumentation_redis-0.61b0-py3-none-any.whl", hash = "sha256:8d4e850bbb5f8eeafa44c0eac3a007990c7125de187bc9c3659e29ff7e091172", size = 15506, upload-time = "2026-03-04T14:19:48.588Z" }, + { url = "https://files.pythonhosted.org/packages/de/42/a13a7da074c972a51c14277e7f747e90037b9d815515c73b802e95897690/opentelemetry_instrumentation_redis-0.62b0-py3-none-any.whl", hash = "sha256:92ada3d7bdf395785f660549b0e6e8e5bac7cab80e7f1369a7d02228b27684c3", size = 15501, upload-time = "2026-04-09T14:40:00.69Z" }, ] [[package]] name = "opentelemetry-instrumentation-sqlalchemy" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3926,14 +4401,14 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/4f/3a325b180944610697a0a926d49d782b41a86120050d44fefb2715b630ac/opentelemetry_instrumentation_sqlalchemy-0.61b0.tar.gz", hash = "sha256:13a3a159a2043a52f0180b3757fbaa26741b0e08abb50deddce4394c118956e6", size = 15343, upload-time = "2026-03-04T14:20:47.648Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/3d/40adc8c38e5be017ceb230a28ca57ca81981d4dc0c4b902cc930c77fd14f/opentelemetry_instrumentation_sqlalchemy-0.62b0.tar.gz", hash = "sha256:d02f85b83f349e9ef70a34cb3f4c3a3481fa15b11747f09209818663e161cac4", size = 18539, upload-time = "2026-04-09T14:40:50.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/97/b906a930c6a1a20c53ecc8b58cabc2cdd0ce560a2b5d44259084ffe4333e/opentelemetry_instrumentation_sqlalchemy-0.61b0-py3-none-any.whl", hash = "sha256:f115e0be54116ba4c327b8d7b68db4045ee18d44439d888ab8130a549c50d1c1", size = 14547, upload-time = "2026-03-04T14:19:53.088Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e0/77954ac593f34740dc32e28a15fe7170e90f6ba6398eaaa5c88b34c05ed1/opentelemetry_instrumentation_sqlalchemy-0.62b0-py3-none-any.whl", hash = "sha256:ec576e0660080d9d15ce4fa44d2a07fff8cb4b796a84344cb0f2c9e5d6e26f79", size = 15534, upload-time = "2026-04-09T14:40:03.957Z" }, ] [[package]] name = "opentelemetry-instrumentation-wsgi" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3941,75 +4416,75 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/e5/189f2845362cfe78e356ba127eab21456309def411c6874aa4800c3de816/opentelemetry_instrumentation_wsgi-0.61b0.tar.gz", hash = "sha256:380f2ae61714e5303275a80b2e14c58571573cd1fddf496d8c39fb9551c5e532", size = 19898, upload-time = "2026-03-04T14:20:54.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/5c/ed45ff053d76c94c59173f2bcde3d61052adb10214f70f028f760aa56625/opentelemetry_instrumentation_wsgi-0.62b0.tar.gz", hash = "sha256:d179f969ecce0c29a15ffd4d982580dfae57c8ff2fd4d9366e299a6d4815e668", size = 19922, upload-time = "2026-04-09T14:40:56.227Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/75/d6b42ba26f3c921be6d01b16561b7bb863f843bad7ac3a5011f62617bcab/opentelemetry_instrumentation_wsgi-0.61b0-py3-none-any.whl", hash = "sha256:bd33b0824166f24134a3400648805e8d2e6a7951f070241294e8b8866611d7fa", size = 14628, upload-time = "2026-03-04T14:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/cb/753dbbe624df88594fa35a3ff26302fea22623385ed64462f6c8ee7c81eb/opentelemetry_instrumentation_wsgi-0.62b0-py3-none-any.whl", hash = "sha256:2714ab5ab2f35e67dc181ffa3a43fa15313c85c09b4d024c36d72cf1efa29c9a", size = 14628, upload-time = "2026-04-09T14:40:13.529Z" }, ] [[package]] name = "opentelemetry-propagator-b3" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fe/e0c84af5c654ec42165ba57af83c7f67e4b8af77f836ddc29dee59ff73c6/opentelemetry_propagator_b3-1.40.0.tar.gz", hash = "sha256:59b6925498947c08a1b7e0dd38193ff97e5009bec74ec23824300c2e32f77bcf", size = 9587, upload-time = "2026-03-04T14:17:30.079Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/43/cea77e171c014324876104cf2a17c78f5e931408b977b9e64979f950912c/opentelemetry_propagator_b3-1.41.0.tar.gz", hash = "sha256:ef98b715b3a05e8b0b03ebaea1bf295b4ad61a0e306e2d1da81d32af7395e6ad", size = 9588, upload-time = "2026-04-09T14:38:43.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/84/8654cc0539b5145046b2e60d058cebad401a600dd0b1240f1711c6788643/opentelemetry_propagator_b3-1.40.0-py3-none-any.whl", hash = "sha256:cb72a1698fd1d1b434f70dc90c1de62da8ade1dd84850d1f040eccf6a420fa7b", size = 8922, upload-time = "2026-03-04T14:17:14.732Z" }, + { url = "https://files.pythonhosted.org/packages/50/c1/11345c06774ec6ed6d89e3994dd1f62ad2ab41dfeb312eacd6b2a2323280/opentelemetry_propagator_b3-1.41.0-py3-none-any.whl", hash = "sha256:0b085c26ba59fcb66771226f967e91886bdeef998b3b5f2e9da6a604918c6f90", size = 8923, upload-time = "2026-04-09T14:38:26.865Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, + { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" }, ] [[package]] name = "opentelemetry-util-http" -version = "0.61b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/3c/f0196223efc5c4ca19f8fad3d5462b171ac6333013335ce540c01af419e9/opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572", size = 11361, upload-time = "2026-03-04T14:20:57.01Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/830f7c57135158eb8a8efd3f94ab191a89e3b8a49bed314a35ee501da3f2/opentelemetry_util_http-0.62b0.tar.gz", hash = "sha256:a62e4b19b8a432c0de657f167dee3455516136bb9c6ed463ca8063019970d835", size = 11393, upload-time = "2026-04-09T14:40:59.442Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/e5/c08aaaf2f64288d2b6ef65741d2de5454e64af3e050f34285fb1907492fe/opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33", size = 9281, upload-time = "2026-03-04T14:20:08.364Z" }, + { url = "https://files.pythonhosted.org/packages/3d/7f/5c1b7d4385852b9e5eacd4e7f9d8b565d3d351d17463b24916ad098adf1a/opentelemetry_util_http-0.62b0-py3-none-any.whl", hash = "sha256:c20462808d8cc95b69b0dc4a3e02a9d36beb663347e96c931f51ffd78bd318ad", size = 9294, upload-time = "2026-04-09T14:40:19.014Z" }, ] [[package]] name = "opik" -version = "1.10.58" +version = "1.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3-stubs", extra = ["bedrock-runtime"] }, @@ -4027,22 +4502,23 @@ dependencies = [ { name = "tenacity" }, { name = "tqdm" }, { name = "uuid6" }, + { name = "watchfiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/bc/54673138cf374226ab9fcdd5685e92442c0d5a95775ff22b870c767387e6/opik-1.10.58.tar.gz", hash = "sha256:058f8b3e3171a1f5e75f25cf1fea392b8f2e0ddba18765fafd24cd756783002b", size = 833671, upload-time = "2026-04-01T11:43:21.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/b9/f6c7e41cb6c02f6e68fde9b6dacf377dcf42079cdbaf891f9fecf4dc958b/opik-1.11.2.tar.gz", hash = "sha256:79e054595b29e1ca8a4fd67d023249f0cf355ea9efbe3e00c28f51628d053d63", size = 871557, upload-time = "2026-04-10T10:48:14.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/9a/99cf048209f10f8444544202b007d5fbe0a6104465d29038b25932b1c79f/opik-1.10.58-py3-none-any.whl", hash = "sha256:29be9d7f846f3229a027250997195e583da840179ad03f3d28b1d613687963e3", size = 1400658, upload-time = "2026-04-01T11:43:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/99/2d/e5536a2a1b6fdd920d995e09315523be53bde5fe01f104894d9ba7421a8c/opik-1.11.2-py3-none-any.whl", hash = "sha256:1016b6db7563d847e50e463a2ae09e595b6921372dd52edeada660b82036e1b2", size = 1451056, upload-time = "2026-04-10T10:48:12.927Z" }, ] [[package]] name = "optype" -version = "0.14.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/ca/d3a2abcf12cc8c18ccac1178ef87ab50a235bf386d2401341776fdad18aa/optype-0.14.0.tar.gz", hash = "sha256:925cf060b7d1337647f880401f6094321e7d8e837533b8e159b9a92afa3157c6", size = 100880, upload-time = "2025-10-01T04:49:56.232Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9f/3b13bab05debf685678b8af004e46b8c67c6f98ffa08eaf5d33bcf162c16/optype-0.17.0.tar.gz", hash = "sha256:31351a1e64d9eba7bf67e14deefb286e85c66458db63c67dd5e26dd72e4664e5", size = 53484, upload-time = "2026-03-08T23:03:12.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a6/11b0eb65eeafa87260d36858b69ec4e0072d09e37ea6714280960030bc93/optype-0.14.0-py3-none-any.whl", hash = "sha256:50d02edafd04edf2e5e27d6249760a51b2198adb9f6ffd778030b3d2806b026b", size = 89465, upload-time = "2025-10-01T04:49:54.674Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/dca78187415947d1bb90b2ee2a58e47d9573528331e8dc6196996b53612a/optype-0.17.0-py3-none-any.whl", hash = "sha256:8c2d88ff13149454bcf6eb47502f80d288bc542e7238fcc412ac4d222c439397", size = 65854, upload-time = "2026-03-08T23:03:11.425Z" }, ] [package.optional-dependencies] @@ -4116,32 +4592,32 @@ wheels = [ [[package]] name = "packaging" -version = "23.2" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/2b/9b9c33ffed44ee921d0967086d653047286054117d584f1b1a7c22ceaf7b/packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", size = 146714, upload-time = "2023-10-01T13:50:05.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7", size = 53011, upload-time = "2023-10-01T13:50:03.745Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "pandas" -version = "3.0.1" +version = "3.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, - { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, - { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, - { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, - { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, + { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, ] [package.optional-dependencies] @@ -4229,21 +4705,21 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, ] [[package]] @@ -4266,13 +4742,14 @@ wheels = [ [[package]] name = "polyfile-weave" -version = "0.5.8" +version = "0.5.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "abnf" }, { name = "chardet" }, { name = "cint" }, { name = "fickling" }, + { name = "filelock" }, { name = "graphviz" }, { name = "intervaltree" }, { name = "jinja2" }, @@ -4282,11 +4759,10 @@ dependencies = [ { name = "pillow" }, { name = "pyreadline3", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, - { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/d4/76e56e4429646d9353b4287794f8324ff94201bdb0a2c35ce88cf3de90d0/polyfile_weave-0.5.8.tar.gz", hash = "sha256:cf2ca6a1351165fbbf2971ace4b8bebbb03b2c00e4f2159ff29bed88854e7b32", size = 5989602, upload-time = "2026-01-08T04:21:26.689Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/55/e5400762e3884f743d59291e71eaaa9c52dd7e144b75a11911e74ec1bac9/polyfile_weave-0.5.9.tar.gz", hash = "sha256:12341fab03e06ede1bfebbd3627dd24015fde5353ea74ece2da186321b818bdb", size = 6024974, upload-time = "2026-01-22T22:08:48.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/32/c09fd626366c00325d1981e310be5cac8661c09206098d267a592e0c5000/polyfile_weave-0.5.8-py3-none-any.whl", hash = "sha256:f68c570ef189a4219798a7c797730fc3b7feace7ff5bd7e662490f89b772964a", size = 1656208, upload-time = "2026-01-08T04:21:15.213Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/215005530a48c5f7d4ec4a31acdb5828f2bfb985cc6e577b0eaa5882c0e2/polyfile_weave-0.5.9-py3-none-any.whl", hash = "sha256:6ae4b1b5eeac9f5bfc862474484d6d3e33655fab31749d93af0b0a91fddabfc7", size = 1700174, upload-time = "2026-01-22T22:08:46.346Z" }, ] [[package]] @@ -4511,20 +4987,17 @@ wheels = [ [[package]] name = "pyarrow" -version = "14.0.2" +version = "23.0.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d7/8b/d18b7eb6fb22e5ed6ffcbc073c85dae635778dbd1270a6cf5d750b031e84/pyarrow-14.0.2.tar.gz", hash = "sha256:36cef6ba12b499d864d1def3e990f97949e0b79400d08b7cf74504ffbd3eb025", size = 1063645, upload-time = "2023-12-18T15:43:41.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/5b/d8ab6c20c43b598228710e4e4a6cba03a01f6faa3d08afff9ce76fd0fd47/pyarrow-14.0.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:c87824a5ac52be210d32906c715f4ed7053d0180c1060ae3ff9b7e560f53f944", size = 26819585, upload-time = "2023-12-18T15:41:27.59Z" }, - { url = "https://files.pythonhosted.org/packages/2d/29/bed2643d0dd5e9570405244a61f6db66c7f4704a6e9ce313f84fa5a3675a/pyarrow-14.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a25eb2421a58e861f6ca91f43339d215476f4fe159eca603c55950c14f378cc5", size = 23965222, upload-time = "2023-12-18T15:41:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/2a/34/da464632e59a8cdd083370d69e6c14eae30221acb284f671c6bc9273fadd/pyarrow-14.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c1da70d668af5620b8ba0a23f229030a4cd6c5f24a616a146f30d2386fec422", size = 35942036, upload-time = "2023-12-18T15:41:38.767Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ff/cbed4836d543b29f00d2355af67575c934999ff1d43e3f438ab0b1b394f1/pyarrow-14.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cc61593c8e66194c7cdfae594503e91b926a228fba40b5cf25cc593563bcd07", size = 38089266, upload-time = "2023-12-18T15:41:47.617Z" }, - { url = "https://files.pythonhosted.org/packages/38/41/345011cb831d3dbb2dab762fc244c745a5df94b199223a99af52a5f7dff6/pyarrow-14.0.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:78ea56f62fb7c0ae8ecb9afdd7893e3a7dbeb0b04106f5c08dbb23f9c0157591", size = 35404468, upload-time = "2023-12-18T15:41:54.49Z" }, - { url = "https://files.pythonhosted.org/packages/fd/af/2fc23ca2068ff02068d8dabf0fb85b6185df40ec825973470e613dbd8790/pyarrow-14.0.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:37c233ddbce0c67a76c0985612fef27c0c92aef9413cf5aa56952f359fcb7379", size = 38003134, upload-time = "2023-12-18T15:42:01.593Z" }, - { url = "https://files.pythonhosted.org/packages/95/1f/9d912f66a87e3864f694e000977a6a70a644ea560289eac1d733983f215d/pyarrow-14.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:e4b123ad0f6add92de898214d404e488167b87b5dd86e9a434126bc2b7a5578d", size = 25043754, upload-time = "2023-12-18T15:42:07.108Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, ] [[package]] @@ -4658,11 +5131,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -4672,7 +5145,7 @@ crypto = [ [[package]] name = "pymilvus" -version = "2.6.11" +version = "2.6.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -4684,9 +5157,9 @@ dependencies = [ { name = "requests" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/e6/0adc3b374f5c5d1eebd4f551b455c6865c449b170b17545001b208e2b153/pymilvus-2.6.11.tar.gz", hash = "sha256:a40c10322cde25184a8c3d84993a14dfb67ad2bdcfc5dff7e68b11a79ff8f6d8", size = 1583634, upload-time = "2026-03-27T06:25:46.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/d7/c5d1381248a33975ccc864a0f980f93270ecc35354de8646c8a16443cccb/pymilvus-2.6.12.tar.gz", hash = "sha256:8323e990dc305e607fef525498eb779e42940a69e0691dde009cd02d48845f7a", size = 1584521, upload-time = "2026-04-09T07:49:11.374Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/1c/bccb331d71f824738f80f11e9b8b4da47973c903826355526ae4fa2b762f/pymilvus-2.6.11-py3-none-any.whl", hash = "sha256:a11e1718b15045361c71ca671b959900cb7e2faae863c896f6b7e87bf2e4d10a", size = 315252, upload-time = "2026-03-27T06:25:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/44b0fa94c91503381e6f12298277f84f8e7b0bb00715ab89fc273c4d681e/pymilvus-2.6.12-py3-none-any.whl", hash = "sha256:69051b8b62712f157b2b50aeb7bde7fd7cdb5940aac0122094eb3cd58bc20f0d", size = 315183, upload-time = "2026-04-09T07:49:09.013Z" }, ] [[package]] @@ -4763,11 +5236,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.9.2" +version = "6.10.0" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" }, ] [[package]] @@ -4825,24 +5298,24 @@ wheels = [ [[package]] name = "pyrefly" -version = "0.59.1" +version = "0.60.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/ce/7882c2af92b2ff6505fcd3430eff8048ece6c6254cc90bdc76ecee12dfab/pyrefly-0.59.1.tar.gz", hash = "sha256:bf1675b0c38d45df2c8f8618cbdfa261a1b92430d9d31eba16e0282b551e210f", size = 5475432, upload-time = "2026-04-01T22:04:04.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/c7/28d14b64888e2d03815627ebff8d57a9f08389c4bbebfe70ae1ed98a1267/pyrefly-0.60.0.tar.gz", hash = "sha256:2499f5b6ff5342e86dfe1cd94bcce133519bbbc93b7ad5636195fea4f0fa3b81", size = 5500389, upload-time = "2026-04-06T19:57:30.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/10/04a0e05b08fc855b6fe38c3df549925fc3c2c6e750506870de7335d3e1f7/pyrefly-0.59.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:390db3cd14aa7e0268e847b60cd9ee18b04273eddfa38cf341ed3bb43f3fef2a", size = 12868133, upload-time = "2026-04-01T22:03:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/fa7be227c3e3fcacee501c1562278dd026186ffd1b5b5beb51d3941a3aed/pyrefly-0.59.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d246d417b6187c1650d7f855f61c68fbfd6d6155dc846d4e4d273a3e6b5175cb", size = 12379325, upload-time = "2026-04-01T22:03:42.046Z" }, - { url = "https://files.pythonhosted.org/packages/bb/13/6828ce1c98171b5f8388f33c4b0b9ea2ab8c49abe0ef8d793c31e30a05cb/pyrefly-0.59.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:575ac67b04412dc651a7143d27e38a40fbdd3c831c714d5520d0e9d4c8631ab4", size = 35826408, upload-time = "2026-04-01T22:03:45.067Z" }, - { url = "https://files.pythonhosted.org/packages/23/56/79ed8ece9a7ecad0113c394a06a084107db3ad8f1fefe19e7ded43c51245/pyrefly-0.59.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:062e6262ce1064d59dcad81ac0499bb7a3ad501e9bc8a677a50dc630ff0bf862", size = 38532699, upload-time = "2026-04-01T22:03:48.376Z" }, - { url = "https://files.pythonhosted.org/packages/18/7d/ecc025e0f0e3f295b497f523cc19cefaa39e57abede8fc353d29445d174b/pyrefly-0.59.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ef4247f9e6f734feb93e1f2b75335b943629956e509f545cc9cdcccd76dd20", size = 36743570, upload-time = "2026-04-01T22:03:51.362Z" }, - { url = "https://files.pythonhosted.org/packages/2f/03/b1ce882ebcb87c673165c00451fbe4df17bf96ccfde18c75880dc87c5f5e/pyrefly-0.59.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a2d01723b84d042f4fa6ec871ffd52d0a7e83b0ea791c2e0bb0ff750abce56", size = 41236246, upload-time = "2026-04-01T22:03:54.361Z" }, - { url = "https://files.pythonhosted.org/packages/17/af/5e9c7afd510e7dd64a2204be0ed39e804089cbc4338675a28615c7176acb/pyrefly-0.59.1-py3-none-win32.whl", hash = "sha256:4ea70c780848f8376411e787643ae5d2d09da8a829362332b7b26d15ebcbaf56", size = 11884747, upload-time = "2026-04-01T22:03:56.776Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c1/7db1077627453fd1068f0761f059a9512645c00c4c20acfb9f0c24ac02ec/pyrefly-0.59.1-py3-none-win_amd64.whl", hash = "sha256:67e6a08cfd129a0d2788d5e40a627f9860e0fe91a876238d93d5c63ff4af68ae", size = 12720608, upload-time = "2026-04-01T22:03:59.252Z" }, - { url = "https://files.pythonhosted.org/packages/07/16/4bb6e5fce5a9cf0992932d9435d964c33e507aaaf96fdfbb1be493078a4a/pyrefly-0.59.1-py3-none-win_arm64.whl", hash = "sha256:01179cb215cf079e8223a064f61a074f7079aa97ea705cbbc68af3d6713afd15", size = 12223158, upload-time = "2026-04-01T22:04:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/31/99/6c9984a09220e5eb7dd5c869b7a32d25c3d06b5e8854c6eb679db1145c3e/pyrefly-0.60.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bf1691af0fee69d0c99c3c6e9d26ab6acd3c8afef96416f9ba2e74934833b7b5", size = 12921262, upload-time = "2026-04-06T19:57:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/b3/6216aa3c00c88e59a27eb4149851b5affe86eeea6129f4224034a32dddb0/pyrefly-0.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3e71b70c9b95545cf3b479bc55d1381b531de7b2380eb64411088a1e56b634cb", size = 12424413, upload-time = "2026-04-06T19:57:03.417Z" }, + { url = "https://files.pythonhosted.org/packages/9b/87/eb8dd73abd92a93952ac27a605e463c432fb250fb23186574038c7035594/pyrefly-0.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:680ee5f8f98230ea145652d7344708f5375786209c5bf03d8b911fdb0d0d4195", size = 35940884, upload-time = "2026-04-06T19:57:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/0d/34/dc6aeb67b840c745fcee6db358295d554abe6ab555a7eaaf44624bd80bf1/pyrefly-0.60.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d0b20dbbe4aff15b959e8d825b7521a144c4122c11e57022e83b36568c54470", size = 38677220, upload-time = "2026-04-06T19:57:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/66/6b/c863fcf7ef592b7d1db91502acf0d1113be8bed7a2a7143fc6f0dd90616f/pyrefly-0.60.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2911563c8e6b2eaefff68885c94727965469a35375a409235a7a4d2b7157dc15", size = 36907431, upload-time = "2026-04-06T19:57:15.074Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/25ea095ab2ecca8e62884669b11a79f14299db93071685b73a97efbaf4f3/pyrefly-0.60.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0a631d9d04705e303fe156f2e62551611bc7ef8066c34708ceebcfb3088bd55", size = 41447898, upload-time = "2026-04-06T19:57:19.382Z" }, + { url = "https://files.pythonhosted.org/packages/8e/2c/097bdc6e8d40676b28eb03710a4577bc3c7b803cd24693ac02bf15de3d67/pyrefly-0.60.0-py3-none-win32.whl", hash = "sha256:a08d69298da5626cf502d3debbb6944fd13d2f405ea6625363751f1ff570d366", size = 11913434, upload-time = "2026-04-06T19:57:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d4/8d27fe310e830c8d11ab73db38b93f9fd2e218744b6efb1204401c9a74d5/pyrefly-0.60.0-py3-none-win_amd64.whl", hash = "sha256:56cf30654e708ae1dd635ffefcba4fa4b349dd7004a6ccc5c41e3a9bb944320c", size = 12745033, upload-time = "2026-04-06T19:57:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ad/8eea1f8fb8209f91f6dbfe48000c9d05fd0cdb1b5b3157283c9b1dada55d/pyrefly-0.60.0-py3-none-win_arm64.whl", hash = "sha256:b6d27fba970f4777063c0227c54167d83bece1804ea34f69e7118e409ba038d2", size = 12246390, upload-time = "2026-04-06T19:57:28.141Z" }, ] [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -4851,9 +5324,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -5248,15 +5721,15 @@ wheels = [ [[package]] name = "resend" -version = "2.26.0" +version = "2.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/ff/6a4e5e758fc2145c6a7d8563934d8ee24bf96a0212d7ec7d1af1f155bb74/resend-2.26.0.tar.gz", hash = "sha256:957a6a59dc597ce27fbd6d5383220dd9cc497fab99d4f3d775c8a42a449a569e", size = 36238, upload-time = "2026-03-20T22:49:09.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/da/3d342cacbde7143e36782243caa3715d9e49cadb43e804419493c784869b/resend-2.27.0.tar.gz", hash = "sha256:abc183da7566c1fdba8221ec5acd9f954c2ff516a0c2615bee2a41bc9db3e277", size = 37177, upload-time = "2026-04-01T21:19:31.823Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/c2/f88d3299d97aa1d36a923d0846fe185fcf5355ca898c954b2e5a79f090b5/resend-2.26.0-py2.py3-none-any.whl", hash = "sha256:5e25a804a84a68df504f2ade5369ac37e0139e37788a1f20b66c88696595b4bc", size = 57699, upload-time = "2026-03-20T22:49:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/95/783b09d24c8f40b900a2728b67fd3c1401d4a6afcdf1db1c8475c249559d/resend-2.27.0-py2.py3-none-any.whl", hash = "sha256:5bc8ddebb0418127fc3e47eb29ab72af727861481c4b051b96cb693df8f8dc40", size = 59831, upload-time = "2026-04-01T21:19:30.471Z" }, ] [[package]] @@ -5310,27 +5783,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.9" +version = "0.15.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, - { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, - { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, - { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, - { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, - { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, - { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, - { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, - { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] [[package]] @@ -5381,29 +5854,29 @@ wheels = [ [[package]] name = "sendgrid" -version = "6.12.4" +version = "6.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ecdsa" }, + { name = "cryptography" }, { name = "python-http-client" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/31/62e00433878dccf33edf07f8efa417b9030a2464eb3b04bbd797a11b4447/sendgrid-6.12.4.tar.gz", hash = "sha256:9e88b849daf0fa4bdf256c3b5da9f5a3272402c0c2fd6b1928c9de440db0a03d", size = 50271, upload-time = "2025-06-12T10:29:37.213Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/fa/f718b2b953f99c1f0085811598ac7e31ccbd4229a81ec2a5290be868187a/sendgrid-6.12.5.tar.gz", hash = "sha256:ea9aae30cd55c332e266bccd11185159482edfc07c149b6cd15cf08869fabdb7", size = 50310, upload-time = "2025-09-19T06:23:09.229Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/9c/45d068fd831a65e6ed1e2ab3233de58784842afdc62fdcdd0a01bbb6b39d/sendgrid-6.12.4-py3-none-any.whl", hash = "sha256:9a211b96241e63bd5b9ed9afcc8608f4bcac426e4a319b3920ab877c8426e92c", size = 102122, upload-time = "2025-06-12T10:29:35.457Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/b3c3880a77082e8f7374954e0074aafafaa9bc78bdf9c8f5a92c2e7afc6a/sendgrid-6.12.5-py3-none-any.whl", hash = "sha256:96f92cc91634bf552fdb766b904bbb53968018da7ae41fdac4d1090dc0311ca8", size = 102173, upload-time = "2025-09-19T06:23:07.93Z" }, ] [[package]] name = "sentry-sdk" -version = "2.55.0" +version = "2.57.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/87/46c0406d8b5ddd026f73adaf5ab75ce144219c41a4830b52df4b9ab55f7f/sentry_sdk-2.57.0.tar.gz", hash = "sha256:4be8d1e71c32fb27f79c577a337ac8912137bba4bcbc64a4ec1da4d6d8dc5199", size = 435288, upload-time = "2026-03-31T09:39:29.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/c9/64/982e07b93219cb52e1cca5d272cb579e2f3eb001956c9e7a9a6d106c9473/sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585", size = 456489, upload-time = "2026-03-31T09:39:27.524Z" }, ] [package.optional-dependencies] @@ -5709,7 +6182,7 @@ wheels = [ [[package]] name = "tablestore" -version = "6.4.3" +version = "6.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -5722,9 +6195,9 @@ dependencies = [ { name = "six" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/0b/c875c2314d472eed9f9644a94ae0aa7e702a6084779a0136e539d5e7ed32/tablestore-6.4.3.tar.gz", hash = "sha256:4981139e68705052ade6341060a4b6238b1fb9a8c18b43a77383fda14f7554a9", size = 5072450, upload-time = "2026-03-31T04:34:37.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/bc/84d7592188b060950f4fe5713eb3b03068d42b2e43ad37decdb5242c1879/tablestore-6.4.4.tar.gz", hash = "sha256:0f40834030aff0c67e568b09deaab97144229b569710d66557edf7a06a5dcb19", size = 5076731, upload-time = "2026-04-09T09:40:20.399Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e0/e11626aea61e1352dafe7707c548d482769afd3ca28f45653d380ba85a5d/tablestore-6.4.3-py3-none-any.whl", hash = "sha256:207b89324cd4157db4559c7619d42b9510a55c0565f00a439389f14426d114c5", size = 5115764, upload-time = "2026-03-31T04:34:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/45/3f/48af1e72e59d60481724b326317bd311615bdedc31f8f81f9508fb84cda6/tablestore-6.4.4-py3-none-any.whl", hash = "sha256:984f086fa7acabaa3558da93205ad6df562b266b85fd249bc5891f2dd1d65814", size = 5118758, upload-time = "2026-04-09T09:40:17.209Z" }, ] [[package]] @@ -5779,7 +6252,7 @@ wheels = [ [[package]] name = "testcontainers" -version = "4.14.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docker" }, @@ -5788,9 +6261,9 @@ dependencies = [ { name = "urllib3" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/02/ef62dec9e4f804189c44df23f0b86897c738d38e9c48282fcd410308632f/testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64", size = 80148, upload-time = "2026-01-31T23:13:46.915Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/31/5e7b23f9e43ff7fd46d243808d70c5e8daf3bc08ecf5a7fb84d5e38f7603/testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891", size = 125640, upload-time = "2026-01-31T23:13:45.464Z" }, + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, ] [[package]] @@ -5964,11 +6437,11 @@ wheels = [ [[package]] name = "types-aiofiles" -version = "25.1.0.20251011" +version = "25.1.0.20260409" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6c/6d23908a8217e36704aa9c79d99a620f2fdd388b66a4b7f72fbc6b6ff6c6/types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff", size = 14535, upload-time = "2025-10-11T02:44:51.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/66/9e62a2692792bc96c0f423f478149f4a7b84720704c546c8960b0a047c89/types_aiofiles-25.1.0.20260409.tar.gz", hash = "sha256:49e67d72bdcf9fe406f5815758a78dc34a1249bb5aa2adba78a80aec0a775435", size = 14812, upload-time = "2026-04-09T04:22:35.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338, upload-time = "2025-10-11T02:44:50.054Z" }, + { url = "https://files.pythonhosted.org/packages/27/d0/28236f869ba4dfb223ecdbc267eb2bdb634b81a561dd992230a4f9ec48fa/types_aiofiles-25.1.0.20260409-py3-none-any.whl", hash = "sha256:923fedb532c772cc0f62e0ce4282725afa82ca5b41cabd9857f06b55e5eee8de", size = 14372, upload-time = "2026-04-09T04:22:34.328Z" }, ] [[package]] @@ -5994,229 +6467,229 @@ wheels = [ [[package]] name = "types-cachetools" -version = "6.2.0.20260317" +version = "6.2.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/7f/16a4d8344c28193a5a74358028c2d2f753f0d9658dd98b9e1967c50045a2/types_cachetools-6.2.0.20260317.tar.gz", hash = "sha256:6d91855bcc944665897c125e720aa3c80aace929b77a64e796343701df4f61c6", size = 9812, upload-time = "2026-03-17T04:06:32.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/61/475b0e8f4a92e5e33affcc6f4e6344c6dee540824021d22f695ea170da63/types_cachetools-6.2.0.20260408.tar.gz", hash = "sha256:0d8ae2dd5ba0b4cfe6a55c34396dd0415f1be07d0033d84781cdc4ed9c2ebc6b", size = 9854, upload-time = "2026-04-08T04:31:49.665Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/9a/b00b23054934c4d569c19f7278c4fb32746cd36a64a175a216d3073a4713/types_cachetools-6.2.0.20260317-py3-none-any.whl", hash = "sha256:92fa9bc50e4629e31fca67ceb3fb1de71791e314fa16c0a0d2728724dc222c8b", size = 9346, upload-time = "2026-03-17T04:06:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7d/579f50f4f004ee93c7d1baa95339591cac1fe02f4e3fb8fc0f900ee4a80f/types_cachetools-6.2.0.20260408-py3-none-any.whl", hash = "sha256:470e0b274737feae74beed3d764885bf4664002ecc393fba3778846b13ce92cb", size = 9350, upload-time = "2026-04-08T04:31:48.826Z" }, ] [[package]] name = "types-cffi" -version = "2.0.0.20260402" +version = "2.0.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/85/3896bfcb4e7c32904f762c36ff0afa96d3e39bfce5a95a41635af79c8761/types_cffi-2.0.0.20260402.tar.gz", hash = "sha256:47e1320c009f630c59c55c8e3d2b8c501e280babf52e92f6109cbfb0864ba367", size = 17476, upload-time = "2026-04-02T04:21:09.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/67/eb4ef3408fdc0b4e5af38b30c0e6ad4663b41bdae9fb85a9f09a8db61a99/types_cffi-2.0.0.20260408.tar.gz", hash = "sha256:aa8b9c456ab715c079fc655929811f21f331bfb940f4a821987c581bf4e36230", size = 17541, upload-time = "2026-04-08T04:36:03.918Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/26/aacfef05841e31c65f889ae4225c6bce6b84cd5d3882c42a3661030f29ee/types_cffi-2.0.0.20260402-py3-none-any.whl", hash = "sha256:f647a400fba0a31d603479169d82ee5359db79bd1136e41dc7e6489296e3a2b2", size = 20103, upload-time = "2026-04-02T04:21:08.199Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a3/7fbd93ededcc7c77e9e5948b9794161733ebdbf618a27965b1bea0e728a4/types_cffi-2.0.0.20260408-py3-none-any.whl", hash = "sha256:68bd296742b4ff7c0afe3547f50bd0acc55416ecf322ffefd2b7344ef6388a42", size = 20101, upload-time = "2026-04-08T04:36:02.995Z" }, ] [[package]] name = "types-colorama" -version = "0.4.15.20250801" +version = "0.4.15.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/37/af713e7d73ca44738c68814cbacf7a655aa40ddd2e8513d431ba78ace7b3/types_colorama-0.4.15.20250801.tar.gz", hash = "sha256:02565d13d68963d12237d3f330f5ecd622a3179f7b5b14ee7f16146270c357f5", size = 10437, upload-time = "2025-08-01T03:48:22.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/c0/1c02ed9edf3462a392f4ea4bda80fa10c538c63d1d7be255dc7dcb545007/types_colorama-0.4.15.20260408.tar.gz", hash = "sha256:9a816657927489463edec1b7b47933b73fe737d37a3616bf596b7de843441032", size = 10623, upload-time = "2026-04-08T04:28:31.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/d03948be8ae9362ad26f36443eab051fe5524295fe008126cd65792f9833/types_colorama-0.4.15.20260408-py3-none-any.whl", hash = "sha256:7327a51c760d94f7df2e8c72c275a4468c03c3abb606d23995cb37e3d24d9132", size = 10763, upload-time = "2026-04-08T04:28:30.688Z" }, ] [[package]] name = "types-defusedxml" -version = "0.7.0.20260402" +version = "0.7.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/3c/8e1243dda2fef73be93081d896503352fb92e2351b0b17ac172bbdb70ebf/types_defusedxml-0.7.0.20260402.tar.gz", hash = "sha256:4cc91b225e77c7fcf88b3fb7d821a37fb4e14530727c790b6b8a19f2968d6074", size = 10604, upload-time = "2026-04-02T04:19:00.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/af/d324da5ffbf0af40477533a09ee6c902de335c445a8dcc88c58f62af6e5f/types_defusedxml-0.7.0.20260408.tar.gz", hash = "sha256:f35377d59344f98b57f9bf319cff2107aac35f9e4d42f9ed6cfeeafacffadb00", size = 10638, upload-time = "2026-04-08T04:26:12.239Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/4e/68f85712dfbcc929c54d57e9b0e7503c198fa65896cae2f6337840ab1cc5/types_defusedxml-0.7.0.20260402-py3-none-any.whl", hash = "sha256:200f3cb340c3c576adeb28cf365399e9bb059b34662b86ad4617692284c98bdb", size = 13434, upload-time = "2026-04-02T04:18:59.263Z" }, + { url = "https://files.pythonhosted.org/packages/ed/68/7570cfb818d6a5b3ff964114527e28e360eccf18329b457f057a18596e64/types_defusedxml-0.7.0.20260408-py3-none-any.whl", hash = "sha256:2d68db82412170b91b3e490b7c118a4f4e5a27756a126e2453f629c8d514b106", size = 13435, upload-time = "2026-04-08T04:26:11.347Z" }, ] [[package]] name = "types-deprecated" -version = "1.3.1.20260402" +version = "1.3.1.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/ff/7e237c5118c1bd15e5205789901f7e01db232b0c61ca7c7c05de0394f5da/types_deprecated-1.3.1.20260402.tar.gz", hash = "sha256:00828ef7dce735d778583d00611f97da05b86b783ee14b0f22af2f945363cd12", size = 8481, upload-time = "2026-04-02T04:18:28.704Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/db/076de3e81b106d3cec17aec9640ab1b2d02f29bad441de280459c161ce65/types_deprecated-1.3.1.20260408.tar.gz", hash = "sha256:62d6a86d0cc754c14bb2de31162d069b1c6a07ce11ee65e5258f8f75308eb3a3", size = 8524, upload-time = "2026-04-08T04:26:39.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/3c/59aa775db5f69eba978390c33e1fd617817381cd87424ac1cff4bf2fb6c5/types_deprecated-1.3.1.20260402-py3-none-any.whl", hash = "sha256:ddf1813bd99cd1c00358cb0cb079878fdaa74509e7e482b79627f74f768f31a9", size = 9077, upload-time = "2026-04-02T04:18:27.867Z" }, + { url = "https://files.pythonhosted.org/packages/53/d0/d3258379deb749d949c3c72313981c9d2cceec518b87dcf506f022f5d49f/types_deprecated-1.3.1.20260408-py3-none-any.whl", hash = "sha256:b64e1eab560d4fa9394a27a3099211344b0e0f2f3ac8026d825c86e70d65cdd5", size = 9079, upload-time = "2026-04-08T04:26:38.752Z" }, ] [[package]] name = "types-docutils" -version = "0.22.3.20260322" +version = "0.22.3.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/49/48a386fe15539556de085b87a69568b028cca2fa4b92596a3d4f79ac6784/types_docutils-0.22.3.20260408.tar.gz", hash = "sha256:22d5d45e4e0d65a1bc8280987a73e28669bb1cc9d16b18d0afc91713d1be26da", size = 57383, upload-time = "2026-04-08T04:27:26.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/08/47/1667fda6e9fcb044f8fb797f6dc4367b88dc2ab40f1a035e387f5405e870/types_docutils-0.22.3.20260408-py3-none-any.whl", hash = "sha256:2545a86966022cdf1468d430b0007eba0837be77974a7f3fafa1b04a6815d531", size = 91981, upload-time = "2026-04-08T04:27:25.934Z" }, ] [[package]] name = "types-flask-cors" -version = "6.0.0.20260402" +version = "6.0.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flask" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/59/84d8ed3801cbf28876067387e1055467e94e3dd404e93e35fe2ec5e46729/types_flask_cors-6.0.0.20260402.tar.gz", hash = "sha256:57350b504328df7ec13a12599e67939189cb644c5d0efec9af80ed03c592052c", size = 10126, upload-time = "2026-04-02T04:20:57.954Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/68/e58191af5b56e836a4a2e2583ecfad91bde176940edf1bfc8ea706a5f74d/types_flask_cors-6.0.0.20260408.tar.gz", hash = "sha256:8c440c158c335819bb9286870c9770687ae6d943510fdd97e4b573324f8d2178", size = 10223, upload-time = "2026-04-08T04:35:42.608Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/71/d86f7644a18a8ccdddf50b9969fc94abbecd0ac52594880dc5667ca53e5e/types_flask_cors-6.0.0.20260402-py3-none-any.whl", hash = "sha256:e018d34946c110f5acfa71cc708ec66b47c4292131647e54889600c20892ca26", size = 9990, upload-time = "2026-04-02T04:20:57.12Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8d/eb905e231aaed6c0853f002446a1fb12d5a32d79b688f2cdd4f8d6e6ce03/types_flask_cors-6.0.0.20260408-py3-none-any.whl", hash = "sha256:ccd8801862b3ebd27754734b84fc3dcfebd0f8056380ae88254c7dd799d64a39", size = 9993, upload-time = "2026-04-08T04:35:41.452Z" }, ] [[package]] name = "types-flask-migrate" -version = "4.1.0.20260402" +version = "4.1.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flask" }, { name = "flask-sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/85/291317e13f72d5b2b6c1fe2c59c77a45d07bb225bf5bb2768da6a7b96351/types_flask_migrate-4.1.0.20260402.tar.gz", hash = "sha256:8e0062f063ecbe5c73b53ffc1e86f4d6de5ab970142c7d2dea939c5680ba817a", size = 8717, upload-time = "2026-04-02T04:21:45.77Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/31/56f5607fca2ad4e41da095b0e22ce9749d29d985df8c0229907bf662413b/types_flask_migrate-4.1.0.20260408.tar.gz", hash = "sha256:65ef927584777eac9a4591eb8320c09eb6eb8862d2ffdd6e23ad485a2869b228", size = 8773, upload-time = "2026-04-08T04:36:22.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/d9/716b9cb9fca0f87e95f573e21e5ffe83d1cf9919ceb2e1cca8bc71488746/types_flask_migrate-4.1.0.20260402-py3-none-any.whl", hash = "sha256:6989d40d3cfae1c5f70c8f20ba39e714949b633329cc23b2dd00e82fd5b07d1c", size = 8669, upload-time = "2026-04-02T04:21:44.967Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/1d1b300251d33f1a97004ef8bba53139116b00872cfb85521d1412259627/types_flask_migrate-4.1.0.20260408-py3-none-any.whl", hash = "sha256:ffdacb78f6697422aa09bdebba34f4133b2443a95a59761c279fc5d368c009d9", size = 8670, upload-time = "2026-04-08T04:36:21.394Z" }, ] [[package]] name = "types-gevent" -version = "25.9.0.20260402" +version = "26.4.0.20260409" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-greenlet" }, { name = "types-psutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/2f/a2056079f14aeacf538b51b0e6585328c3584fa8e6f4758214c9773ea4b0/types_gevent-25.9.0.20260402.tar.gz", hash = "sha256:24297e6f5733e187a517f08dde6df7b2147e14f7de4d343148f410dffebb5381", size = 38270, upload-time = "2026-04-02T04:22:00.125Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/ea/17fa935aa62d45cb9f67947e93c3c0c1ed97a76d579b12e1623cd348b68a/types_gevent-26.4.0.20260409.tar.gz", hash = "sha256:6b029c599fe4ec0efce8cd2bf5e5ae958d9808aa5b2f7bdfcb9b9eb42d91cc6a", size = 38333, upload-time = "2026-04-09T04:22:42.334Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/995920b5cc58bc9041ded8ea2fda32719f6c513bc6e43a0c5234780936db/types_gevent-25.9.0.20260402-py3-none-any.whl", hash = "sha256:178ba12e426c987dd69ef0b8ce9f1095a965103a0d673294831f49f7127bc5ba", size = 55494, upload-time = "2026-04-02T04:21:59.144Z" }, + { url = "https://files.pythonhosted.org/packages/29/17/04671a7e3de8c0fdd4c39dc43830b496ad68998d37cff38e0d9701f77a67/types_gevent-26.4.0.20260409-py3-none-any.whl", hash = "sha256:f5f5eb7365a9b8b738787a2dc93c509ee0ca919c6d4388504f2cd09e476d4066", size = 55491, upload-time = "2026-04-09T04:22:41.226Z" }, ] [[package]] name = "types-greenlet" -version = "3.3.0.20251206" +version = "3.4.0.20260409" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/d3/23f4ab29a5ce239935bb3c157defcf50df8648c16c65965fae03980d67f3/types_greenlet-3.3.0.20251206.tar.gz", hash = "sha256:3e1ab312ab7154c08edc2e8110fbf00d9920323edc1144ad459b7b0052063055", size = 8901, upload-time = "2025-12-06T03:01:38.634Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/a6/668751bc864efe820e1eb12c2a77f9e62537f433cc002e483ad01badb04b/types_greenlet-3.4.0.20260409.tar.gz", hash = "sha256:81d2cf628934a16856bb9e54136def8de5356e934f0ad5d5474f219a0c5cb205", size = 8976, upload-time = "2026-04-09T04:22:31.693Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/8f/aabde1b6e49b25a6804c12a707829e44ba0f5520563c09271f05d3196142/types_greenlet-3.3.0.20251206-py3-none-any.whl", hash = "sha256:8d11041c0b0db545619e8c8a1266aa4aaa4ebeae8ae6b4b7049917a6045a5590", size = 8809, upload-time = "2025-12-06T03:01:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3f/c8a4d8782f78fccb4b5fe91c5eae2efce6648072754bc7096b1e3b5407ad/types_greenlet-3.4.0.20260409-py3-none-any.whl", hash = "sha256:cbceadb4594eccd95b57b3f7fa8a9b851488f5e6c05026f4a3db9aac02ec8333", size = 8812, upload-time = "2026-04-09T04:22:30.734Z" }, ] [[package]] name = "types-html5lib" -version = "1.1.11.20260402" +version = "1.1.11.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/95/74eabb3bd0bb2f2b3a8ba56a55e87ee4b76f2b39e2a690eca399deffc837/types_html5lib-1.1.11.20260402.tar.gz", hash = "sha256:a167a30b9619a6eea82ec8b8948044859e033966a4721db34187d647c3a6c1f3", size = 18268, upload-time = "2026-04-02T04:21:56.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/59/914d00107c770e49fa57d4c4572e0371bbce14321385fd2ea3e06691b62d/types_html5lib-1.1.11.20260408.tar.gz", hash = "sha256:8a281aa367bc77dbc758358cd9bef79530f2d154eeed9b33705bb035a0dab9e4", size = 18316, upload-time = "2026-04-08T04:35:49.581Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/a9/fac9d4313b1851620610f46d086ba288482c0d5384ebf6feafb5bc4bdd15/types_html5lib-1.1.11.20260402-py3-none-any.whl", hash = "sha256:245d02cf53ef62d7342268c53dbc2af2d200849feec03f77f5909655cb54ab0d", size = 24314, upload-time = "2026-04-02T04:21:55.659Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/12d95e98e42e120522665ec6850b38df8d2c1cca94e21c4d7f8578acb64e/types_html5lib-1.1.11.20260408-py3-none-any.whl", hash = "sha256:d18dc4b90d6d6745585790b920db13ede43e1f8ff6ee1ac0ceb0dec4223a06fa", size = 24313, upload-time = "2026-04-08T04:35:48.679Z" }, ] [[package]] name = "types-jmespath" -version = "1.1.0.20260124" +version = "1.1.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/ca/c8d7fc6e450c2f8fc6f510cb194754c43b17f933f2dcabcfc6985cbb97a8/types_jmespath-1.1.0.20260124.tar.gz", hash = "sha256:29d86868e72c0820914577077b27d167dcab08b1fc92157a29d537ff7153fdfe", size = 10709, upload-time = "2026-01-24T03:18:46.557Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/5e/33881ff525fbaa71cb6192d81fd4039607006ff48f85c40ef1e20d72d1d3/types_jmespath-1.1.0.20260408.tar.gz", hash = "sha256:42483cfc3d16bdd88c1150a7419d59ef59b8bdc4db3eec8ebf6971a0dad1a425", size = 10733, upload-time = "2026-04-08T04:29:22.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/91/915c4a6e6e9bd2bca3ec0c21c1771b175c59e204b85e57f3f572370fe753/types_jmespath-1.1.0.20260124-py3-none-any.whl", hash = "sha256:ec387666d446b15624215aa9cbd2867ffd885b6c74246d357c65e830c7a138b3", size = 11509, upload-time = "2026-01-24T03:18:45.536Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/4c34097ce72dc8ea533db26a0162c53837398b26d4a0645ca3c7df74370b/types_jmespath-1.1.0.20260408-py3-none-any.whl", hash = "sha256:58a29fe039e5d3f9d0d42f1b067b9efa7c3e29c7e6df9c6830cbe5fa44ffb943", size = 11512, upload-time = "2026-04-08T04:29:22.133Z" }, ] [[package]] name = "types-markdown" -version = "3.10.2.20260211" +version = "3.10.2.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/2e/35b30a09f6ee8a69142408d3ceb248c4454aa638c0a414d8704a3ef79563/types_markdown-3.10.2.20260211.tar.gz", hash = "sha256:66164310f88c11a58c6c706094c6f8c537c418e3525d33b76276a5fbd66b01ce", size = 19768, upload-time = "2026-02-11T04:19:29.497Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/0e/a690840934c459aa50e0470e7550d7f151632eafa4a8e3c21d18009ad15c/types_markdown-3.10.2.20260408.tar.gz", hash = "sha256:d5cba15ed65a1420e80e31c17e3d4a2ad7208a3f3a4da97fd2c5f093caf523cd", size = 19784, upload-time = "2026-04-08T04:33:07.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/c9/659fa2df04b232b0bfcd05d2418e683080e91ec68f636f3c0a5a267350e7/types_markdown-3.10.2.20260211-py3-none-any.whl", hash = "sha256:2d94d08587e3738203b3c4479c449845112b171abe8b5cadc9b0c12fcf3e99da", size = 25854, upload-time = "2026-02-11T04:19:28.647Z" }, + { url = "https://files.pythonhosted.org/packages/75/7e/265a8df257c8dced6ea89295f793a19f0a49ccbfeae1ed562368b2caf7a3/types_markdown-3.10.2.20260408-py3-none-any.whl", hash = "sha256:b0bbe8b7a8174db732067b86e391262898f5f536589ea81efec6d35ceb829331", size = 25857, upload-time = "2026-04-08T04:33:06.769Z" }, ] [[package]] name = "types-oauthlib" -version = "3.3.0.20260324" +version = "3.3.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/38/543938f86d81bd6a78b8c355fe81bb8da0a26e4c28addfe3443e38a683d2/types_oauthlib-3.3.0.20260324.tar.gz", hash = "sha256:3c4cc07fa33886f881682237c1e445c5f1778b44efea118f4c1e4ede82cb52f2", size = 26030, upload-time = "2026-03-24T04:06:30.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/7e/4cf7b08b4c6b266d9967c02ebdba8c5390029d5750def924b23679a730a0/types_oauthlib-3.3.0.20260408.tar.gz", hash = "sha256:deaeccbc33634f5efa7ef320924bce743495f5a1520073ce4fa0fea441bf063d", size = 26066, upload-time = "2026-04-08T04:28:45.636Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/60/26f0ddade4b2bb17b3d8f3ebaac436e5487caec28831da3d7ea309fe93b9/types_oauthlib-3.3.0.20260324-py3-none-any.whl", hash = "sha256:d24662033b04f4d50a2f1fed04c1b43ff2554aa037c1dafa0424f87100a46ccd", size = 48984, upload-time = "2026-03-24T04:06:29.696Z" }, + { url = "https://files.pythonhosted.org/packages/d7/77/6866665af7b414bbffd37028a92618d3771402ae587e9cd2d70efcb6d8f6/types_oauthlib-3.3.0.20260408-py3-none-any.whl", hash = "sha256:1c305d18a05636fac4953800aa0982e54c258562838dafeaa6a3d05b7f4669fe", size = 48987, upload-time = "2026-04-08T04:28:44.719Z" }, ] [[package]] name = "types-objgraph" -version = "3.6.0.20240907" +version = "3.6.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/48/ba0ec63d392904eee34ef1cbde2d8798f79a3663950e42fbbc25fd1bd6f7/types-objgraph-3.6.0.20240907.tar.gz", hash = "sha256:2e3dee675843ae387889731550b0ddfed06e9420946cf78a4bca565b5fc53634", size = 2928, upload-time = "2024-09-07T02:35:21.214Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/4b/e43381191b1d9d1a0d8b1d7da12ee28ea63f97f38bc6694231dde066b3c8/types_objgraph-3.6.0.20260408.tar.gz", hash = "sha256:9937aae5ad5bb625a2091b33e2f67e979f61e3719078d318b2261c4e7f13ac9a", size = 7606, upload-time = "2026-04-08T04:30:41.072Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/c9/6d647a947f3937b19bcc6d52262921ddad60d90060ff66511a4bd7e990c5/types_objgraph-3.6.0.20240907-py3-none-any.whl", hash = "sha256:67207633a9b5789ee1911d740b269c3371081b79c0d8f68b00e7b8539f5c43f5", size = 3314, upload-time = "2024-09-07T02:35:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/38/99/6f618e0931367814b2ab9ad2b946f0f0ca4b8b02405a3552bcb90acc1b5c/types_objgraph-3.6.0.20260408-py3-none-any.whl", hash = "sha256:fd4ae0c6c10e260a3dfdd778f3f79f081c804f9d48b5bb6c2299d8645b287c5f", size = 8059, upload-time = "2026-04-08T04:30:40.301Z" }, ] [[package]] name = "types-olefile" -version = "0.47.0.20240806" +version = "0.47.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/18/9d87a1bc394323ce22690308c751680c4301fc3fbe47cd58e16d760b563a/types-olefile-0.47.0.20240806.tar.gz", hash = "sha256:96490f208cbb449a52283855319d73688ba9167ae58858ef8c506bf7ca2c6b67", size = 4369, upload-time = "2024-08-06T02:30:01.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/d0/1c7e058666a4e5364463ad0a7bfd7a0bbc2180df427016cc7ebeeddb0b29/types_olefile-0.47.0.20260408.tar.gz", hash = "sha256:01fbcb6332152e88486634460c8d59a8c75da9a50d85e0ff6f754c02db3fc23e", size = 9037, upload-time = "2026-04-08T04:30:13.973Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/4d/f8acae53dd95353f8a789a06ea27423ae41f2067eb6ce92946fdc6a1f7a7/types_olefile-0.47.0.20240806-py3-none-any.whl", hash = "sha256:c760a3deab7adb87a80d33b0e4edbbfbab865204a18d5121746022d7f8555118", size = 4758, upload-time = "2024-08-06T02:30:01.15Z" }, + { url = "https://files.pythonhosted.org/packages/37/20/b88f8f1336fd3772813c21dd536e50d10c4416294539b8b623e769e9b4a2/types_olefile-0.47.0.20260408-py3-none-any.whl", hash = "sha256:2499f110beb659504173dcd66c069a297b210f715ad45a713e097ecd3a265992", size = 9493, upload-time = "2026-04-08T04:30:13.173Z" }, ] [[package]] name = "types-openpyxl" -version = "3.1.5.20260402" +version = "3.1.5.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/8f/d9daf094e0bb468b26e74c1bf9e0170e58c3f16e583d244e9f32078b6bcc/types_openpyxl-3.1.5.20260402.tar.gz", hash = "sha256:855ad28d47c0965048082dfca424d6ebd54d8861d72abcee9106ba5868899e7f", size = 101310, upload-time = "2026-04-02T04:17:37.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/c9/24f03f9d9fedd164de699c1418869bef9b819f59f75e7f647f5788c02d98/types_openpyxl-3.1.5.20260408.tar.gz", hash = "sha256:b49274d086fbb6e6bcd2a67d161dd1161d4d380488e4cce546d647d45eadcac2", size = 101361, upload-time = "2026-04-08T04:30:37.809Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/ee/a0b22012076cf23b73fbb82d9c40843cbf6b1d228d7a2dc883da0a905a16/types_openpyxl-3.1.5.20260402-py3-none-any.whl", hash = "sha256:1d149989f0aad4e2074e96b87a045136399e27bc2a33cfefcd0eb4cad8ea5b4c", size = 166046, upload-time = "2026-04-02T04:17:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/64db0c32c6fda4b198aff84329ceeea00a93d16c28f05e9fe0404ddb8b8c/types_openpyxl-3.1.5.20260408-py3-none-any.whl", hash = "sha256:7ab7586796aed017cde50b81bd67ba024120c39b99c102320b57dd91390c317f", size = 166044, upload-time = "2026-04-08T04:30:36.449Z" }, ] [[package]] name = "types-pexpect" -version = "4.9.0.20260127" +version = "4.9.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/32/7e03a07e16f79a404d6200ed6bdfcc320d0fb833436a5c6895a1403dedb7/types_pexpect-4.9.0.20260127.tar.gz", hash = "sha256:f8d43efc24251a8e533c71ea9be03d19bb5d08af096d561611697af9720cba7f", size = 13461, upload-time = "2026-01-27T03:28:30.923Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/0f/5e9aa68e4595264e968ffaa3358afb2a8d60093f460aaa7e0398c0d9bfd0/types_pexpect-4.9.0.20260408.tar.gz", hash = "sha256:faedd97fc8086b224bc1966770c486ac6ec96bef07dc47cc2724fe4ae62f8f4a", size = 13471, upload-time = "2026-04-08T04:32:30.624Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/d9/7ac5c9aa5a89a1a64cd835ae348227f4939406d826e461b85b690a8ba1c2/types_pexpect-4.9.0.20260127-py3-none-any.whl", hash = "sha256:69216c0ebf0fe45ad2900823133959b027e9471e24fc3f2e4c7b00605555da5f", size = 17078, upload-time = "2026-01-27T03:28:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/96004a3e5dd6c6e7de4edc421d5a2926e062d22be7b006edab747ed42830/types_pexpect-4.9.0.20260408-py3-none-any.whl", hash = "sha256:ba6699609bb6593f00ef7204efc390fd10bc14a8d632f22c8dea13f263b16fcc", size = 17083, upload-time = "2026-04-08T04:32:29.75Z" }, ] [[package]] name = "types-protobuf" -version = "7.34.1.20260403" +version = "7.34.1.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b3/c2e407ea36e0e4355c135127cee1b88a2cc9a2c92eafca50a360ab9f2708/types_protobuf-7.34.1.20260403.tar.gz", hash = "sha256:8d7881867888e667eb9563c08a916fccdc12bdb5f9f34c31d217cce876e36765", size = 68782, upload-time = "2026-04-03T04:18:09.428Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/b1/4521e68c2cc17703d80eb42796751345376dd4c706f84007ef5e7c707774/types_protobuf-7.34.1.20260408.tar.gz", hash = "sha256:e2c0a0430e08c75b52671a6f0035abfdcc791aad12af16274282de1b721758ab", size = 68835, upload-time = "2026-04-08T04:26:43.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/95/24fb0f6fe37b41cf94f9b9912712645e17d8048d4becaf37c1607ddd8e32/types_protobuf-7.34.1.20260403-py3-none-any.whl", hash = "sha256:16d9bbca52ab0f306279958878567df2520f3f5579059419b0ce149a0ad1e332", size = 86011, upload-time = "2026-04-03T04:18:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b5/0bc9874d89c58fb0ce851e150055ce732d254dbb10b06becbc7635d0d635/types_protobuf-7.34.1.20260408-py3-none-any.whl", hash = "sha256:ebbcd4e27b145aef6a59bc0cb6c013b3528151c1ba5e7f7337aeee355d276a5e", size = 86012, upload-time = "2026-04-08T04:26:42.566Z" }, ] [[package]] name = "types-psutil" -version = "7.2.2.20260402" +version = "7.2.2.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/a2/a608db0caf0d71bd231305dc3ab3f5d65624d77761003696a3ca8c6fad40/types_psutil-7.2.2.20260402.tar.gz", hash = "sha256:9f36eebf15ad8487f8004ed67c8e008b84b63ba00cfb709a3f60275058217329", size = 26522, upload-time = "2026-04-02T04:18:47.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/14/279fd5defebbd560ede04aecd38f7651cccee7336f2264d0889d8c9a9d43/types_psutil-7.2.2.20260408.tar.gz", hash = "sha256:e8053450685965b8cd52afb62569073d00ea9967ae78bb45dff5f606847f97f2", size = 26556, upload-time = "2026-04-08T04:27:44.349Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/8a/f4b3ca3154e8a77df91eb7a28c208af721d48f8a4aca667f582523a0beff/types_psutil-7.2.2.20260402-py3-none-any.whl", hash = "sha256:653d1fd908e68cc0666754b16a0cee28efbded0c401caa5314d2aeea67f227cd", size = 32860, upload-time = "2026-04-02T04:18:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/af/40/2fd92a4a1ee088c4dbcc44c977908d9869838d9cd2a2fa2e001352f56694/types_psutil-7.2.2.20260408-py3-none-any.whl", hash = "sha256:0c334f6f6bc9e9c24fca5c7d1f0b6971c961a0a2e3956dc5ce704722c01f9762", size = 32861, upload-time = "2026-04-08T04:27:42.929Z" }, ] [[package]] name = "types-psycopg2" -version = "2.9.21.20260223" +version = "2.9.21.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/1f/4daff0ce5e8e191844e65aaa793ed1b9cb40027dc2700906ecf2b6bcc0ed/types_psycopg2-2.9.21.20260223.tar.gz", hash = "sha256:78ed70de2e56bc6b5c26c8c1da8e9af54e49fdc3c94d1504609f3519e2b84f02", size = 27090, upload-time = "2026-02-23T04:11:18.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/24/d8ae11a0c056535557aaabeb7d7838423abdfdcf1e5f8dfb2c04d316c65d/types_psycopg2-2.9.21.20260408.tar.gz", hash = "sha256:bb65cd12f53b6633077fd782607a33065e1f3bf585219c9f786b61ad2b72211c", size = 27078, upload-time = "2026-04-08T04:26:15.848Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/e7/c566df58410bc0728348b514e718f0b38fa0d248b5c10599a11494ba25d2/types_psycopg2-2.9.21.20260223-py3-none-any.whl", hash = "sha256:c6228ade72d813b0624f4c03feeb89471950ac27cd0506b5debed6f053086bc8", size = 24919, upload-time = "2026-02-23T04:11:17.214Z" }, + { url = "https://files.pythonhosted.org/packages/1a/fe/9aab9239640107b6e46afddcee578a916b8b98bfee36e03da5b0d2c95124/types_psycopg2-2.9.21.20260408-py3-none-any.whl", hash = "sha256:49b086bfc9e0ce901c6537403ead1c19c75275571040b037af0248a8e48c322f", size = 24921, upload-time = "2026-04-08T04:26:14.715Z" }, ] [[package]] name = "types-pygments" -version = "2.20.0.20260406" +version = "2.20.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-docutils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/bd/d17c28a4c65c556bc4c4bc8f363aa2fbfc91b397e3c0019839d74d9ead31/types_pygments-2.20.0.20260406.tar.gz", hash = "sha256:d3ed7ecd7c34a382459d28ce624b87e1dee03d6844e43aa7590ef4b8c7c9dfce", size = 19486, upload-time = "2026-04-06T04:33:59.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/89/4b443128fa540c54a8f7ecdeec225aab4818534167c4a2d133099dc00fa6/types_pygments-2.20.0.20260408.tar.gz", hash = "sha256:e8a56a3ab1aee7f4ed8f1876d2f62c96e0f41ede52405a7d30c888f3989d8f00", size = 21115, upload-time = "2026-04-08T04:34:24.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/dca7518e6f99ce0f235ec1c6512593ee4bd25109ae1c912bf9ee836a26e1/types_pygments-2.20.0.20260406-py3-none-any.whl", hash = "sha256:6bb0c79874c304977e1c097f7007140e16fe78c443329154db803d7910d945b3", size = 27278, upload-time = "2026-04-06T04:33:58.744Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d8/30924b38eef70caef6b05af5440c84d7673cea2a042e206f404c8100a88d/types_pygments-2.20.0.20260408-py3-none-any.whl", hash = "sha256:6d347d5967b5f0654b659a8b8461a870b207b7e60cd4d646bbc047f6a8db8e1e", size = 29055, upload-time = "2026-04-08T04:34:23.412Z" }, ] [[package]] name = "types-pymysql" -version = "1.1.0.20251220" +version = "1.1.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/e959dd6d2f8e3b3c3f058d79ac9ece328922a5a8770c707fe9c3a757481c/types_pymysql-1.1.0.20251220.tar.gz", hash = "sha256:ae1c3df32a777489431e2e9963880a0df48f6591e0aa2fd3a6fabd9dee6eca54", size = 22184, upload-time = "2025-12-20T03:07:38.689Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/04/c3570f05ebab083f28698c829dddf754ffefc30aae4e29915610848e44db/types_pymysql-1.1.0.20260408.tar.gz", hash = "sha256:b784dc37908479e3767e2d794ab507b3674adb1c686ca3d13fc9e2960dbcb9ec", size = 22344, upload-time = "2026-04-08T04:27:47.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/fa/4f4d3bfca9ef6dd17d69ed18b96564c53b32d3ce774132308d0bee849f10/types_pymysql-1.1.0.20251220-py3-none-any.whl", hash = "sha256:fa1082af7dea6c53b6caa5784241924b1296ea3a8d3bd060417352c5e10c0618", size = 23067, upload-time = "2025-12-20T03:07:37.766Z" }, + { url = "https://files.pythonhosted.org/packages/70/b3/15dee33878709705a4cc83bcc1bb30e00e95bbe038b472cb1207a15b50a1/types_pymysql-1.1.0.20260408-py3-none-any.whl", hash = "sha256:da630647eaaa7a926a3907794f4067f269cd245b2c202c74aa3c6a3bd660a9db", size = 23071, upload-time = "2026-04-08T04:27:46.735Z" }, ] [[package]] @@ -6234,38 +6707,38 @@ wheels = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20260402" +version = "2.9.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/30/c5d9efbff5422b20c9551dc5af237d1ab0c3d33729a9b3239a876ca47dd4/types_python_dateutil-2.9.0.20260402.tar.gz", hash = "sha256:a980142b9966713acb382c467e35c5cc4208a2f91b10b8d785a0ae6765df6c0b", size = 16941, upload-time = "2026-04-02T04:18:35.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/f3/2427775f80cd5e19a0a71ba8e5ab7645a01a852f43a5fd0ffc24f66338e0/types_python_dateutil-2.9.0.20260408.tar.gz", hash = "sha256:8b056ec01568674235f64ecbcef928972a5fac412f5aab09c516dfa2acfbb582", size = 16981, upload-time = "2026-04-08T04:28:10.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/d7/fe753bf8329c8c3c1addcba1d2bf716c33898216757abb24f8b80f82d040/types_python_dateutil-2.9.0.20260402-py3-none-any.whl", hash = "sha256:7827e6a9c93587cc18e766944254d1351a2396262e4abe1510cbbd7601c5e01f", size = 18436, upload-time = "2026-04-02T04:18:34.806Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c6/eeba37bfee282a6a97f889faef9352d6172c6a5088eb9a4daf570d9d748d/types_python_dateutil-2.9.0.20260408-py3-none-any.whl", hash = "sha256:473139d514a71c9d1fbd8bb328974bedcb1cc3dba57aad04ffa4157f483c216f", size = 18437, upload-time = "2026-04-08T04:28:10.095Z" }, ] [[package]] name = "types-python-http-client" -version = "3.3.7.20250708" +version = "3.3.7.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/a0/0ad93698a3ebc6846ca23aca20ff6f6f8ebe7b4f0c1de7f19e87c03dbe8f/types_python_http_client-3.3.7.20250708.tar.gz", hash = "sha256:5f85b32dc64671a4e5e016142169aa187c5abed0b196680944e4efd3d5ce3322", size = 7707, upload-time = "2025-07-08T03:14:36.197Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/30/f741f5edce6b02a838a30064360f5480510d5f2861561f44c5e33bc1dd96/types_python_http_client-3.3.7.20260408.tar.gz", hash = "sha256:ae84aadeec645ede7e602714090e6c8ebca97dbf28af509ac5eeccfc300174a2", size = 7684, upload-time = "2026-04-08T04:27:09.67Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/4f/b88274658cf489e35175be8571c970e9a1219713bafd8fc9e166d7351ecb/types_python_http_client-3.3.7.20250708-py3-none-any.whl", hash = "sha256:e2fc253859decab36713d82fc7f205868c3ddeaee79dbb55956ad9ca77abe12b", size = 8890, upload-time = "2025-07-08T03:14:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/45c6c04924086e8856e7f9a33a38ee713992d9ae9cd6d449de97badcba3c/types_python_http_client-3.3.7.20260408-py3-none-any.whl", hash = "sha256:3f310282e0fe2a18c5291f935538e1f97b9f80d2c5571aad155e66806719017c", size = 8851, upload-time = "2026-04-08T04:27:08.877Z" }, ] [[package]] name = "types-pywin32" -version = "311.0.0.20260402" +version = "311.0.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/f0/fc3c923b5d7822f3a93c7b242a69de0e1945e7c153cc5367074621a6509f/types_pywin32-311.0.0.20260402.tar.gz", hash = "sha256:637f041065f02fb49cbaba530ae8cf2e483b5d2c145a9bf97fd084c3e913c7e3", size = 332312, upload-time = "2026-04-02T04:18:52.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/40/0d182fbf578f30f7ff2b07b8fe494cc42178992d0087a739c70990adca8c/types_pywin32-311.0.0.20260408.tar.gz", hash = "sha256:cb86c6beae20195165e770a65c3ee707746dc777ca8e03e4f06a66d4013a4bd0", size = 332341, upload-time = "2026-04-08T04:33:29.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/0c/a2ee20785df4ebcda6d6ec62d58b7c08a37072f9d00cda4f9548e9c8e5aa/types_pywin32-311.0.0.20260402-py3-none-any.whl", hash = "sha256:4db644fcf40ee85a3ee2551f110d009e427c01569ed4670bb53cfe999df0929f", size = 395413, upload-time = "2026-04-02T04:18:51.529Z" }, + { url = "https://files.pythonhosted.org/packages/0d/b5/3cc67baf622805270d84e2252dfa130daf7ccd49795f80b51350abb91bd9/types_pywin32-311.0.0.20260408-py3-none-any.whl", hash = "sha256:0b691da60aaed0ee7169a69268bad1e2051eb52f4acc10248c103aadcd1f2451", size = 395413, upload-time = "2026-04-08T04:33:28.476Z" }, ] [[package]] name = "types-pyyaml" -version = "6.0.12.20250915" +version = "6.0.12.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, ] [[package]] @@ -6283,11 +6756,11 @@ wheels = [ [[package]] name = "types-regex" -version = "2026.4.4.20260405" +version = "2026.4.4.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/9c/dd7b36fe87902a161a69c4a6959e3a6afae09c2c600916beb1aecd300870/types_regex-2026.4.4.20260405.tar.gz", hash = "sha256:993b76a255d9b83fd68eed2fc52b2746be51a93b833796be4fcf9412efa0da51", size = 13143, upload-time = "2026-04-05T04:26:56.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/42/d7c691fc5a8a8ecfba3f23c1c4c087a089af0767610d88c29201193d8f60/types_regex-2026.4.4.20260408.tar.gz", hash = "sha256:86b2975ff11b06e7f538839821510daea2566d9cb18bb8acde47834315409cf9", size = 13182, upload-time = "2026-04-08T04:31:11.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/83/5dbae203616699890efcdb2a2670d62baf5ed93634f75d793157f1edefb3/types_regex-2026.4.4.20260405-py3-none-any.whl", hash = "sha256:40443cb88c43b9940dd4c904e251be7e65dab3798b2cf6f5ff19501ae99b2ab5", size = 11119, upload-time = "2026-04-05T04:26:55.636Z" }, + { url = "https://files.pythonhosted.org/packages/e1/92/e109654a804d11d9b60d67c7b29d64b2beac6b2e3209ea075e268e5a1021/types_regex-2026.4.4.20260408-py3-none-any.whl", hash = "sha256:d436bcc409abf9b06747b7e038014afc6d40ef7b72329655c353a1955534068f", size = 11116, upload-time = "2026-04-08T04:31:11.01Z" }, ] [[package]] @@ -6313,67 +6786,67 @@ wheels = [ [[package]] name = "types-setuptools" -version = "82.0.0.20260402" +version = "82.0.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/f8/74f8a76b4311e70772c0df8f2d432040a3b0facd7bcce6b72b0b26e1746b/types_setuptools-82.0.0.20260402.tar.gz", hash = "sha256:63d2b10ba7958396ad79bbc24d2f6311484e452daad4637ffd40407983a27069", size = 44805, upload-time = "2026-04-02T04:17:49.229Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/12/3464b410c50420dd4674fa5fe9d3880711c1dbe1a06f5fe4960ee9067b9e/types_setuptools-82.0.0.20260408.tar.gz", hash = "sha256:036c68caf7e672a699f5ebbf914708d40644c14e05298bc49f7272be91cf43d3", size = 44861, upload-time = "2026-04-08T04:29:33.292Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/e9/22451997f70ac2c5f18dc5f988750c986011fb049d9021767277119e63fa/types_setuptools-82.0.0.20260402-py3-none-any.whl", hash = "sha256:4b9a9f6c3c4c65107a3956ad6a6acbccec38e398ff6d5f78d5df7f103dadb8d6", size = 68429, upload-time = "2026-04-02T04:17:48.11Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e1/46a4fc3ef03aabf5d18bac9df5cf37c6b02c3bddf3e05c3533f4b4588331/types_setuptools-82.0.0.20260408-py3-none-any.whl", hash = "sha256:ece0a215cdfa6463a65fd6f68bd940f39e455729300ddfe61cab1147ed1d2462", size = 68428, upload-time = "2026-04-08T04:29:32.175Z" }, ] [[package]] name = "types-shapely" -version = "2.1.0.20260402" +version = "2.1.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/f7/46e95b09434105d7b772d05657495f2900bae8e108fdf4e6d8b5902aa28c/types_shapely-2.1.0.20260402.tar.gz", hash = "sha256:0eb592328170433b4724430a64c309bf07ba69d5d11489d3dba21382d78f5297", size = 26481, upload-time = "2026-04-02T04:20:03.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/8d/bf9e3eb51249601e22d797481999a06fb34998c4db5c76804394f8a3fa28/types_shapely-2.1.0.20260408.tar.gz", hash = "sha256:8552549d9429baa52ec4331e43b5db3b334fc3a7f30da48663010b7454b1451c", size = 26529, upload-time = "2026-04-08T04:34:42.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/3a/1aa3a62f5b85d4a9e649e7b42842a9e5503fef7eb50c480137a6b94f8bb1/types_shapely-2.1.0.20260402-py3-none-any.whl", hash = "sha256:8d70a16f615a104fd8abdd73e684d4e83b9dedf31d6432ecf86945b5ef0e35de", size = 37817, upload-time = "2026-04-02T04:20:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3d/cbec691f56e71636192a07bf6809f598bed06d869b03b4e2b1ad2f7df032/types_shapely-2.1.0.20260408-py3-none-any.whl", hash = "sha256:8a31e2b074342a363f0c9d0c7d6e1e6c0dcce302a92ef94d64d0ca2a2b94a1d1", size = 37818, upload-time = "2026-04-08T04:34:41.243Z" }, ] [[package]] name = "types-simplejson" -version = "3.20.0.20260402" +version = "3.20.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/93/2ff2f4b8ccd942ee3a4b62c013d2c1779e416d303950060ed8b3f1a4fc11/types_simplejson-3.20.0.20260402.tar.gz", hash = "sha256:ee2bbf65830fe93270a1c0406f3474c952fe1232532c7b6f3eb9500edb308c5a", size = 10650, upload-time = "2026-04-02T04:19:26.266Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/36/e319fd0f6d906dbf7c2c03eef17db77ef461197a75b253fccd9c7c695d3e/types_simplejson-3.20.0.20260408.tar.gz", hash = "sha256:0b0e1bf61e70f81dfe6ef4c2b9c02e39403848c0652df334e7a430c3a26c06b3", size = 10693, upload-time = "2026-04-08T04:28:07.8Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/2a/7ba2bede9c2b25fb338d0bda9925a23b73a5ac99fd97304ebe067c090e33/types_simplejson-3.20.0.20260402-py3-none-any.whl", hash = "sha256:b3bdef21bc24fee26b80385ffea5163b6b10381089aa619fe2f8f8d3790e6148", size = 10419, upload-time = "2026-04-02T04:19:25.464Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/01a5a4c3948c2269cf9d727e5e66a8b404e03beb4f9522680a3f71097011/types_simplejson-3.20.0.20260408-py3-none-any.whl", hash = "sha256:f9e542199cb159ed34ad54b6ceb3dc9af890c256b810ad1bd7c69c61db7d2236", size = 10415, upload-time = "2026-04-08T04:28:06.984Z" }, ] [[package]] name = "types-six" -version = "1.17.0.20251009" +version = "1.17.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/f7/448215bc7695cfa0c8a7e0dcfa54fe31b1d52fb87004fed32e659dd85c80/types_six-1.17.0.20251009.tar.gz", hash = "sha256:efe03064ecd0ffb0f7afe133990a2398d8493d8d1c1cc10ff3dfe476d57ba44f", size = 15552, upload-time = "2025-10-09T02:54:26.02Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/95/14bb40b2fa8f19234d60b370bfa1ff64b42509b6d2dee070132949ce4f80/types_six-1.17.0.20260408.tar.gz", hash = "sha256:b28579aedb204d07abac52e49c87e2b4c03cb6171bd764bd9b7775ba58fffaba", size = 15766, upload-time = "2026-04-08T04:26:23.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/2f/94baa623421940e3eb5d2fc63570ebb046f2bb4d9573b8787edab3ed2526/types_six-1.17.0.20251009-py3-none-any.whl", hash = "sha256:2494f4c2a58ada0edfe01ea84b58468732e43394c572d9cf5b1dd06d86c487a3", size = 19935, upload-time = "2025-10-09T02:54:25.096Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/3e9c382043579b5170c3bc38d13154d48e8ef2c89c4473748a33e3c9bccd/types_six-1.17.0.20260408-py3-none-any.whl", hash = "sha256:02208fa1099944ed0c8f8de42f065ffd63c55cd7b59f49be49802626b8d58318", size = 19937, upload-time = "2026-04-08T04:26:22.259Z" }, ] [[package]] name = "types-tensorflow" -version = "2.18.0.20260402" +version = "2.18.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "types-protobuf" }, { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/d9/1ca68336ce7ad8c4a19001fce85f47ffae9d7ac335e5ddd73497b6bfbca4/types_tensorflow-2.18.0.20260402.tar.gz", hash = "sha256:607c4a5895d44c88c7c465410093ee050aa760c3cedab5b9662f475c5e2137d3", size = 259058, upload-time = "2026-04-02T04:22:39.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/15/d9f1a54e75008fde3dc48f333b4d3c86f0d27b822e3a9c109214f8957ae6/types_tensorflow-2.18.0.20260408.tar.gz", hash = "sha256:68bfbcc76dd9e314eae0a91964edf463c52fc0e3d60189542efbf67006e71015", size = 259103, upload-time = "2026-04-08T04:36:45.263Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/6c/0ad58c7246a5369ceb2ae16c146ac0684a0827f499a8141fc3d13743c38b/types_tensorflow-2.18.0.20260402-py3-none-any.whl", hash = "sha256:0d4a74921c457ade8f46eb09cf728a1732156678e497ce15a88b9c0c16dc2fe5", size = 329776, upload-time = "2026-04-02T04:22:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/11/64/4005df91e916f586d9f80c3f052f2ae41afbcd9c9a54d33005fabeefcaab/types_tensorflow-2.18.0.20260408-py3-none-any.whl", hash = "sha256:01cff182dd6c38c300b27b9d1a26791f04607d914fa9429e5f85766c3bc0d71d", size = 329775, upload-time = "2026-04-08T04:36:43.863Z" }, ] [[package]] name = "types-tqdm" -version = "4.67.3.20260402" +version = "4.67.3.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/42/e9e6688891d8db77b5795ec02b329524170892ff81bec63c4c4ca7425b30/types_tqdm-4.67.3.20260402.tar.gz", hash = "sha256:e0739f3bc5d1c801999a202f0537280aa1bc2e669c49f5be91bfb99376690624", size = 18077, upload-time = "2026-04-02T04:22:23.049Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/42/2e2968e68a694d3dac3a47aa0df06e46be1a6eef498e5bd15f4c54674eb9/types_tqdm-4.67.3.20260408.tar.gz", hash = "sha256:fd849a79891ae7136ed47541aface15c35bd9a13160fa8a93e42e10f60cf4c8d", size = 18119, upload-time = "2026-04-08T04:36:52.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/73/a6cf75de5be376d7b57ce6c934ae9bc90aa5be6ada4ac50a99ecbdf9763e/types_tqdm-4.67.3.20260402-py3-none-any.whl", hash = "sha256:b5d1a65fe3286e1a855e51ddebf63d3641daf9bad285afd1ec56808eb59df76e", size = 24562, upload-time = "2026-04-02T04:22:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/14/5d/7dedddc32ab7bc2344ece772b5e0f03ec63a1d47ad259696689713c1cf50/types_tqdm-4.67.3.20260408-py3-none-any.whl", hash = "sha256:3b9ed74ebef04df8f53d470ffdc84348e93496d8acafa08bf79fafce0f2f5b5d", size = 24561, upload-time = "2026-04-08T04:36:51.538Z" }, ] [[package]] @@ -6760,13 +7233,13 @@ wheels = [ [[package]] name = "weave" -version = "0.52.17" +version = "0.52.36" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "cachetools" }, { name = "click" }, - { name = "diskcache" }, - { name = "eval-type-backport" }, - { name = "gql", extra = ["aiohttp", "requests"] }, + { name = "diskcache-weave" }, + { name = "gql", extra = ["httpx"] }, { name = "jsonschema" }, { name = "packaging" }, { name = "polyfile-weave" }, @@ -6776,27 +7249,26 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "wandb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/95/27e05d954972a83372a3ceb6b5db6136bc4f649fa69d8009b27c144ca111/weave-0.52.17.tar.gz", hash = "sha256:940aaf892b65c72c67cb893e97ed5339136a4b33a7ea85d52ed36671111826ef", size = 609149, upload-time = "2025-11-13T22:09:51.045Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/ee/63064875e0d1d7bf261484da7a8ef1d447d38f739b3e93e4d8673d51c882/weave-0.52.36.tar.gz", hash = "sha256:f58b37786de5444914e408e64026c3131b5c4417e6889d5a61fdcbec12c8e8dd", size = 793026, upload-time = "2026-04-01T17:23:50.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/0b/ae7860d2b0c02e7efab26815a9a5286d3b0f9f4e0356446f2896351bf770/weave-0.52.17-py3-none-any.whl", hash = "sha256:5772ef82521a033829c921115c5779399581a7ae06d81dfd527126e2115d16d4", size = 765887, upload-time = "2025-11-13T22:09:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/78/34/57a0843b3016e3dc63660cd06bfa52322f3333225ae7e7cfa6db97a59f8c/weave-0.52.36-py3-none-any.whl", hash = "sha256:4ff5b53323d20cc321aec665a4b4da746d6d85d432eda2ccca0e85bc8891649d", size = 983539, upload-time = "2026-04-01T17:23:48.819Z" }, ] [[package]] name = "weaviate-client" -version = "4.20.4" +version = "4.20.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, - { name = "deprecation" }, { name = "grpcio" }, { name = "httpx" }, { name = "protobuf" }, { name = "pydantic" }, { name = "validators" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/1c/82b560254f612f95b644849d86e092da6407f17965d61e22b583b30b72cf/weaviate_client-4.20.4.tar.gz", hash = "sha256:08703234b59e4e03739f39e740e9e88cb50cd0aa147d9408b88ea6ce995c37b6", size = 809529, upload-time = "2026-03-10T15:08:13.845Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/c8/aa47cfa0a2b1e260846eaf04ce4cc2ab1bb03f29d793e7b009bc3e3babc7/weaviate_client-4.20.5.tar.gz", hash = "sha256:c07c688f0e6b78723dfecbcfeebf897cefa75f1a89c63ebd84aab88c662e4394", size = 811866, upload-time = "2026-04-09T20:08:45.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/d7/9461c3e7d8c44080d2307078e33dc7fefefa3171c8f930f2b83a5cbf67f2/weaviate_client-4.20.4-py3-none-any.whl", hash = "sha256:7af3a213bebcb30dcf456b0db8b6225d8926106b835d7b883276de9dc1c301fe", size = 619517, upload-time = "2026-03-10T15:08:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/58/ba/d55f1a665802f736436d09198afc0d00806a405aadb9977193a2f009cfcb/weaviate_client-4.20.5-py3-none-any.whl", hash = "sha256:3f508e3dc08257f85230f9d2ea0562443ed0715e7e89156f22b7e950d6c08cdb", size = 620766, upload-time = "2026-04-09T20:08:43.215Z" }, ] [[package]] diff --git a/dev/pytest/pytest_unit_tests.sh b/dev/pytest/pytest_unit_tests.sh index 1d4ff4d86f..962532de81 100755 --- a/dev/pytest/pytest_unit_tests.sh +++ b/dev/pytest/pytest_unit_tests.sh @@ -10,7 +10,11 @@ PYTEST_XDIST_ARGS="${PYTEST_XDIST_ARGS:--n auto}" # Run most tests in parallel (excluding controllers which have import conflicts with xdist) # Controller tests have module-level side effects (Flask route registration) that cause # race conditions when imported concurrently by multiple pytest-xdist workers. -pytest --timeout "${PYTEST_TIMEOUT}" ${PYTEST_XDIST_ARGS} api/tests/unit_tests --ignore=api/tests/unit_tests/controllers +pytest --timeout "${PYTEST_TIMEOUT}" ${PYTEST_XDIST_ARGS} \ + api/tests/unit_tests \ + api/providers/vdb/*/tests/unit_tests \ + --ignore=api/tests/unit_tests/controllers # Run controller tests sequentially to avoid import race conditions pytest --timeout "${PYTEST_TIMEOUT}" --cov-append api/tests/unit_tests/controllers + diff --git a/dev/pytest/pytest_vdb.sh b/dev/pytest/pytest_vdb.sh index 126aebf7bd..c1f129bee0 100755 --- a/dev/pytest/pytest_vdb.sh +++ b/dev/pytest/pytest_vdb.sh @@ -6,19 +6,7 @@ cd "$SCRIPT_DIR/../.." PYTEST_TIMEOUT="${PYTEST_TIMEOUT:-180}" -pytest --timeout "${PYTEST_TIMEOUT}" api/tests/integration_tests/vdb/chroma \ - api/tests/integration_tests/vdb/milvus \ - api/tests/integration_tests/vdb/pgvecto_rs \ - api/tests/integration_tests/vdb/pgvector \ - api/tests/integration_tests/vdb/qdrant \ - api/tests/integration_tests/vdb/weaviate \ - api/tests/integration_tests/vdb/elasticsearch \ - api/tests/integration_tests/vdb/vikingdb \ - api/tests/integration_tests/vdb/baidu \ - api/tests/integration_tests/vdb/tcvectordb \ - api/tests/integration_tests/vdb/upstash \ - api/tests/integration_tests/vdb/couchbase \ - api/tests/integration_tests/vdb/oceanbase \ - api/tests/integration_tests/vdb/tidb_vector \ - api/tests/integration_tests/vdb/huawei \ - api/tests/integration_tests/vdb/hologres \ +uv sync --project api --group dev + +uv run --project api pytest --timeout "${PYTEST_TIMEOUT}" \ + api/providers/vdb/*/tests/integration_tests \ diff --git a/dev/start-docker-compose b/dev/start-docker-compose index aa4f66a6cf..1321c3210f 100755 --- a/dev/start-docker-compose +++ b/dev/start-docker-compose @@ -1,8 +1,8 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(dirname "$(realpath "$0")")" -ROOT="$(dirname "$SCRIPT_DIR")" - -cd "$ROOT/docker" -docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$ROOT/docker" +docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d diff --git a/docker/.env.example b/docker/.env.example index f20d57c71a..4426a882f1 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -373,6 +373,20 @@ REDIS_USE_CLUSTERS=false REDIS_CLUSTERS= REDIS_CLUSTERS_PASSWORD= +# Redis connection and retry configuration +# max redis retry +REDIS_RETRY_RETRIES=3 +# Base delay (in seconds) for exponential backoff on retries +REDIS_RETRY_BACKOFF_BASE=1.0 +# Cap (in seconds) for exponential backoff on retries +REDIS_RETRY_BACKOFF_CAP=10.0 +# Timeout (in seconds) for Redis socket operations +REDIS_SOCKET_TIMEOUT=5.0 +# Timeout (in seconds) for establishing a Redis connection +REDIS_SOCKET_CONNECT_TIMEOUT=5.0 +# Interval (in seconds) for Redis health checks +REDIS_HEALTH_CHECK_INTERVAL=30 + # ------------------------------ # Celery Configuration # ------------------------------ @@ -455,6 +469,7 @@ S3_REGION=us-east-1 S3_BUCKET_NAME=difyai S3_ACCESS_KEY= S3_SECRET_KEY= +S3_ADDRESS_STYLE=auto # Whether to use AWS managed IAM roles for authenticating with the S3 service. # If set to false, the access key and secret key must be provided. S3_USE_AWS_MANAGED_IAM=false @@ -1173,6 +1188,14 @@ MAX_ITERATIONS_NUM=99 # The timeout for the text generation in millisecond TEXT_GENERATION_TIMEOUT_MS=60000 +# Enable the experimental vinext runtime shipped in the image. +EXPERIMENTAL_ENABLE_VINEXT=false + +# Allow inline style attributes in Markdown rendering. +# Enable this if your workflows use Jinja2 templates with styled HTML. +# Only recommended for self-hosted deployments with trusted content. +ALLOW_INLINE_STYLES=false + # Allow rendering unsafe URLs which have "data:" scheme. ALLOW_UNSAFE_DATA_SCHEME=false diff --git a/docker/dify-env-sync.py b/docker/dify-env-sync.py index d7c762748c..afa39d8451 100755 --- a/docker/dify-env-sync.py +++ b/docker/dify-env-sync.py @@ -172,7 +172,10 @@ def analyze_value_change(current: str, recommended: str) -> str | None: return None # Boolean comparison - if current.lower() in {"true", "false"} and recommended.lower() in {"true", "false"}: + if current.lower() in {"true", "false"} and recommended.lower() in { + "true", + "false", + }: if current.lower() != recommended.lower(): return colorize(BLUE, f" -> Boolean value change ({current} -> {recommended})") return None @@ -187,7 +190,10 @@ def analyze_value_change(current: str, recommended: str) -> str | None: # String length if len(current) != len(recommended): - return colorize(YELLOW, f" -> String length change ({len(current)} -> {len(recommended)} characters)") + return colorize( + YELLOW, + f" -> String length change ({len(current)} -> {len(recommended)} characters)", + ) return None @@ -311,7 +317,10 @@ def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tup env_var_pattern = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=") - with example_file.open(encoding="utf-8") as src, new_env_file.open("w", encoding="utf-8") as dst: + with ( + example_file.open(encoding="utf-8") as src, + new_env_file.open("w", encoding="utf-8") as dst, + ): for line in src: raw_line = line.rstrip("\n") match = env_var_pattern.match(raw_line) diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 5234202a62..4f4b3851f6 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -161,9 +161,11 @@ services: NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} SENTRY_DSN: ${WEB_SENTRY_DSN:-} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} + EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d03835e2b0..1fc1cfdf9e 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -100,6 +100,12 @@ x-shared-env: &shared-api-worker-env REDIS_USE_CLUSTERS: ${REDIS_USE_CLUSTERS:-false} REDIS_CLUSTERS: ${REDIS_CLUSTERS:-} REDIS_CLUSTERS_PASSWORD: ${REDIS_CLUSTERS_PASSWORD:-} + REDIS_RETRY_RETRIES: ${REDIS_RETRY_RETRIES:-3} + REDIS_RETRY_BACKOFF_BASE: ${REDIS_RETRY_BACKOFF_BASE:-1.0} + REDIS_RETRY_BACKOFF_CAP: ${REDIS_RETRY_BACKOFF_CAP:-10.0} + REDIS_SOCKET_TIMEOUT: ${REDIS_SOCKET_TIMEOUT:-5.0} + REDIS_SOCKET_CONNECT_TIMEOUT: ${REDIS_SOCKET_CONNECT_TIMEOUT:-5.0} + REDIS_HEALTH_CHECK_INTERVAL: ${REDIS_HEALTH_CHECK_INTERVAL:-30} CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://:difyai123456@redis:6379/1} CELERY_BACKEND: ${CELERY_BACKEND:-redis} BROKER_USE_SSL: ${BROKER_USE_SSL:-false} @@ -125,6 +131,7 @@ x-shared-env: &shared-api-worker-env S3_BUCKET_NAME: ${S3_BUCKET_NAME:-difyai} S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} S3_SECRET_KEY: ${S3_SECRET_KEY:-} + S3_ADDRESS_STYLE: ${S3_ADDRESS_STYLE:-auto} S3_USE_AWS_MANAGED_IAM: ${S3_USE_AWS_MANAGED_IAM:-false} ARCHIVE_STORAGE_ENABLED: ${ARCHIVE_STORAGE_ENABLED:-false} ARCHIVE_STORAGE_ENDPOINT: ${ARCHIVE_STORAGE_ENDPOINT:-} @@ -509,6 +516,8 @@ x-shared-env: &shared-api-worker-env MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} + EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} + ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50} PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} @@ -870,9 +879,11 @@ services: NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} SENTRY_DSN: ${WEB_SENTRY_DSN:-} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} + EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index ae642768f5..da3d76210d 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -165,3 +165,132 @@ Open the HTML report locally with: ```bash open cucumber-report/report.html ``` + +## Writing new scenarios + +### Workflow + +1. Create a `.feature` file under `features//` +2. Add step definitions under `features/step-definitions//` +3. Reuse existing steps from `common/` and other definition files before writing new ones +4. Run with `pnpm -C e2e e2e -- --tags @your-tag` to verify +5. Run `pnpm -C e2e check` before committing + +### Feature file conventions + +Tag every feature or scenario with a capability tag. Add auth tags only when they clarify intent or change the browser session behavior: + +```gherkin +@datasets @authenticated +Feature: Create dataset + Scenario: Create a new empty dataset + Given I am signed in as the default E2E admin + When I open the datasets page + ... +``` + +- Capability tags (`@apps`, `@auth`, `@datasets`, …) group related scenarios for selective runs +- Auth/session tags: + - default behavior — scenarios run with the shared authenticated storageState unless marked otherwise + - `@unauthenticated` — uses a clean BrowserContext with no cookies or storage + - `@authenticated` — optional intent tag for readability or selective runs; it does not currently change hook behavior on its own +- `@fresh` — only runs in `e2e:full` mode (requires uninitialized instance) +- `@skip` — excluded from all runs + +Keep scenarios short and declarative. Each step should describe **what** the user does, not **how** the UI works. + +### Step definition conventions + +```typescript +import { When, Then } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import type { DifyWorld } from '../../support/world' + +When('I open the datasets page', async function (this: DifyWorld) { + await this.getPage().goto('/datasets') +}) +``` + +Rules: + +- Always type `this` as `DifyWorld` for proper context access +- Use `async function` (not arrow functions — Cucumber binds `this`) +- One step = one user-visible action or one assertion +- Keep steps stateless across scenarios; use `DifyWorld` properties for in-scenario state + +### Locator priority + +Follow the Playwright recommended locator strategy, in order of preference: + +| Priority | Locator | Example | When to use | +| -------- | ------------------ | ----------------------------------------- | ----------------------------------------- | +| 1 | `getByRole` | `getByRole('button', { name: 'Create' })` | Default choice — accessible and resilient | +| 2 | `getByLabel` | `getByLabel('App name')` | Form inputs with visible labels | +| 3 | `getByPlaceholder` | `getByPlaceholder('Enter name')` | Inputs without visible labels | +| 4 | `getByText` | `getByText('Welcome')` | Static text content | +| 5 | `getByTestId` | `getByTestId('workflow-canvas')` | Only when no semantic locator works | + +Avoid raw CSS/XPath selectors. They break when the DOM structure changes. + +### Assertions + +Use `@playwright/test` `expect` — it auto-waits and retries until the condition is met or the timeout expires: + +```typescript +// URL assertion +await expect(page).toHaveURL(/\/datasets\/[a-f0-9-]+\/documents/) + +// Element visibility +await expect(page.getByRole('button', { name: 'Save' })).toBeVisible() + +// Element state +await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled() + +// Negation +await expect(page.getByText('Loading')).not.toBeVisible() +``` + +Do not use manual `waitForTimeout` or polling loops. If you need a longer wait for a specific assertion, pass `{ timeout: 30_000 }` to the assertion. + +### Cucumber expressions + +Use Cucumber expression parameter types to extract values from Gherkin steps: + +| Type | Pattern | Example step | +| ---------- | ------------- | ---------------------------------- | +| `{string}` | Quoted string | `I select the "Workflow" app type` | +| `{int}` | Integer | `I should see {int} items` | +| `{float}` | Decimal | `the progress is {float} percent` | +| `{word}` | Single word | `I click the {word} tab` | + +Prefer `{string}` for UI labels, names, and text content — it maps naturally to Gherkin's quoted values. + +### Scoping locators + +When the page has multiple similar elements, scope locators to a container: + +```typescript +When('I fill in the app name in the dialog', async function (this: DifyWorld) { + const dialog = this.getPage().getByRole('dialog') + await dialog.getByPlaceholder('Give your app a name').fill('My App') +}) +``` + +### Failure diagnostics + +The `After` hook automatically captures on failure: + +- Full-page screenshot (PNG) +- Page HTML dump +- Console errors and page errors + +Artifacts are saved to `cucumber-report/artifacts/` and attached to the HTML report. No extra code needed in step definitions. + +## Reusing existing steps + +Before writing a new step definition, inspect the existing step definition files first. Reuse a matching step when the wording and behavior already fit, and only add a new step when the scenario needs a genuinely new user action or assertion. Steps in `common/` are designed for broad reuse across all features. + +Or browse the step definition files directly: + +- `features/step-definitions/common/` — auth guards and navigation assertions shared by all features +- `features/step-definitions//` — domain-specific steps scoped to a single feature area diff --git a/e2e/features/apps/create-chatbot-app.feature b/e2e/features/apps/create-chatbot-app.feature new file mode 100644 index 0000000000..4f506e4f40 --- /dev/null +++ b/e2e/features/apps/create-chatbot-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated +Feature: Create Chatbot app + Scenario: Create a new Chatbot app and redirect to the configuration page + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I expand the beginner app types + And I select the "Chatbot" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the app configuration page diff --git a/e2e/features/apps/create-workflow-app.feature b/e2e/features/apps/create-workflow-app.feature new file mode 100644 index 0000000000..b88d94d899 --- /dev/null +++ b/e2e/features/apps/create-workflow-app.feature @@ -0,0 +1,10 @@ +@apps @authenticated +Feature: Create Workflow app + Scenario: Create a new Workflow app and redirect to the workflow editor + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I select the "Workflow" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the workflow editor diff --git a/e2e/features/auth/sign-out.feature b/e2e/features/auth/sign-out.feature new file mode 100644 index 0000000000..9112f1220a --- /dev/null +++ b/e2e/features/auth/sign-out.feature @@ -0,0 +1,18 @@ +@auth @authenticated +Feature: Sign out + Scenario: Sign out from the apps console + Given I am signed in as the default E2E admin + When I open the apps console + And I open the account menu + And I sign out + Then I should be on the sign-in page + + Scenario: Redirect back to sign-in when reopening the apps console after signing out + Given I am signed in as the default E2E admin + When I open the apps console + And I open the account menu + And I sign out + Then I should be on the sign-in page + When I open the apps console + Then I should be redirected to the signin page + And I should see the "Sign in" button diff --git a/e2e/features/smoke/unauthenticated-entry.feature b/e2e/features/smoke/unauthenticated-entry.feature new file mode 100644 index 0000000000..a2783c1cba --- /dev/null +++ b/e2e/features/smoke/unauthenticated-entry.feature @@ -0,0 +1,7 @@ +@smoke @unauthenticated +Feature: Unauthenticated app console entry + Scenario: Redirect to the sign-in page when opening the apps console without logging in + Given I am not signed in + When I open the apps console + Then I should be redirected to the signin page + And I should see the "Sign in" button diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index b8e76c6f06..6bc9ae30b6 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -24,6 +24,30 @@ When('I confirm app creation', async function (this: DifyWorld) { await createButton.click() }) +When('I select the {string} app type', async function (this: DifyWorld, appType: string) { + const dialog = this.getPage().getByRole('dialog') + const appTypeTitle = dialog.getByText(appType, { exact: true }) + + await expect(appTypeTitle).toBeVisible() + await appTypeTitle.click() +}) + +When('I expand the beginner app types', async function (this: DifyWorld) { + const page = this.getPage() + const toggle = page.getByRole('button', { name: 'More basic app types' }) + + await expect(toggle).toBeVisible() + await toggle.click() +}) + Then('I should land on the app editor', async function (this: DifyWorld) { await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/(workflow|configuration)(?:\?.*)?$/) }) + +Then('I should land on the workflow editor', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/workflow(?:\?.*)?$/) +}) + +Then('I should land on the app configuration page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/configuration(?:\?.*)?$/) +}) diff --git a/e2e/features/step-definitions/auth/sign-out.steps.ts b/e2e/features/step-definitions/auth/sign-out.steps.ts new file mode 100644 index 0000000000..935b73c3af --- /dev/null +++ b/e2e/features/step-definitions/auth/sign-out.steps.ts @@ -0,0 +1,25 @@ +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import type { DifyWorld } from '../../support/world' + +When('I open the account menu', async function (this: DifyWorld) { + const page = this.getPage() + const trigger = page.getByRole('button', { name: 'Account' }) + + await expect(trigger).toBeVisible() + await trigger.click() +}) + +When('I sign out', async function (this: DifyWorld) { + const page = this.getPage() + + await expect(page.getByText('Log out')).toBeVisible() + await page.getByText('Log out').click() +}) + +Then('I should be on the sign-in page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/signin/) + await expect(this.getPage().getByRole('button', { name: /^Sign in$/i })).toBeVisible({ + timeout: 30_000, + }) +}) diff --git a/e2e/features/step-definitions/common/auth.steps.ts b/e2e/features/step-definitions/common/auth.steps.ts index bf03c2d8f4..bed35244c5 100644 --- a/e2e/features/step-definitions/common/auth.steps.ts +++ b/e2e/features/step-definitions/common/auth.steps.ts @@ -9,3 +9,10 @@ Given('I am signed in as the default E2E admin', async function (this: DifyWorld 'text/plain', ) }) + +Given('I am not signed in', async function (this: DifyWorld) { + this.attach( + 'Using a clean browser context without the shared authenticated storage state.', + 'text/plain', + ) +}) diff --git a/e2e/features/step-definitions/common/navigation.steps.ts b/e2e/features/step-definitions/common/navigation.steps.ts index b18ff035fa..28e6953d65 100644 --- a/e2e/features/step-definitions/common/navigation.steps.ts +++ b/e2e/features/step-definitions/common/navigation.steps.ts @@ -10,6 +10,10 @@ Then('I should stay on the apps console', async function (this: DifyWorld) { await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/) }) +Then('I should be redirected to the signin page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/signin(?:\?.*)?$/) +}) + Then('I should see the {string} button', async function (this: DifyWorld, label: string) { await expect(this.getPage().getByRole('button', { name: label })).toBeVisible() }) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index a6862d79f5..7a8319463b 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -3,7 +3,7 @@ import { chromium, type Browser } from '@playwright/test' import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { ensureAuthenticatedState } from '../../fixtures/auth' +import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth' import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env' import type { DifyWorld } from './world' @@ -31,7 +31,7 @@ const writeArtifact = async ( return artifactPath } -BeforeAll(async () => { +BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => { await mkdir(artifactsDir, { recursive: true }) browser = await chromium.launch({ @@ -46,7 +46,11 @@ BeforeAll(async () => { Before(async function (this: DifyWorld, { pickle }) { if (!browser) throw new Error('Shared Playwright browser is not available.') - await this.startAuthenticatedSession(browser) + const isUnauthenticatedScenario = pickle.tags.some((tag) => tag.name === '@unauthenticated') + + if (isUnauthenticatedScenario) await this.startUnauthenticatedSession(browser) + else await this.startAuthenticatedSession(browser) + this.scenarioStartedAt = Date.now() const tags = pickle.tags.map((tag) => tag.name).join(' ') diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 15ab8daf16..bf63199107 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -25,12 +25,12 @@ export class DifyWorld extends World { this.pageErrors = [] } - async startAuthenticatedSession(browser: Browser) { + async startSession(browser: Browser, authenticated: boolean) { this.resetScenarioState() this.context = await browser.newContext({ baseURL, locale: defaultLocale, - storageState: authStatePath, + ...(authenticated ? { storageState: authStatePath } : {}), }) this.context.setDefaultTimeout(30_000) this.page = await this.context.newPage() @@ -44,6 +44,14 @@ export class DifyWorld extends World { }) } + async startAuthenticatedSession(browser: Browser) { + await this.startSession(browser, true) + } + + async startUnauthenticatedSession(browser: Browser) { + await this.startSession(browser, false) + } + getPage() { if (!this.page) throw new Error('Playwright page has not been initialized for this scenario.') diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index 853bfff5ed..14aee52634 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -12,7 +12,7 @@ export type AuthSessionMetadata = { usedInitPassword: boolean } -const WAIT_TIMEOUT_MS = 120_000 +export const AUTH_BOOTSTRAP_TIMEOUT_MS = 120_000 const e2eRoot = fileURLToPath(new URL('..', import.meta.url)) export const authDir = path.join(e2eRoot, '.auth') @@ -39,40 +39,54 @@ const escapeRegex = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, ' const appURL = (baseURL: string, pathname: string) => new URL(pathname, baseURL).toString() -const waitForPageState = async (page: Page) => { +type AuthPageState = 'install' | 'login' | 'init' + +const getRemainingTimeout = (deadline: number) => Math.max(deadline - Date.now(), 1) + +const waitForPageState = async (page: Page, deadline: number): Promise => { const installHeading = page.getByRole('heading', { name: 'Setting up an admin account' }) const signInButton = page.getByRole('button', { name: 'Sign in' }) const initPasswordField = page.getByLabel('Admin initialization password') - const deadline = Date.now() + WAIT_TIMEOUT_MS - - while (Date.now() < deadline) { - if (await installHeading.isVisible().catch(() => false)) return 'install' as const - if (await signInButton.isVisible().catch(() => false)) return 'login' as const - if (await initPasswordField.isVisible().catch(() => false)) return 'init' as const - - await page.waitForTimeout(1_000) + try { + return await Promise.any([ + installHeading + .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) + .then(() => 'install'), + signInButton + .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) + .then(() => 'login'), + initPasswordField + .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) + .then(() => 'init'), + ]) + } catch { + throw new Error(`Unable to determine auth page state for ${page.url()}`) } - - throw new Error(`Unable to determine auth page state for ${page.url()}`) } -const completeInitPasswordIfNeeded = async (page: Page) => { +const completeInitPasswordIfNeeded = async (page: Page, deadline: number) => { const initPasswordField = page.getByLabel('Admin initialization password') - if (!(await initPasswordField.isVisible({ timeout: 3_000 }).catch(() => false))) return false + + const needsInitPassword = await initPasswordField + .waitFor({ state: 'visible', timeout: Math.min(getRemainingTimeout(deadline), 3_000) }) + .then(() => true) + .catch(() => false) + + if (!needsInitPassword) return false await initPasswordField.fill(initPassword) await page.getByRole('button', { name: 'Validate' }).click() await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({ - timeout: WAIT_TIMEOUT_MS, + timeout: getRemainingTimeout(deadline), }) return true } -const completeInstall = async (page: Page, baseURL: string) => { +const completeInstall = async (page: Page, baseURL: string, deadline: number) => { await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({ - timeout: WAIT_TIMEOUT_MS, + timeout: getRemainingTimeout(deadline), }) await page.getByLabel('Email address').fill(adminCredentials.email) @@ -81,13 +95,13 @@ const completeInstall = async (page: Page, baseURL: string) => { await page.getByRole('button', { name: 'Set up' }).click() await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), { - timeout: WAIT_TIMEOUT_MS, + timeout: getRemainingTimeout(deadline), }) } -const completeLogin = async (page: Page, baseURL: string) => { +const completeLogin = async (page: Page, baseURL: string, deadline: number) => { await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible({ - timeout: WAIT_TIMEOUT_MS, + timeout: getRemainingTimeout(deadline), }) await page.getByLabel('Email address').fill(adminCredentials.email) @@ -95,12 +109,13 @@ const completeLogin = async (page: Page, baseURL: string) => { await page.getByRole('button', { name: 'Sign in' }).click() await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), { - timeout: WAIT_TIMEOUT_MS, + timeout: getRemainingTimeout(deadline), }) } export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => { const baseURL = resolveBaseURL(configuredBaseURL) + const deadline = Date.now() + AUTH_BOOTSTRAP_TIMEOUT_MS await mkdir(authDir, { recursive: true }) @@ -111,25 +126,28 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU const page = await context.newPage() try { - await page.goto(appURL(baseURL, '/install'), { waitUntil: 'networkidle' }) + await page.goto(appURL(baseURL, '/install'), { + timeout: getRemainingTimeout(deadline), + waitUntil: 'domcontentloaded', + }) - let usedInitPassword = await completeInitPasswordIfNeeded(page) - let pageState = await waitForPageState(page) + let usedInitPassword = await completeInitPasswordIfNeeded(page, deadline) + let pageState = await waitForPageState(page, deadline) while (pageState === 'init') { - const completedInitPassword = await completeInitPasswordIfNeeded(page) + const completedInitPassword = await completeInitPasswordIfNeeded(page, deadline) if (!completedInitPassword) throw new Error(`Unable to validate initialization password for ${page.url()}`) usedInitPassword = true - pageState = await waitForPageState(page) + pageState = await waitForPageState(page, deadline) } - if (pageState === 'install') await completeInstall(page, baseURL) - else await completeLogin(page, baseURL) + if (pageState === 'install') await completeInstall(page, baseURL, deadline) + else await completeLogin(page, baseURL, deadline) await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible({ - timeout: WAIT_TIMEOUT_MS, + timeout: getRemainingTimeout(deadline), }) await context.storageState({ path: authStatePath }) diff --git a/e2e/package.json b/e2e/package.json index 0ee2afff7f..925418f223 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -19,6 +19,7 @@ "@types/node": "catalog:", "tsx": "catalog:", "typescript": "catalog:", + "vite": "catalog:", "vite-plus": "catalog:" } } diff --git a/e2e/scripts/common.ts b/e2e/scripts/common.ts index bb82121079..5bca7fb4c9 100644 --- a/e2e/scripts/common.ts +++ b/e2e/scripts/common.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from 'node:child_process' +import { createHash } from 'node:crypto' import { access, copyFile, readFile, writeFile } from 'node:fs/promises' import net from 'node:net' import path from 'node:path' @@ -38,6 +39,10 @@ export const middlewareEnvExampleFile = path.join(dockerDir, 'middleware.env.exa export const webEnvLocalFile = path.join(webDir, '.env.local') export const webEnvExampleFile = path.join(webDir, '.env.example') export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example') +export const e2eWebEnvOverrides = { + NEXT_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/console/api', + NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/api', +} satisfies Record const formatCommand = (command: string, args: string[]) => [command, ...args].join(' ') @@ -166,13 +171,16 @@ export const ensureLineInFile = async (filePath: string, line: string) => { await writeFile(filePath, `${normalizedContent}${line}\n`, 'utf8') } -export const ensureWebEnvLocal = async () => { - await ensureFileExists(webEnvLocalFile, webEnvExampleFile) - - const fileContent = await readFile(webEnvLocalFile, 'utf8') - const nextContent = fileContent.replaceAll('http://localhost:5001', 'http://127.0.0.1:5001') - - if (nextContent !== fileContent) await writeFile(webEnvLocalFile, nextContent, 'utf8') +export const getWebEnvLocalHash = async () => { + const fileContent = await readFile(webEnvLocalFile, 'utf8').catch(() => '') + return createHash('sha256') + .update( + JSON.stringify({ + envLocal: fileContent, + overrides: e2eWebEnvOverrides, + }), + ) + .digest('hex') } export const readSimpleDotenv = async (filePath: string) => { diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index 6f38598df4..4bd9de09d2 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -1,14 +1,15 @@ -import { access, mkdir, rm } from 'node:fs/promises' +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { waitForUrl } from '../support/process' import { apiDir, apiEnvExampleFile, dockerDir, + e2eWebEnvOverrides, e2eDir, ensureFileExists, ensureLineInFile, - ensureWebEnvLocal, + getWebEnvLocalHash, isMainModule, isTcpPortReachable, middlewareComposeFile, @@ -23,6 +24,7 @@ import { } from './common' const buildIdPath = path.join(webDir, '.next', 'BUILD_ID') +const webBuildEnvStampPath = path.join(webDir, '.next', 'e2e-web-env.sha256') const middlewareDataPaths = [ path.join(dockerDir, 'volumes', 'db', 'data'), @@ -110,27 +112,47 @@ const waitForDependency = async ({ } export const ensureWebBuild = async () => { - await ensureWebEnvLocal() + const envHash = await getWebEnvLocalHash() + const buildEnv = { + ...e2eWebEnvOverrides, + } if (process.env.E2E_FORCE_WEB_BUILD === '1') { await runCommandOrThrow({ command: 'pnpm', args: ['run', 'build'], cwd: webDir, + env: buildEnv, }) + await writeFile(webBuildEnvStampPath, `${envHash}\n`, 'utf8') return } try { - await access(buildIdPath) - console.log('Reusing existing web build artifact.') + const [buildExists, previousEnvHash] = await Promise.all([ + access(buildIdPath) + .then(() => true) + .catch(() => false), + readFile(webBuildEnvStampPath, 'utf8') + .then((value) => value.trim()) + .catch(() => ''), + ]) + + if (buildExists && previousEnvHash === envHash) { + console.log('Reusing existing web build artifact.') + return + } } catch { - await runCommandOrThrow({ - command: 'pnpm', - args: ['run', 'build'], - cwd: webDir, - }) + // Fall through to rebuild when the existing build cannot be verified. } + + await runCommandOrThrow({ + command: 'pnpm', + args: ['run', 'build'], + cwd: webDir, + env: buildEnv, + }) + await writeFile(webBuildEnvStampPath, `${envHash}\n`, 'utf8') } export const startWeb = async () => { @@ -141,6 +163,7 @@ export const startWeb = async () => { args: ['run', 'start'], cwd: webDir, env: { + ...e2eWebEnvOverrides, HOSTNAME: '127.0.0.1', PORT: '3000', }, @@ -152,14 +175,25 @@ export const startApi = async () => { await runCommandOrThrow({ command: 'uv', - args: ['run', '--project', '.', 'flask', 'upgrade-db'], + args: ['run', '--project', '.', '--no-sync', 'flask', 'upgrade-db'], cwd: apiDir, env, }) await runForegroundProcess({ command: 'uv', - args: ['run', '--project', '.', 'flask', 'run', '--host', '127.0.0.1', '--port', '5001'], + args: [ + 'run', + '--project', + '.', + '--no-sync', + 'flask', + 'run', + '--host', + '127.0.0.1', + '--port', + '5001', + ], cwd: apiDir, env, }) diff --git a/package.json b/package.json index ce3180214b..736a354ef7 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "prepare": "vp config" }, "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2afee91b57..8901c7948f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,23 +7,26 @@ settings: catalogs: default: '@amplitude/analytics-browser': - specifier: 2.38.1 - version: 2.38.1 + specifier: 2.39.0 + version: 2.39.0 '@amplitude/plugin-session-replay-browser': - specifier: 1.27.6 - version: 1.27.6 + specifier: 1.27.7 + version: 1.27.7 '@antfu/eslint-config': - specifier: 8.0.0 - version: 8.0.0 + specifier: 8.2.0 + version: 8.2.0 '@base-ui/react': - specifier: 1.3.0 - version: 1.3.0 + specifier: 1.4.0 + version: 1.4.0 '@chromatic-com/storybook': - specifier: 5.1.1 - version: 5.1.1 + specifier: 5.1.2 + version: 5.1.2 '@cucumber/cucumber': - specifier: 12.7.0 - version: 12.7.0 + specifier: 12.8.0 + version: 12.8.0 + '@date-fns/tz': + specifier: 1.4.1 + version: 1.4.1 '@egoist/tailwindcss-icons': specifier: 1.9.2 version: 1.9.2 @@ -40,17 +43,17 @@ catalogs: specifier: 0.27.19 version: 0.27.19 '@formatjs/intl-localematcher': - specifier: 0.8.2 - version: 0.8.2 + specifier: 0.8.3 + version: 0.8.3 '@headlessui/react': - specifier: 2.2.9 - version: 2.2.9 + specifier: 2.2.10 + version: 2.2.10 '@heroicons/react': specifier: 2.2.0 version: 2.2.0 '@hono/node-server': - specifier: 1.19.12 - version: 1.19.12 + specifier: 1.19.14 + version: 1.19.14 '@iconify-json/heroicons': specifier: 1.2.3 version: 1.2.3 @@ -58,23 +61,23 @@ catalogs: specifier: 1.2.10 version: 1.2.10 '@lexical/link': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.43.0 + version: 0.43.0 '@lexical/list': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.43.0 + version: 0.43.0 '@lexical/react': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.43.0 + version: 0.43.0 '@lexical/selection': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.43.0 + version: 0.43.0 '@lexical/text': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.43.0 + version: 0.43.0 '@lexical/utils': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.43.0 + version: 0.43.0 '@mdx-js/loader': specifier: 3.1.1 version: 3.1.1 @@ -88,23 +91,23 @@ catalogs: specifier: 4.7.0 version: 4.7.0 '@next/eslint-plugin-next': - specifier: 16.2.2 - version: 16.2.2 + specifier: 16.2.3 + version: 16.2.3 '@next/mdx': - specifier: 16.2.2 - version: 16.2.2 + specifier: 16.2.3 + version: 16.2.3 '@orpc/client': - specifier: 1.13.13 - version: 1.13.13 + specifier: 1.13.14 + version: 1.13.14 '@orpc/contract': - specifier: 1.13.13 - version: 1.13.13 + specifier: 1.13.14 + version: 1.13.14 '@orpc/openapi-client': - specifier: 1.13.13 - version: 1.13.13 + specifier: 1.13.14 + version: 1.13.14 '@orpc/tanstack-query': - specifier: 1.13.13 - version: 1.13.13 + specifier: 1.13.14 + version: 1.13.14 '@playwright/test': specifier: 1.59.1 version: 1.59.1 @@ -115,26 +118,26 @@ catalogs: specifier: 4.2.0 version: 4.2.0 '@sentry/react': - specifier: 10.47.0 - version: 10.47.0 + specifier: 10.48.0 + version: 10.48.0 '@storybook/addon-docs': - specifier: 10.3.4 - version: 10.3.4 + specifier: 10.3.5 + version: 10.3.5 '@storybook/addon-links': - specifier: 10.3.4 - version: 10.3.4 + specifier: 10.3.5 + version: 10.3.5 '@storybook/addon-onboarding': - specifier: 10.3.4 - version: 10.3.4 + specifier: 10.3.5 + version: 10.3.5 '@storybook/addon-themes': - specifier: 10.3.4 - version: 10.3.4 + specifier: 10.3.5 + version: 10.3.5 '@storybook/nextjs-vite': - specifier: 10.3.4 - version: 10.3.4 + specifier: 10.3.5 + version: 10.3.5 '@storybook/react': - specifier: 10.3.4 - version: 10.3.4 + specifier: 10.3.5 + version: 10.3.5 '@streamdown/math': specifier: 1.0.2 version: 1.0.2 @@ -154,23 +157,23 @@ catalogs: specifier: 4.2.2 version: 4.2.2 '@tanstack/eslint-plugin-query': - specifier: 5.96.1 - version: 5.96.1 + specifier: 5.99.0 + version: 5.99.0 '@tanstack/react-devtools': - specifier: 0.10.1 - version: 0.10.1 + specifier: 0.10.2 + version: 0.10.2 '@tanstack/react-form': - specifier: 1.28.6 - version: 1.28.6 + specifier: 1.29.0 + version: 1.29.0 '@tanstack/react-form-devtools': - specifier: 0.2.20 - version: 0.2.20 + specifier: 0.2.21 + version: 0.2.21 '@tanstack/react-query': - specifier: 5.96.1 - version: 5.96.1 + specifier: 5.99.0 + version: 5.99.0 '@tanstack/react-query-devtools': - specifier: 5.96.1 - version: 5.96.1 + specifier: 5.99.0 + version: 5.99.0 '@tanstack/react-virtual': specifier: 3.13.23 version: 3.13.23 @@ -187,14 +190,14 @@ catalogs: specifier: 14.6.1 version: 14.6.1 '@tsslint/cli': - specifier: 3.0.2 - version: 3.0.2 + specifier: 3.0.3 + version: 3.0.3 '@tsslint/compat-eslint': - specifier: 3.0.2 - version: 3.0.2 + specifier: 3.0.3 + version: 3.0.3 '@tsslint/config': - specifier: 3.0.2 - version: 3.0.2 + specifier: 3.0.3 + version: 3.0.3 '@types/js-cookie': specifier: 3.0.6 version: 3.0.6 @@ -205,8 +208,8 @@ catalogs: specifier: 0.6.4 version: 0.6.4 '@types/node': - specifier: 25.5.0 - version: 25.5.0 + specifier: 25.6.0 + version: 25.6.0 '@types/qs': specifier: 6.15.0 version: 6.15.0 @@ -220,23 +223,23 @@ catalogs: specifier: 1.15.9 version: 1.15.9 '@typescript-eslint/eslint-plugin': - specifier: 8.58.0 - version: 8.58.0 + specifier: 8.58.2 + version: 8.58.2 '@typescript-eslint/parser': - specifier: 8.58.0 - version: 8.58.0 + specifier: 8.58.2 + version: 8.58.2 '@typescript/native-preview': - specifier: 7.0.0-dev.20260401.1 - version: 7.0.0-dev.20260401.1 + specifier: 7.0.0-dev.20260413.1 + version: 7.0.0-dev.20260413.1 '@vitejs/plugin-react': specifier: 6.0.1 version: 6.0.1 '@vitejs/plugin-rsc': - specifier: 0.5.21 - version: 0.5.21 + specifier: 0.5.24 + version: 0.5.24 '@vitest/coverage-v8': - specifier: 4.1.2 - version: 4.1.2 + specifier: 4.1.4 + version: 4.1.4 abcjs: specifier: 6.6.2 version: 6.6.2 @@ -249,6 +252,9 @@ catalogs: class-variance-authority: specifier: 0.7.1 version: 0.7.1 + client-only: + specifier: 0.0.1 + version: 0.0.1 clsx: specifier: 2.1.1 version: 2.1.1 @@ -264,6 +270,9 @@ catalogs: cron-parser: specifier: 5.5.0 version: 5.5.0 + date-fns: + specifier: 4.1.0 + version: 4.1.0 dayjs: specifier: 1.11.20 version: 1.11.20 @@ -295,23 +304,23 @@ catalogs: specifier: 1.45.1 version: 1.45.1 eslint: - specifier: 10.1.0 - version: 10.1.0 + specifier: 10.2.0 + version: 10.2.0 eslint-markdown: - specifier: 0.6.0 - version: 0.6.0 + specifier: 0.6.1 + version: 0.6.1 eslint-plugin-better-tailwindcss: - specifier: 4.3.2 - version: 4.3.2 + specifier: 4.4.1 + version: 4.4.1 eslint-plugin-hyoban: specifier: 0.14.1 version: 0.14.1 eslint-plugin-markdown-preferences: - specifier: 0.41.0 - version: 0.41.0 + specifier: 0.41.1 + version: 0.41.1 eslint-plugin-no-barrel-files: - specifier: 1.2.2 - version: 1.2.2 + specifier: 1.3.1 + version: 1.3.1 eslint-plugin-react-refresh: specifier: 0.5.2 version: 0.5.2 @@ -319,23 +328,20 @@ catalogs: specifier: 4.0.2 version: 4.0.2 eslint-plugin-storybook: - specifier: 10.3.4 - version: 10.3.4 + specifier: 10.3.5 + version: 10.3.5 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 - foxact: - specifier: 0.3.0 - version: 0.3.0 happy-dom: - specifier: 20.8.9 - version: 20.8.9 + specifier: 20.9.0 + version: 20.9.0 hast-util-to-jsx-runtime: specifier: 2.3.6 version: 2.3.6 hono: - specifier: 4.12.10 - version: 4.12.10 + specifier: 4.12.12 + version: 4.12.12 html-entities: specifier: 2.6.0 version: 2.6.0 @@ -343,8 +349,8 @@ catalogs: specifier: 1.11.13 version: 1.11.13 i18next: - specifier: 26.0.3 - version: 26.0.3 + specifier: 26.0.4 + version: 26.0.4 i18next-resources-to-backend: specifier: 1.2.1 version: 1.2.1 @@ -355,8 +361,8 @@ catalogs: specifier: 11.1.4 version: 11.1.4 jotai: - specifier: 2.19.0 - version: 2.19.0 + specifier: 2.19.1 + version: 2.19.1 js-audio-recorder: specifier: 1.0.7 version: 1.0.7 @@ -370,20 +376,20 @@ catalogs: specifier: 1.5.0 version: 1.5.0 katex: - specifier: 0.16.44 - version: 0.16.44 + specifier: 0.16.45 + version: 0.16.45 knip: - specifier: 6.2.0 - version: 6.2.0 + specifier: 6.4.1 + version: 6.4.1 ky: - specifier: 1.14.3 - version: 1.14.3 + specifier: 2.0.0 + version: 2.0.0 lamejs: specifier: 1.2.1 version: 1.2.1 lexical: - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.43.0 + version: 0.43.0 mermaid: specifier: 11.14.0 version: 11.14.0 @@ -397,8 +403,8 @@ catalogs: specifier: 1.0.0 version: 1.0.0 next: - specifier: 16.2.2 - version: 16.2.2 + specifier: 16.2.3 + version: 16.2.3 next-themes: specifier: 0.4.6 version: 0.4.6 @@ -406,26 +412,26 @@ catalogs: specifier: 2.8.9 version: 2.8.9 pinyin-pro: - specifier: 3.28.0 - version: 3.28.0 + specifier: 3.28.1 + version: 3.28.1 postcss: - specifier: 8.5.8 - version: 8.5.8 + specifier: 8.5.9 + version: 8.5.9 qrcode.react: specifier: 4.2.0 version: 4.2.0 qs: - specifier: 6.15.0 - version: 6.15.0 + specifier: 6.15.1 + version: 6.15.1 react: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.5 + version: 19.2.5 react-18-input-autosize: specifier: 3.0.0 version: 3.0.0 react-dom: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.5 + version: 19.2.5 react-easy-crop: specifier: 5.5.7 version: 5.5.7 @@ -433,8 +439,8 @@ catalogs: specifier: 5.2.4 version: 5.2.4 react-i18next: - specifier: 17.0.2 - version: 17.0.2 + specifier: 16.5.8 + version: 16.5.8 react-multi-email: specifier: 1.0.25 version: 1.0.25 @@ -445,8 +451,8 @@ catalogs: specifier: 8.0.0-rc.0 version: 8.0.0-rc.0 react-server-dom-webpack: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.5 + version: 19.2.5 react-sortablejs: specifier: 6.1.4 version: 6.1.4 @@ -478,8 +484,8 @@ catalogs: specifier: 1.0.8 version: 1.0.8 storybook: - specifier: 10.3.4 - version: 10.3.4 + specifier: 10.3.5 + version: 10.3.5 streamdown: specifier: 2.5.0 version: 2.5.0 @@ -493,8 +499,8 @@ catalogs: specifier: 4.2.2 version: 4.2.2 tldts: - specifier: 7.0.27 - version: 7.0.27 + specifier: 7.0.28 + version: 7.0.28 tsx: specifier: 4.21.0 version: 4.21.0 @@ -514,14 +520,14 @@ catalogs: specifier: 13.0.0 version: 13.0.0 vinext: - specifier: 0.0.39 - version: 0.0.39 + specifier: 0.0.41 + version: 0.0.41 vite-plugin-inspect: specifier: 12.0.0-beta.1 version: 12.0.0-beta.1 vite-plus: - specifier: 0.1.15 - version: 0.1.15 + specifier: 0.1.16 + version: 0.1.16 vitest-canvas-mock: specifier: 1.1.4 version: 1.1.4 @@ -559,8 +565,8 @@ overrides: svgo@>=3.0.0 <3.3.3: 3.3.3 tar@<=7.5.10: 7.5.11 undici@>=7.0.0 <7.24.0: 7.24.0 - vite: npm:@voidzero-dev/vite-plus-core@0.1.15 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.15 + vite: npm:@voidzero-dev/vite-plus-core@0.1.16 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.16 yaml@>=2.0.0 <2.8.3: 2.8.3 yauzl@<3.2.1: 3.2.1 @@ -568,30 +574,36 @@ importers: .: devDependencies: + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 + version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) e2e: devDependencies: '@cucumber/cucumber': specifier: 'catalog:' - version: 12.7.0 + version: 12.8.0 '@playwright/test': specifier: 'catalog:' version: 1.59.1 '@types/node': specifier: 'catalog:' - version: 25.5.0 + version: 25.6.0 tsx: specifier: 'catalog:' version: 4.21.0 typescript: specifier: 'catalog:' version: 6.0.2 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 + version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) packages/iconify-collections: devDependencies: @@ -603,103 +615,109 @@ importers: devDependencies: '@eslint/js': specifier: 'catalog:' - version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) + version: 10.0.1(eslint@10.2.0(jiti@2.6.1)) '@types/node': specifier: 'catalog:' - version: 25.5.0 + version: 25.6.0 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + version: 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.2(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + version: 4.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) eslint: specifier: 'catalog:' - version: 10.1.0(jiti@2.6.1) + version: 10.2.0(jiti@2.6.1) typescript: specifier: 'catalog:' version: 6.0.2 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 + version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.15 - version: '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-test@0.1.16 + version: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' web: dependencies: '@amplitude/analytics-browser': specifier: 'catalog:' - version: 2.38.1 + version: 2.39.0 '@amplitude/plugin-session-replay-browser': specifier: 'catalog:' - version: 1.27.6(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0) + version: 1.27.7(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0) '@base-ui/react': specifier: 'catalog:' - version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.4.0(@date-fns/tz@1.4.1)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@date-fns/tz': + specifier: 'catalog:' + version: 1.4.1 '@emoji-mart/data': specifier: 'catalog:' version: 1.2.1 '@floating-ui/react': specifier: 'catalog:' - version: 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@formatjs/intl-localematcher': specifier: 'catalog:' - version: 0.8.2 + version: 0.8.3 '@headlessui/react': specifier: 'catalog:' - version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 2.2.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@heroicons/react': specifier: 'catalog:' - version: 2.2.0(react@19.2.4) + version: 2.2.0(react@19.2.5) '@lexical/code': specifier: npm:lexical-code-no-prism@0.41.0 - version: lexical-code-no-prism@0.41.0(@lexical/utils@0.42.0)(lexical@0.42.0) + version: lexical-code-no-prism@0.41.0(@lexical/utils@0.43.0)(lexical@0.43.0) '@lexical/link': specifier: 'catalog:' - version: 0.42.0 + version: 0.43.0 '@lexical/list': specifier: 'catalog:' - version: 0.42.0 + version: 0.43.0 '@lexical/react': specifier: 'catalog:' - version: 0.42.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.30) + version: 0.43.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yjs@13.6.30) '@lexical/selection': specifier: 'catalog:' - version: 0.42.0 + version: 0.43.0 '@lexical/text': specifier: 'catalog:' - version: 0.42.0 + version: 0.43.0 '@lexical/utils': specifier: 'catalog:' - version: 0.42.0 + version: 0.43.0 '@monaco-editor/react': specifier: 'catalog:' - version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@orpc/client': specifier: 'catalog:' - version: 1.13.13 + version: 1.13.14 '@orpc/contract': specifier: 'catalog:' - version: 1.13.13 + version: 1.13.14 '@orpc/openapi-client': specifier: 'catalog:' - version: 1.13.13 + version: 1.13.14 '@orpc/tanstack-query': specifier: 'catalog:' - version: 1.13.13(@orpc/client@1.13.13)(@tanstack/query-core@5.96.1) + version: 1.13.14(@orpc/client@1.13.14)(@tanstack/query-core@5.99.0) '@remixicon/react': specifier: 'catalog:' - version: 4.9.0(react@19.2.4) + version: 4.9.0(react@19.2.5) '@sentry/react': specifier: 'catalog:' - version: 10.47.0(react@19.2.4) + version: 10.48.0(react@19.2.5) '@streamdown/math': specifier: 'catalog:' - version: 1.0.2(react@19.2.4) + version: 1.0.2(react@19.2.5) '@svgdotjs/svg.js': specifier: 'catalog:' version: 3.2.5 @@ -711,34 +729,40 @@ importers: version: 0.5.19(tailwindcss@4.2.2) '@tanstack/react-form': specifier: 'catalog:' - version: 1.28.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.29.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-query': specifier: 'catalog:' - version: 5.96.1(react@19.2.4) + version: 5.99.0(react@19.2.5) '@tanstack/react-virtual': specifier: 'catalog:' - version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) abcjs: specifier: 'catalog:' version: 6.6.2 ahooks: specifier: 'catalog:' - version: 3.9.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 3.9.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: 'catalog:' version: 0.7.1 + client-only: + specifier: 'catalog:' + version: 0.0.1 clsx: specifier: 'catalog:' version: 2.1.1 cmdk: specifier: 'catalog:' - version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) copy-to-clipboard: specifier: 'catalog:' version: 3.3.3 cron-parser: specifier: 'catalog:' version: 5.5.0 + date-fns: + specifier: 'catalog:' + version: 4.1.0 dayjs: specifier: 'catalog:' version: 1.11.20 @@ -753,7 +777,7 @@ importers: version: 6.0.0 echarts-for-react: specifier: 'catalog:' - version: 3.0.6(echarts@6.0.0)(react@19.2.4) + version: 3.0.6(echarts@6.0.0)(react@19.2.5) elkjs: specifier: 'catalog:' version: 0.11.1 @@ -762,7 +786,7 @@ importers: version: 8.6.0(embla-carousel@8.6.0) embla-carousel-react: specifier: 'catalog:' - version: 8.6.0(react@19.2.4) + version: 8.6.0(react@19.2.5) emoji-mart: specifier: 'catalog:' version: 5.6.0 @@ -772,9 +796,6 @@ importers: fast-deep-equal: specifier: 'catalog:' version: 3.1.3 - foxact: - specifier: 'catalog:' - version: 0.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) hast-util-to-jsx-runtime: specifier: 'catalog:' version: 2.3.6 @@ -786,7 +807,7 @@ importers: version: 1.11.13 i18next: specifier: 'catalog:' - version: 26.0.3(typescript@6.0.2) + version: 26.0.4(typescript@6.0.2) i18next-resources-to-backend: specifier: 'catalog:' version: 1.2.1 @@ -795,7 +816,7 @@ importers: version: 11.1.4 jotai: specifier: 'catalog:' - version: 2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) js-audio-recorder: specifier: 'catalog:' version: 1.0.7 @@ -810,16 +831,16 @@ importers: version: 1.5.0 katex: specifier: 'catalog:' - version: 0.16.44 + version: 0.16.45 ky: specifier: 'catalog:' - version: 1.14.3 + version: 2.0.0 lamejs: specifier: 'catalog:' version: 1.2.1 lexical: specifier: 'catalog:' - version: 0.42.0 + version: 0.43.0 mermaid: specifier: 'catalog:' version: 11.14.0 @@ -834,58 +855,58 @@ importers: version: 1.0.0 next: specifier: 'catalog:' - version: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) next-themes: specifier: 'catalog:' - version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) nuqs: specifier: 'catalog:' - version: 2.8.9(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4) + version: 2.8.9(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react@19.2.5) pinyin-pro: specifier: 'catalog:' - version: 3.28.0 + version: 3.28.1 qrcode.react: specifier: 'catalog:' - version: 4.2.0(react@19.2.4) + version: 4.2.0(react@19.2.5) qs: specifier: 'catalog:' - version: 6.15.0 + version: 6.15.1 react: specifier: 'catalog:' - version: 19.2.4 + version: 19.2.5 react-18-input-autosize: specifier: 'catalog:' - version: 3.0.0(react@19.2.4) + version: 3.0.0(react@19.2.5) react-dom: specifier: 'catalog:' - version: 19.2.4(react@19.2.4) + version: 19.2.5(react@19.2.5) react-easy-crop: specifier: 'catalog:' - version: 5.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 5.5.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-hotkeys-hook: specifier: 'catalog:' - version: 5.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-i18next: specifier: 'catalog:' - version: 17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) + version: 16.5.8(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2) react-multi-email: specifier: 'catalog:' - version: 1.0.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.0.25(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-papaparse: specifier: 'catalog:' version: 4.4.0 react-pdf-highlighter: specifier: 'catalog:' - version: 8.0.0-rc.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 8.0.0-rc.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-sortablejs: specifier: 'catalog:' - version: 6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7) + version: 6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sortablejs@1.15.7) react-textarea-autosize: specifier: 'catalog:' - version: 8.5.9(@types/react@19.2.14)(react@19.2.4) + version: 8.5.9(@types/react@19.2.14)(react@19.2.5) reactflow: specifier: 'catalog:' - version: 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) remark-breaks: specifier: 'catalog:' version: 4.0.0 @@ -909,7 +930,7 @@ importers: version: 1.0.8 streamdown: specifier: 'catalog:' - version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 2.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) string-ts: specifier: 'catalog:' version: 2.3.1 @@ -918,13 +939,13 @@ importers: version: 3.5.0 tldts: specifier: 'catalog:' - version: 7.0.27 + version: 7.0.28 unist-util-visit: specifier: 'catalog:' version: 5.1.0 use-context-selector: specifier: 'catalog:' - version: 2.0.0(react@19.2.4)(scheduler@0.27.0) + version: 2.0.0(react@19.2.5)(scheduler@0.27.0) uuid: specifier: 'catalog:' version: 13.0.0 @@ -933,17 +954,17 @@ importers: version: 4.3.6 zundo: specifier: 'catalog:' - version: 2.3.0(zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))) + version: 2.3.0(zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))) zustand: specifier: 'catalog:' - version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 8.0.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.2)(@typescript-eslint/rule-tester@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2))(@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.18.1))(typescript@6.0.2) + version: 8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2) '@chromatic-com/storybook': specifier: 'catalog:' - version: 5.1.1(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 5.1.2(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@dify/iconify-collections': specifier: workspace:* version: link:../packages/iconify-collections @@ -952,10 +973,10 @@ importers: version: 1.9.2(tailwindcss@4.2.2) '@eslint-react/eslint-plugin': specifier: 'catalog:' - version: 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + version: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@hono/node-server': specifier: 'catalog:' - version: 1.19.12(hono@4.12.10) + version: 1.19.14(hono@4.12.12) '@iconify-json/heroicons': specifier: 'catalog:' version: 1.2.3 @@ -964,58 +985,58 @@ importers: version: 1.2.10 '@mdx-js/loader': specifier: 'catalog:' - version: 3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + version: 3.1.1(webpack@5.105.4(uglify-js@3.19.3)) '@mdx-js/react': specifier: 'catalog:' - version: 3.1.1(@types/react@19.2.14)(react@19.2.4) + version: 3.1.1(@types/react@19.2.14)(react@19.2.5) '@mdx-js/rollup': specifier: 'catalog:' version: 3.1.1(rollup@4.59.0) '@next/eslint-plugin-next': specifier: 'catalog:' - version: 16.2.2 + version: 16.2.3 '@next/mdx': specifier: 'catalog:' - version: 16.2.2(@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)) + version: 16.2.3(@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)) '@rgrove/parse-xml': specifier: 'catalog:' version: 4.2.0 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.3.4(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) '@storybook/addon-links': specifier: 'catalog:' - version: 10.3.4(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/addon-onboarding': specifier: 'catalog:' - version: 10.3.4(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/addon-themes': specifier: 'catalog:' - version: 10.3.4(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/nextjs-vite': specifier: 'catalog:' - version: 10.3.4(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + version: 10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3)) '@storybook/react': specifier: 'catalog:' - version: 10.3.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + version: 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) '@tailwindcss/postcss': specifier: 'catalog:' version: 4.2.2 '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) '@tanstack/eslint-plugin-query': specifier: 'catalog:' - version: 5.96.1(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + version: 5.99.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@tanstack/react-devtools': specifier: 'catalog:' - version: 0.10.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) + version: 0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.11) '@tanstack/react-form-devtools': specifier: 'catalog:' - version: 0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11) + version: 0.2.21(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) '@tanstack/react-query-devtools': specifier: 'catalog:' - version: 5.96.1(@tanstack/react-query@5.96.1(react@19.2.4))(react@19.2.4) + version: 5.99.0(@tanstack/react-query@5.99.0(react@19.2.5))(react@19.2.5) '@testing-library/dom': specifier: 'catalog:' version: 10.4.1 @@ -1024,19 +1045,19 @@ importers: version: 6.9.1 '@testing-library/react': specifier: 'catalog:' - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@testing-library/user-event': specifier: 'catalog:' version: 14.6.1(@testing-library/dom@10.4.1) '@tsslint/cli': specifier: 'catalog:' - version: 3.0.2(@tsslint/compat-eslint@3.0.2(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) + version: 3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) '@tsslint/compat-eslint': specifier: 'catalog:' - version: 3.0.2(jiti@2.6.1)(typescript@6.0.2) + version: 3.0.3(jiti@2.6.1)(typescript@6.0.2) '@tsslint/config': specifier: 'catalog:' - version: 3.0.2(@tsslint/compat-eslint@3.0.2(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) + version: 3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) '@types/js-cookie': specifier: 'catalog:' version: 3.0.6 @@ -1048,7 +1069,7 @@ importers: version: 0.6.4 '@types/node': specifier: 'catalog:' - version: 25.5.0 + version: 25.6.0 '@types/qs': specifier: 'catalog:' version: 6.15.0 @@ -1063,70 +1084,70 @@ importers: version: 1.15.9 '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + version: 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260401.1 + version: 7.0.0-dev.20260413.1 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.21(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4) + version: 0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.2(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 4.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) agentation: specifier: 'catalog:' - version: 3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 3.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) code-inspector-plugin: specifier: 'catalog:' version: 1.5.1 eslint: specifier: 'catalog:' - version: 10.1.0(jiti@2.6.1) + version: 10.2.0(jiti@2.6.1) eslint-markdown: specifier: 'catalog:' - version: 0.6.0(eslint@10.1.0(jiti@2.6.1)) + version: 0.6.1(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-better-tailwindcss: specifier: 'catalog:' - version: 4.3.2(eslint@10.1.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.18.1))(tailwindcss@4.2.2)(typescript@6.0.2) + version: 4.4.1(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2) eslint-plugin-hyoban: specifier: 'catalog:' - version: 0.14.1(eslint@10.1.0(jiti@2.6.1)) + version: 0.14.1(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-markdown-preferences: specifier: 'catalog:' - version: 0.41.0(@eslint/markdown@8.0.1)(eslint@10.1.0(jiti@2.6.1)) + version: 0.41.1(@eslint/markdown@8.0.1)(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-no-barrel-files: specifier: 'catalog:' - version: 1.2.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + version: 1.3.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) eslint-plugin-react-refresh: specifier: 'catalog:' - version: 0.5.2(eslint@10.1.0(jiti@2.6.1)) + version: 0.5.2(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-sonarjs: specifier: 'catalog:' - version: 4.0.2(eslint@10.1.0(jiti@2.6.1)) + version: 4.0.2(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-storybook: specifier: 'catalog:' - version: 10.3.4(eslint@10.1.0(jiti@2.6.1))(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + version: 10.3.5(eslint@10.2.0(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) happy-dom: specifier: 'catalog:' - version: 20.8.9 + version: 20.9.0 hono: specifier: 'catalog:' - version: 4.12.10 + version: 4.12.12 knip: specifier: 'catalog:' - version: 6.2.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + version: 6.4.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) postcss: specifier: 'catalog:' - version: 8.5.8 + version: 8.5.9 react-server-dom-webpack: specifier: 'catalog:' - version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + version: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) storybook: specifier: 'catalog:' - version: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwindcss: specifier: 'catalog:' version: 4.2.2 @@ -1141,22 +1162,22 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: 0.0.39(bf3f106951d7257e1336e695c204ec32) + version: 0.0.41(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2) vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.15 - version: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 + version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plugin-inspect: specifier: 'catalog:' - version: 12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) + version: 12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) vite-plus: specifier: 'catalog:' - version: 0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.15 - version: '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-test@0.1.16 + version: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vitest-canvas-mock: specifier: 'catalog:' - version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) packages: @@ -1167,17 +1188,17 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@amplitude/analytics-browser@2.38.1': - resolution: {integrity: sha512-8E3WDuCz5pmVysw7iwT9MjltzaO7Sqy9jWNaXovO30Z8sXs5Ncl32qv6o14kwlpl3wRSaaAKDe0Z3Grjx3dYYQ==} + '@amplitude/analytics-browser@2.39.0': + resolution: {integrity: sha512-sTNGGjiubsDs1NqKsTXp0ykCaSIzjaGclMRHlnO7JBatqK0f/Knl0cfn1a7XBFuTVix/M5nrWATsKv6+0dSpMg==} - '@amplitude/analytics-client-common@2.4.42': - resolution: {integrity: sha512-pEpE6s8GsXTlD9Jj4b/wplCQD8fT2ml/VZSnQ1E5sU0goaeZaYQKMTXGpbA2aE40ABZMwQSopxJn+puBrJc8eg==} + '@amplitude/analytics-client-common@2.4.43': + resolution: {integrity: sha512-R5n3cfnVNLk32BE2DbCp4xpn39mfmjMUjvOO9kt5dLFdF0cozb9MCawVyZJQVfnJJT6k5NMoswdUBu7Ul0nbRw==} '@amplitude/analytics-connector@1.6.4': resolution: {integrity: sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==} - '@amplitude/analytics-core@2.44.1': - resolution: {integrity: sha512-bx8RAYneoEyT/gsCpcktEgBMUs5vIb2piA/Kof88BaNKAWEpIa9B4Ogg4vNPqmEgNIx/wztSduFMHHw2pLcncg==} + '@amplitude/analytics-core@2.45.0': + resolution: {integrity: sha512-vWRYbXu2Grs1GM+WHo03RPtbaPs5sJm21YQcAow9JASvtoY4xNqItIeRydCJQWtFHhbbxY41n+CVW6mzDP6aBA==} '@amplitude/analytics-types@2.11.1': resolution: {integrity: sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==} @@ -1185,26 +1206,26 @@ packages: '@amplitude/experiment-core@0.7.2': resolution: {integrity: sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==} - '@amplitude/plugin-autocapture-browser@1.25.1': - resolution: {integrity: sha512-eIaPO7eUH2W0OWe0JoqUVvMPUGDeOn4JQa7zdClEbvHnPxfGS1RHIFNsBk5ofgEWxhUo2Ka/Z0Wl86k9FMaa7w==} + '@amplitude/plugin-autocapture-browser@1.25.2': + resolution: {integrity: sha512-AWzIX0uit60Q742rH/96/n88e+3BaVZa4+7Xs+BeuuIOyrljOZlQKzH23Lxzkl0DgbNb5+MMqWds0pov3DV5TA==} - '@amplitude/plugin-custom-enrichment-browser@0.1.3': - resolution: {integrity: sha512-iKZkqkI5CpLb62cGNgvqTVEUj8i5UBFWJc0aQMZZBqc+vmzHBaqvjeAU0dwO8KA623YfT5I+/Vp1MnqvEXGJFg==} + '@amplitude/plugin-custom-enrichment-browser@0.1.4': + resolution: {integrity: sha512-vxuQocn8YGE2wMLZUmotRG8c6RijoaQAsHKDQEO56CNk3WhSecgSGMnlHcUcOYIzwfXKFj4MxRJS386kdDHV+Q==} - '@amplitude/plugin-network-capture-browser@1.9.12': - resolution: {integrity: sha512-/8x+GDqE25pTvsU9Po7Ur+V8pUuX4IG5p2xHPM9N/APfyc3D1zLTkC8FKo8wfPpg4Wu97mSzy1JnvPDqbJcJyw==} + '@amplitude/plugin-network-capture-browser@1.9.13': + resolution: {integrity: sha512-8uzTQFbP+dvqJX+S39KqKw+EheJW8JCWT/xlXT55vtTU/ZTFeF074QnHFEKUPewpYXpwKXgJky8PDoMk0b46Qw==} - '@amplitude/plugin-page-url-enrichment-browser@0.7.4': - resolution: {integrity: sha512-gF7V1ypkYB7FTwKlqjbO+7Z+Wvf72RfA64aREj9aplZdRJ0EY3qSEYMA3L2v0U5ztYchiy5MJraSaaxKfzXdJg==} + '@amplitude/plugin-page-url-enrichment-browser@0.7.5': + resolution: {integrity: sha512-0Q7P5vsue/s92i3zevVDVJf9AiHkbxGdwkB8iV2oWgkXtglzWugwr//qN+muHmXdi1ZWxRjm93CW+jQJVripgw==} - '@amplitude/plugin-page-view-tracking-browser@2.9.5': - resolution: {integrity: sha512-fWewMrgo0T7AyKnrZn6ox0ER5Ibw/IFTkX0GrQ8DxcsXrmUuSWUTsxZaA7YPDzuWPbd4AX9/AWZF2i6A9Ybtfg==} + '@amplitude/plugin-page-view-tracking-browser@2.9.6': + resolution: {integrity: sha512-/4lG2lXIB6qbQNf1VYQ5fDOnvInPEtYuOgvmyLfuZ6PvHVFUu4NZtoOVdAcy0R9x76rNyCpRXxdL78p9Ra1ANA==} - '@amplitude/plugin-session-replay-browser@1.27.6': - resolution: {integrity: sha512-wHv9b/Qzu9qg0thE+qo23/KpYGiADnAj42I1C1goQAJG7XNOk62F0sdejVvnQIV9NsLe0ItoS+tg3eqlBE7Exg==} + '@amplitude/plugin-session-replay-browser@1.27.7': + resolution: {integrity: sha512-KcGMFaBGqZAOm1Gdzio9d95IL3Nmp5J1xOu1PD0NAPYLfW1MyoyA5PFIIlMqqVf1DoCjmgqP7AY4swetU2tpWg==} - '@amplitude/plugin-web-vitals-browser@1.1.27': - resolution: {integrity: sha512-jh/dWMsthx5E+ensNTwj7nkqi8iG8wyJc1HryOdY49w9zTgcbZmJwE2uumLBXBasn7l62a5EdqRkwctGL53fHw==} + '@amplitude/plugin-web-vitals-browser@1.1.28': + resolution: {integrity: sha512-gs4Y1eOuVUEDwYEJF82f/GmgQ7iM4Y/eZTkftJKjFsBNbrPro2CuLymfdAcC+QuVfyrp3qAiWcSGnjDXA6ZbQg==} '@amplitude/rrdom@2.0.0-alpha.37': resolution: {integrity: sha512-u4dSnBtlbJ8oU5P/Ywl2RLqvjqWbkl4ScMUbvQA7in4pWcx+0NRN+VVjLZXQcd8Fn7E/rcxjeUh7e7HfwvdasQ==} @@ -1238,14 +1259,14 @@ packages: '@amplitude/rrweb@2.0.0-alpha.37': resolution: {integrity: sha512-jJkSpPYiVgOZB422pb2jOJJn3pvb5E5f9vKK8CEmUlk2mVAl6kPQzW98mb05M65OJFj5nn9tSe9h5r5+Cl93ag==} - '@amplitude/session-replay-browser@1.35.1': - resolution: {integrity: sha512-7X6T+niZaG+zpvcFOwdkbTNUWzD6T9/rQ7POYkTK+C/6FtvJ0fpHXNHdHT8fozKox2UXL/wwZvoQWFriHSe1dA==} + '@amplitude/session-replay-browser@1.36.0': + resolution: {integrity: sha512-HZpNRMRAiLbzGF84DzF+ZH5WztJH4tVe2e/FzYJ2r27Sgf2gftCmzCB9pN8BXXcHKYtQK8/Qol+PTmSIzvyvEw==} '@amplitude/targeting@0.2.0': resolution: {integrity: sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==} - '@antfu/eslint-config@8.0.0': - resolution: {integrity: sha512-IKiCfsa1vRgj8srB2azqiN3nOAcVyP/TZ5Ibiz0TDW9NoQPizTvkmRTSi1vo4ax0SL9TH/8uJLK6uCfd6bQzLA==} + '@antfu/eslint-config@8.2.0': + resolution: {integrity: sha512-spfwYXMNrlkl69riTSBnbC0C2K8EVfVMOK3ceP2EpAAioyfprIW1gTwyLRtd9jZSFeNdX4mFNAIG+o0sOneOfA==} hasBin: true peerDependencies: '@angular-eslint/eslint-plugin': ^21.1.0 @@ -1382,19 +1403,21 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.3.0': - resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==} + '@base-ui/react@1.4.0': + resolution: {integrity: sha512-QcqdVbr/+ba2/RAKJIV1PV6S02Q5+r6a4Eym8ndBw+ZbBILkkmQAyRxXCg/pArrHnkrGeU8goe26aw0h6eE8pg==} engines: {node: '>=14.0.0'} peerDependencies: + '@date-fns/tz': ^1.2.0 '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 react: ^17 || ^18 || ^19 react-dom: ^17 || ^18 || ^19 peerDependenciesMeta: '@types/react': optional: true - '@base-ui/utils@0.2.6': - resolution: {integrity: sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==} + '@base-ui/utils@0.2.7': + resolution: {integrity: sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -1425,8 +1448,8 @@ packages: '@chevrotain/utils@11.1.2': resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} - '@chromatic-com/storybook@5.1.1': - resolution: {integrity: sha512-BPoAXHM71XgeCK2u0jKr9i8apeQMm/Z9IWGyndA2FMijfQG9m8ox45DdWh/pxFkK5ClhGgirv5QwMhFIeHmThg==} + '@chromatic-com/storybook@5.1.2': + resolution: {integrity: sha512-H/hgvwC3E+OtseP2OT2QYUJH2VfnzT6wM3pWOkaNV6g7QI+VUdWJbeJ3o2jFqvEPQNqzhQKWDOlvM4lu+7is6g==} engines: {node: '>=20.0.0', yarn: '>=1.22.18'} peerDependencies: storybook: ^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 @@ -1471,8 +1494,8 @@ packages: '@cucumber/cucumber-expressions@19.0.0': resolution: {integrity: sha512-4FKoOQh2Uf6F6/Ln+1OxuK8LkTg6PyAqekhf2Ix8zqV2M54sH+m7XNJNLhOFOAW/t9nxzRbw2CcvXbCLjcvHZg==} - '@cucumber/cucumber@12.7.0': - resolution: {integrity: sha512-7A/9CJpJDxv1SQ7hAZU0zPn2yRxx6XMR+LO4T94Enm3cYNWsEEj+RGX38NLX4INT+H6w5raX3Csb/qs4vUBsOA==} + '@cucumber/cucumber@12.8.0': + resolution: {integrity: sha512-sRG2QMAgCic4Uq1q+5LRzApEHiNGX5rhQY/GuOJZ9BIySrGPA9pevB0imJsZvdzt9scaWyIM3c7dIf4Dp1YQRA==} engines: {node: 20 || 22 || >=24} hasBin: true @@ -1496,18 +1519,18 @@ packages: peerDependencies: '@cucumber/messages': '>=18' - '@cucumber/junit-xml-formatter@0.9.0': - resolution: {integrity: sha512-WF+A7pBaXpKMD1i7K59Nk5519zj4extxY4+4nSgv5XLsGXHDf1gJnb84BkLUzevNtp2o2QzMG0vWLwSm8V5blw==} + '@cucumber/junit-xml-formatter@0.13.2': + resolution: {integrity: sha512-worYkxjeOWJV+b7WkgJekWgFHlIhbuocnFK3hP+pMYXqZMmkXsxAorYPjeF8KyLnZXajw5fKHS2bM9rQIUI7Zw==} peerDependencies: '@cucumber/messages': '*' - '@cucumber/message-streams@4.0.1': - resolution: {integrity: sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==} + '@cucumber/message-streams@4.1.1': + resolution: {integrity: sha512-QCAntLajesWMyX+mZKrj63YghVAts7yKFlZe46XprLbdJZN0ddB+f/Mr9OnyWKC2DHhJ18jzCfKIFCaqpAmUxg==} peerDependencies: '@cucumber/messages': '>=17.1.1' - '@cucumber/messages@32.0.1': - resolution: {integrity: sha512-1OSoW+GQvFUNAl6tdP2CTBexTXMNJF0094goVUcvugtQeXtJ0K8sCP0xbq7GGoiezs/eJAAOD03+zAPT64orHQ==} + '@cucumber/messages@32.2.0': + resolution: {integrity: sha512-oYp1dgL2TByYWL51Z+rNm+/mFtJhiPU9WS03goes9EALb8d9GFcXRbG1JluFLFaChF1YDqIzLac0kkC3tv1DjQ==} '@cucumber/pretty-formatter@1.0.1': resolution: {integrity: sha512-A1lU4VVP0aUWdOTmpdzvXOyEYuPtBDI0xYwYJnmoMDplzxMdhcHk86lyyvYDoMoPzzq6OkOE3isuosvUU4X7IQ==} @@ -1523,6 +1546,9 @@ packages: '@cucumber/tag-expressions@9.1.0': resolution: {integrity: sha512-bvHjcRFZ+J1TqIa9eFNO1wGHqwx4V9ZKV3hYgkuK/VahHx73uiP4rKV3JVrvWSMrwrFvJG6C8aEwnCWSvbyFdQ==} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@e18e/eslint-plugin@0.3.0': resolution: {integrity: sha512-hHgfpxsrZ2UYHcicA+tGZnmk19uJTaye9VH79O+XS8R4ona2Hx3xjhXghclNW58uXMk3xXlbYEOMr8thsoBmWg==} peerDependencies: @@ -1555,6 +1581,10 @@ packages: resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@es-joy/resolve.exports@1.2.0': resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} engines: {node: '>=10'} @@ -1779,16 +1809,16 @@ packages: resolution: {integrity: sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-array@0.23.3': - resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + '@eslint/config-array@0.23.4': + resolution: {integrity: sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/config-helpers@0.2.3': resolution: {integrity: sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.5.3': - resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + '@eslint/config-helpers@0.5.4': + resolution: {integrity: sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@0.14.0': @@ -1803,13 +1833,13 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@1.1.1': - resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + '@eslint/core@1.2.0': + resolution: {integrity: sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/css-tree@3.6.9': - resolution: {integrity: sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + '@eslint/css-tree@4.0.1': + resolution: {integrity: sha512-2fCSKRwoUHntYq9J1Lm28s2zeoCSNh1Cbk6Tg7k7ViwOnveIfZwPRFGwBglz+dzw2MHe5w5Fo9+VJfqL9nco2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/eslintrc@3.3.5': resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} @@ -1840,8 +1870,8 @@ packages: resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@3.0.3': - resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + '@eslint/object-schema@3.0.4': + resolution: {integrity: sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/plugin-kit@0.3.5': @@ -1856,6 +1886,10 @@ packages: resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/plugin-kit@0.7.0': + resolution: {integrity: sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1883,14 +1917,14 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@formatjs/fast-memoize@3.1.1': - resolution: {integrity: sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==} + '@formatjs/fast-memoize@3.1.2': + resolution: {integrity: sha512-vPnriihkfK0lzoQGaXq+qXH23VsYyansRTkTgo2aTG0k1NjLFyZimFVdfj4C9JkSE5dm7CEngcQ5TTc1yAyBfQ==} - '@formatjs/intl-localematcher@0.8.2': - resolution: {integrity: sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==} + '@formatjs/intl-localematcher@0.8.3': + resolution: {integrity: sha512-pHUjWb9NuhnMs8+PxQdzBtZRFJHlGhrURGAbm6Ltwl82BFajeuiIR3jblSa7ia3r62rXe/0YtVpUG3xWr5bFCA==} - '@headlessui/react@2.2.9': - resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==} + '@headlessui/react@2.2.10': + resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} engines: {node: '>=10'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -1901,8 +1935,8 @@ packages: peerDependencies: react: '>= 16 || ^19.0.0-rc' - '@hono/node-server@1.19.12': - resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -2126,77 +2160,77 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lexical/clipboard@0.42.0': - resolution: {integrity: sha512-D3K2ID0zew/+CKpwxnUTTh/N46yU4IK8bFWV9Htz+g1vFhgUF9UnDOQCmqpJbdP7z+9U1F8rk3fzf9OmP2Fm2w==} + '@lexical/clipboard@0.43.0': + resolution: {integrity: sha512-3dWDusVyM9EosBt4/n/ERyPIGOyuWuECj9zbvJdzGUdvu/VsqCdlyDsU5M7NxTUNQn2Fhkdj2o00UeB6bagX5Q==} - '@lexical/code-core@0.42.0': - resolution: {integrity: sha512-vrZTUPWDJkHjAAvuV2+Qte4vYE80s7hIO7wxipiJmWojGx6lcmQjO+UqJ8AIrqI4Wjy8kXrK74kisApWmwxuCw==} + '@lexical/code-core@0.43.0': + resolution: {integrity: sha512-8NtEOI4+hM688Pmd0Qh/aTCS5uovps902V53LGB15DUUwwL+Z5U+Hz7ZYozhyM6W755FQ3x15qtEGIIbDHE5bQ==} - '@lexical/devtools-core@0.42.0': - resolution: {integrity: sha512-8nP8eE9i8JImgSrvInkWFfMCmXVKp3w3VaOvbJysdlK/Zal6xd8EWJEi6elj0mUW5T/oycfipPs2Sfl7Z+n14A==} + '@lexical/devtools-core@0.43.0': + resolution: {integrity: sha512-Hyz8vxvmo0aThXjq3+t0mabozmQeb6U+pxKceAgBSxE9oLWbQmP7RW8jYPZW20bYqEcX1Kgmu+CdW8e3eSF7Kw==} peerDependencies: react: '>=17.x' react-dom: '>=17.x' - '@lexical/dragon@0.42.0': - resolution: {integrity: sha512-/TQzP+7PLJMqq9+MlgQWiJsxS9GOOa8Gp0svCD8vNIOciYmXfd28TR1Go+ZnBWwr7k/2W++3XUYVQU2KUcQsDQ==} + '@lexical/dragon@0.43.0': + resolution: {integrity: sha512-wB2s8uO9DFwS5err1wM+7Yoz3cixtEXy1ZiU8RoJJ7tmjSEmQsLIflAQq8Lic291tCNPs+lSHKjdw+52vi0Z7Q==} - '@lexical/extension@0.42.0': - resolution: {integrity: sha512-rkZq/h8d1BenKRqU4t/zQUVfY/RinMX1Tz7t+Ee3ss0sk+kzP4W+URXNAxpn7r39Vn6wrFBqmCziah3dLAIqPw==} + '@lexical/extension@0.43.0': + resolution: {integrity: sha512-hCFj//3RhsPrCmx8VRTTLIsWtC2n5GG03ZDdyrgmeLzXNuknwDqhzaGAfQi9LSYn+NU+j3yCUROu8pZqaedtvw==} - '@lexical/hashtag@0.42.0': - resolution: {integrity: sha512-WOg5nFOfhabNBXzEIutdWDj+TUHtJEezj6w8jyYDGqZ31gu0cgrXSeV8UIynz/1oj+rpzEeEB7P6ODnwgjt7qA==} + '@lexical/hashtag@0.43.0': + resolution: {integrity: sha512-oCKjY8/jkxJuu8iBnNX0WSLA6ZIYTn+v3NLpJxDqnAFZJCnJ2i/nM8GKzPMzHCDzJVNxbQB08fOptdXf8eN0Fg==} - '@lexical/history@0.42.0': - resolution: {integrity: sha512-YfCZ1ICUt6BCg2ncJWFMuS4yftnB7FEHFRf3qqTSTf6oGZ4IZfzabMNEy47xybUuf7FXBbdaCKJrc/zOM+wGxw==} + '@lexical/history@0.43.0': + resolution: {integrity: sha512-SdrH3xgtUcolVRLihbQwiANQIiwSLdkKBon9oSsZNNnzVgEb7DUQUtJQGf33oW8HHWObIuWkh72W0fN1dZixOw==} - '@lexical/html@0.42.0': - resolution: {integrity: sha512-KgBUDLXehufCsXW3w0XsuoI2xecIhouOishnaNOH4zIA7dAtnNAfdPN/kWrWs0s83gz44OrnqccP+Bprw3UDEQ==} + '@lexical/html@0.43.0': + resolution: {integrity: sha512-C6LpUQlRl9J8Hqpm/C8LCX1ZxFHyD/gvOdV+NuNGnXN06uo0jDDm9SNh/HI3VWvFu9ec4OuzUkQRCafW8WC8fQ==} - '@lexical/link@0.42.0': - resolution: {integrity: sha512-cdeM/+f+kn7aGwW/3FIi6USjl1gBNdEEwg0/ZS+KlYcsy8gxx2e4cyVjsomBu/WU17Qxa0NC0paSr7qEJ/1Fig==} + '@lexical/link@0.43.0': + resolution: {integrity: sha512-jjU9PVWWBA2yEssbVkLQpu1ZIpXi3JwYb+JO20R47hzUm7T8SAPDd/VwU+2tcjqz065YntSGIaQ79dCft7WOJw==} - '@lexical/list@0.42.0': - resolution: {integrity: sha512-TIezILnmIVuvfqEEbcMnsT4xQRlswI6ysHISqsvKL6l5EBhs1gqmNYjHa/Yrfzaq5y52TM1PAtxbFts+G7N6kg==} + '@lexical/list@0.43.0': + resolution: {integrity: sha512-WyYVeQa2x1LrI8Emr9AiWTjSMiZw77Zy7MRnohPTdX/4fu3Njfw61lpoonCNHlv/r5Mb/RHkIAwWjtjcSzwA+g==} - '@lexical/mark@0.42.0': - resolution: {integrity: sha512-H1aGjbMEcL4B8GT7bm/ePHm7j3Wema+wIRNPmxMtXGMz5gpVN3gZlvg2UcUHHJb00SrBA95OUVT5I2nu/KP06w==} + '@lexical/mark@0.43.0': + resolution: {integrity: sha512-pgwR5ia2ECDS0pyQxIrFvMOKjffI6fo2cGwqYg+Jz+ANMqE5zD4PoOUs7FEuZYAKPOAQR9GrETB7YAVSzKjk3Q==} - '@lexical/markdown@0.42.0': - resolution: {integrity: sha512-+mOxgBiumlgVX8Acna+9HjJfSOw1jywufGcAQq3/8S11wZ4gE0u13AaR8LMmU8ydVeOQg09y8PNzGNQ/avZJbg==} + '@lexical/markdown@0.43.0': + resolution: {integrity: sha512-bJYhISQkdRo6XxcajgP9T+c8XAGfkJ/DHnSvM5nyJnHD0vZSH/2RZd2Lgt0eAnMVEt9ECG8cUkR557QSaPeJBA==} - '@lexical/offset@0.42.0': - resolution: {integrity: sha512-V+4af1KmTOnBZrR+kU3e6eD33W/g3QqMPPp3cpFwyXk/dKRc4K8HfyDsSDrjop1mPd9pl3lKSiEmX6uQG8K9XQ==} + '@lexical/offset@0.43.0': + resolution: {integrity: sha512-SYNF16Hk17ePaxFtPcBx3rzSM8yxDYSAzkSOdnUUePSzfTW3DUDzvUfe7q/7QCe/UlZd+4ULI0VjNgYRlR8Uiw==} - '@lexical/overflow@0.42.0': - resolution: {integrity: sha512-wlrHaM27rODJP5m+CTgfZGLg3qWlQ0ptGodcqoGdq6HSbV8nGFY6TvcLMaMtYQ1lm4v9G7Xe9LwjooR6xS3Gug==} + '@lexical/overflow@0.43.0': + resolution: {integrity: sha512-Usm7UfIwydhsg+qMbkBav79AOKqYa32zXY+TXveTqbaA+IAoIl3vFYP9x9ie4cHz/kgrmt/QuQs66cwPefRakg==} - '@lexical/plain-text@0.42.0': - resolution: {integrity: sha512-YWvBwIxLltrIaZDcv0rK4s44P6Yt17yhOb0E+g3+tjF8GGPrrocox+Pglu0m2RHR+G7zULN3isolmWIm/HhWiw==} + '@lexical/plain-text@0.43.0': + resolution: {integrity: sha512-wza2z2+OSsq3UPsFseqsVvnAWvW9s3W/rjQuf6Bk2/Xde2F3R7fvu3kArsaaVPzUKTVeOPCD8hUKIUpxP5OT2g==} - '@lexical/react@0.42.0': - resolution: {integrity: sha512-ujWJXhvlFVVTpwDcnSgEYWRuqUbreZaMB+4bjIDT5r7hkAplUHQndlkeuFHKFiJBasSAreleV7zhXrLL5xa9eA==} + '@lexical/react@0.43.0': + resolution: {integrity: sha512-Ov9PCS7Ghm83fmjSDr6CafDLsuMhf7A7FFfEr4DmDM/6Lw2w0a0QQJP+KqxPqaVaRgeQMJAVg38Zgrvuk3v7tw==} peerDependencies: react: '>=17.x' react-dom: '>=17.x' - '@lexical/rich-text@0.42.0': - resolution: {integrity: sha512-v4YgiM3oK3FZcRrfB+LetvLbQ5aee9MRO9tHf0EFweXg19XnSjHV0cfPAW7TyPxRELzB69+K0Q3AybRlTMjG4Q==} + '@lexical/rich-text@0.43.0': + resolution: {integrity: sha512-y6uhY5X+PBLg8LSCDazSMAkUfA1RwBW6DFOuUKW5SI1DaB/oc/vpQhkR1DYGqXnytMx7hfiK+7lL51ZC0ydeWg==} - '@lexical/selection@0.42.0': - resolution: {integrity: sha512-iWTjLA5BSEuUnvWe9Xwu9FSdZFl3Yi0NqalabXKI+7KgCIlIVXE74y4NvWPUSLkSCB/Z1RPKiHmZqZ1vyu/yGQ==} + '@lexical/selection@0.43.0': + resolution: {integrity: sha512-sdKdXIFggtHxTctvXjTyx2RgWuKOOP3PhrzRJF+COGfckrr/YzDtQCOfyvktElyKEeYXa3t9sx/R6Ep3n074fA==} - '@lexical/table@0.42.0': - resolution: {integrity: sha512-GKiZyjQsHDXRckq5VBrOowyvds51WoVRECfDgcl8pqLMnKyEdCa58E7fkSJrr5LS80Scod+Cjn6SBRzOcdsrKg==} + '@lexical/table@0.43.0': + resolution: {integrity: sha512-oLrOBzRwpmdHDpGVRgwBVgO1ro0w50rMdtOVQ6KsL53ijZ6OiI1YE2ZNOy4qfJvjub+2dgp83gKpB7YcmXAP3w==} - '@lexical/text@0.42.0': - resolution: {integrity: sha512-hT3EYVtBmONXyXe4TFVgtFcG1tf6JhLEuAf95+cOjgFGFSgvkZ/64BPbKLNTj2/9n6cU7EGPUNNwVigCSECJ2g==} + '@lexical/text@0.43.0': + resolution: {integrity: sha512-dtUZ79WaAv3nEYBIWPBZIrjwCUPONN8HcgtReY3qku7WQkzqy3FaMwT/lBa92cUhqsn4ChLIBO3lPFhWRALyvg==} - '@lexical/utils@0.42.0': - resolution: {integrity: sha512-wGNdCW3QWEyVdFiSTLZfFPtiASPyYLcekIiYYZmoRVxVimT/jY+QPfnkO4JYgkO7Z70g/dsg9OhqyQSChQfvkQ==} + '@lexical/utils@0.43.0': + resolution: {integrity: sha512-Y9wzFwoeI9KLDJsztTz45Aobp6sACHSRqUtyjxpCsU0jwL60Tt9rD71QVz7SvpmzxjtnBb040s6LHa6vP0gY+A==} - '@lexical/yjs@0.42.0': - resolution: {integrity: sha512-DplzWnYhfFceGPR+UyDFpZdB287wF/vNOHFuDsBF/nGDdTezvr0Gf60opzyBEF3oXym6p3xTmGygxvO97LZ+vw==} + '@lexical/yjs@0.43.0': + resolution: {integrity: sha512-3ghY9BYZVo3Hg2TmY2+H3Q6+AhhGwNIhnr6mvCbdLBEsnSTXr4VZSPMXN2ae5phCPrI19eHrx4MvFNYodQcqrA==} peerDependencies: yjs: '>=13.5.22' @@ -2247,14 +2281,14 @@ packages: '@next/env@16.0.0': resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==} - '@next/env@16.2.2': - resolution: {integrity: sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==} + '@next/env@16.2.3': + resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} - '@next/eslint-plugin-next@16.2.2': - resolution: {integrity: sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==} + '@next/eslint-plugin-next@16.2.3': + resolution: {integrity: sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==} - '@next/mdx@16.2.2': - resolution: {integrity: sha512-2CbRTXE6sJ7zDAaKXknb5FrrPs46iJeMPzuoBXsAOV/XVnxABGD4mSDusn0VuCoII/KjUZ+zsuo2VFbchYQXng==} + '@next/mdx@16.2.3': + resolution: {integrity: sha512-mm7XNfPagSIcN8jFtozB9toeh5ESES0KCLRoo0gu6xydijvnIrV7dRIK3akNL3Tecc8AHX1FNzYZOZTeFU6RCw==} peerDependencies: '@mdx-js/loader': '>=0.15.0' '@mdx-js/react': '>=0.15.0' @@ -2264,54 +2298,54 @@ packages: '@mdx-js/react': optional: true - '@next/swc-darwin-arm64@16.2.2': - resolution: {integrity: sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==} + '@next/swc-darwin-arm64@16.2.3': + resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.2': - resolution: {integrity: sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==} + '@next/swc-darwin-x64@16.2.3': + resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.2': - resolution: {integrity: sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==} + '@next/swc-linux-arm64-gnu@16.2.3': + resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.2': - resolution: {integrity: sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==} + '@next/swc-linux-arm64-musl@16.2.3': + resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.2': - resolution: {integrity: sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==} + '@next/swc-linux-x64-gnu@16.2.3': + resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.2': - resolution: {integrity: sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==} + '@next/swc-linux-x64-musl@16.2.3': + resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.2': - resolution: {integrity: sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==} + '@next/swc-win32-arm64-msvc@16.2.3': + resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.2': - resolution: {integrity: sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==} + '@next/swc-win32-x64-msvc@16.2.3': + resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2340,36 +2374,36 @@ packages: resolution: {integrity: sha512-y3SvzjuY1ygnzWA4Krwx/WaJAsTMP11DN+e21A8Fa8PW1oDtVB5NSRW7LWurAiS2oKRkuCgcjTYMkBuBkcPCRg==} engines: {node: '>=12.4.0'} - '@orpc/client@1.13.13': - resolution: {integrity: sha512-jagx/Sa+9K4HEC5lBrUlMSrmR/06hvZctWh93/sKZc8GBk4zM0+71oT1kXQVw1oRYFV2XAq3xy3m6NdM6gfKYA==} + '@orpc/client@1.13.14': + resolution: {integrity: sha512-JQf3lO//UGHmmkd8+9fuWuh1gga1lhWuKnsT19cui7F6WizBy0NdFSVQerOsSy2c1kxOthlD7GnicGgSY2rhQA==} - '@orpc/contract@1.13.13': - resolution: {integrity: sha512-md6iyrYkePBSJNs1VnVEEnAUORMDPHIf3JGRSHxyssIcNakev/iOjP0HvpH0Sx0MlTBhihAJo6uFL8Vpth58Nw==} + '@orpc/contract@1.13.14': + resolution: {integrity: sha512-MfsjaQQDVcs4wHmdl5N/7vkwMnQ41nlojWXyRfRXNJHQczqBzM6sYaTJuUPXlw4YbIu64KHZ5nbbtwNCO5YXsg==} - '@orpc/openapi-client@1.13.13': - resolution: {integrity: sha512-k8od+bD7MqysKPPybAkxgfaNIaNseFPXtbidWkZAdCZ5w34SnDc7QPZJ0PQbyt9n9B+jOXSADNwQSTWSuGpjyA==} + '@orpc/openapi-client@1.13.14': + resolution: {integrity: sha512-mHuj/UL5qLqB1JqrRdlAoUYMidbsry8Cr9QOlOZk1mp7+OZhasFv75UNzxyjNNaSjyd3l2k4UkgpcHK4VSD7tQ==} - '@orpc/shared@1.13.13': - resolution: {integrity: sha512-kNpYOBjHvmgKHla6munWOaEeA0utEfAvoiZpXjiRjjt1RxTibdwQvVHgxRIBNMXfQsb+ON3Q/wDkoaUhvvSnIw==} + '@orpc/shared@1.13.14': + resolution: {integrity: sha512-/ri8ttSX+ppoo01d3LdqQ4Xh6VZS5PYRYmHxTvO8tuyiqBJhN18d8P1VtEW4T9hetoK7JZKeU7EAeqVUnCF9WA==} peerDependencies: '@opentelemetry/api': '>=1.9.0' peerDependenciesMeta: '@opentelemetry/api': optional: true - '@orpc/standard-server-fetch@1.13.13': - resolution: {integrity: sha512-Lffy26+WtCQkwOUacsrdyeJF1GNzrhm75O3LXKVFXqmSdyVVdyI6zuqLn/YKGODU2L9IqGxZ2CwsV2tE298SSA==} + '@orpc/standard-server-fetch@1.13.14': + resolution: {integrity: sha512-k2zkCi98qd3NkvWhUX/Yece/qjB+o07g/gHC509YB5HbOGtBV/da3eseYjFyzBx5LDxMz28BOALI8/q/YDhKZw==} - '@orpc/standard-server-peer@1.13.13': - resolution: {integrity: sha512-FeWAbXfnZDPYQRajM0hD6GJvHeC3DZILngAjdcLHy5zt3riu6nL2lLPSWDv5yNWWscmYU+CfKmXWd0Z01BOeWA==} + '@orpc/standard-server-peer@1.13.14': + resolution: {integrity: sha512-jinseQ8bn7XQOHjsCXhR1HiF3wAwn1xEQPpnE/av0PoOi4h0ATvhZjDLaRHvRavs8YwrIqwSuAuYT/hDxON58A==} - '@orpc/standard-server@1.13.13': - resolution: {integrity: sha512-9pgS8XvauuRQElkyuD8F3om+nN0KBEnTkhblDHCBzkZERjWkmfirJmshQrWHoFaDTk+nnXHIaY6d7TBTxXdPRw==} + '@orpc/standard-server@1.13.14': + resolution: {integrity: sha512-o8PaDERiwREFQpIZO0mQ1PhguchyNzrf1w7m3eK1JB4rPjHu1VJUgqCpy/sV3Id5ji4bX/gKHEC3NZjDX6mEWQ==} - '@orpc/tanstack-query@1.13.13': - resolution: {integrity: sha512-6+Cheaiu+RDPdszdeRKoBINrF8MQp64zSeZB+L3gqgF43zlYDhLOgELZMzYa6U3U6bLk4rmIeubpk+i1kACfRg==} + '@orpc/tanstack-query@1.13.14': + resolution: {integrity: sha512-5rq1Z1anVTVBseYeNBi5RJSgWPxpD0MqK7MYej3xnt56jjc6mFmWpUGNz9xy0BXPh3KmA/xDTNuB23kKgJ5JmQ==} peerDependencies: - '@orpc/client': 1.13.13 + '@orpc/client': 1.13.14 '@tanstack/query-core': '>=5.80.2' '@ota-meshi/ast-token-store@0.3.0': @@ -2503,15 +2537,15 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/runtime@0.122.0': - resolution: {integrity: sha512-vevyz3bNjevQFCV2Yg5o6Sp9BSoiYiJVymMrzA3S1ZGj4J8ak4YiywhFyQMueQ3UNlJU6HZOZYDy70TUc99aHw==} + '@oxc-project/runtime@0.123.0': + resolution: {integrity: sha512-wRf0z8saz9tHLcK3YeTeBmwISrpy4bBimvKxUmryiIhbt+ZJb0nwwJNL3D8xpeWbNfZlGSlzRBZbfcbApIGZJw==} engines: {node: ^20.19.0 || >=22.12.0} '@oxc-project/types@0.121.0': resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} - '@oxc-project/types@0.122.0': - resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@oxc-project/types@0.123.0': + resolution: {integrity: sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==} '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} @@ -2743,33 +2777,33 @@ packages: cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.18.1': - resolution: {integrity: sha512-CxSd15ZwHn70UJFTXVvy76bZ9zwI097cVyjvUFmYRJwvkQF3VnrTf2oe1gomUacErksvtqLgn9OKvZhLMYwvog==} + '@oxlint-tsgolint/darwin-arm64@0.20.0': + resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.18.1': - resolution: {integrity: sha512-LE7VW/T/VcKhl3Z1ev5BusrxdlQ3DWweSeOB+qpBeur2h8+vCWq+M7tCO29C7lveBDfx1+rNwj4aiUVlA+Qs+g==} + '@oxlint-tsgolint/darwin-x64@0.20.0': + resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.18.1': - resolution: {integrity: sha512-2AG8YIXVJJbnM0rcsJmzzWOjZXBu5REwowgUpbHZueF7OYM3wR7Xu8pXEpAojEHAtYYZ3X4rpPoetomkJx7kCw==} + '@oxlint-tsgolint/linux-arm64@0.20.0': + resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.18.1': - resolution: {integrity: sha512-f8vDYPEdiwpA2JaDEkadTXfuqIgweQ8zcL4SX75EN2kkW2oAynjN7cd8m86uXDgB0JrcyOywbRtwnXdiIzXn2A==} + '@oxlint-tsgolint/linux-x64@0.20.0': + resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.18.1': - resolution: {integrity: sha512-fBdML05KMDAL9ebWeoHIzkyI86Eq6r9YH5UDRuXJ9vAIo1EnKo0ti7hLUxNdc2dy2FF/T4k98p5wkkXvLyXqfA==} + '@oxlint-tsgolint/win32-arm64@0.20.0': + resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.18.1': - resolution: {integrity: sha512-cYZMhNrsq9ZZ3OUWHyawqiS+c8HfieYG0zuZP2LbEuWWPfdZM/22iAlo608J+27G1s9RXQhvgX6VekwWbXbD7A==} + '@oxlint-tsgolint/win32-x64@0.20.0': + resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==} cpu: [x64] os: [win32] @@ -2995,8 +3029,8 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@preact/signals-core@1.14.0': - resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==} + '@preact/signals-core@1.14.1': + resolution: {integrity: sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==} '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -3277,106 +3311,8 @@ packages: resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==} engines: {node: '>=14.0.0'} - '@rolldown/binding-android-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': - resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.12': - resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} - - '@rolldown/pluginutils@1.0.0-rc.5': - resolution: {integrity: sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw==} + '@rolldown/pluginutils@1.0.0-rc.15': + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -3537,32 +3473,32 @@ packages: cpu: [x64] os: [win32] - '@sentry-internal/browser-utils@10.47.0': - resolution: {integrity: sha512-bVFRAeJWMBcBCvJKIFCMJ1/yQToL4vPGqfmlnDZeypcxkqUDKQ/Y3ziLHXoDL2sx0lagcgU2vH1QhCQ67Aujjw==} + '@sentry-internal/browser-utils@10.48.0': + resolution: {integrity: sha512-SCiTLBXzugFKxev6NoKYBIhQoDk0gUh0AVVVepCBqfCJiWBG01Zvv0R5tCVohr4cWRllkQ8mlBdNQd/I7s9tdA==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.47.0': - resolution: {integrity: sha512-pdvMmi4dQpX5S/vAAzrhHPIw3T3HjUgDNgUiCBrlp7N9/6zGO2gNPhUnNekP+CjgI/z0rvf49RLqlDenpNrMOg==} + '@sentry-internal/feedback@10.48.0': + resolution: {integrity: sha512-tGkEyOM1HDS9qebDphUMEnyk3qq/50AnuTBiFmMJyjNzowylVGmRRk0sr3xkmbVHCDXQCiYnDmSVlJ2x4SDMrQ==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.47.0': - resolution: {integrity: sha512-A5OY8friSe6g8WAK4L8IeOPiEd9D3Ps40DzRH5j2f6SUja0t90mKMvHRcRf8zq0d4BkdB+JM7tjOkwxpuv8heA==} + '@sentry-internal/replay-canvas@10.48.0': + resolution: {integrity: sha512-9nWuN2z4O+iwbTfuYV5ZmngBgJU/ZxfOo47A5RJP3Nu/kl59aJ1lUhILYOKyeNOIC/JyeERmpIcTxnlPXQzZ3Q==} engines: {node: '>=18'} - '@sentry-internal/replay@10.47.0': - resolution: {integrity: sha512-ScdovxP7hJxgMt70+7hFvwT02GIaIUAxdEM/YPsayZBeCoAukPW8WiwztJfoKtsfPyKJ5A6f0H3PIxTPcA9Row==} + '@sentry-internal/replay@10.48.0': + resolution: {integrity: sha512-sevRTePfuk4PNuz9KAKpmTZEomAU0aLXyIhOwA0OnUDdxPhkY8kq5lwDbuxTHv6DQUjUX3YgFbY45VH1JEqHKA==} engines: {node: '>=18'} - '@sentry/browser@10.47.0': - resolution: {integrity: sha512-rC0agZdxKA5XWfL4VwPOr/rJMogXDqZgnVzr93YWpFn9DMZT/7LzxSJVPIJwRUjx3bFEby3PcTa3YaX7pxm1AA==} + '@sentry/browser@10.48.0': + resolution: {integrity: sha512-4jt2zX2ExgFcNe2x+W+/k81fmDUsOrquGtt028CiGuDuma6kEsWBI4JbooT1jhj2T+eeUxe3YGbM23Zhh7Ghhw==} engines: {node: '>=18'} - '@sentry/core@10.47.0': - resolution: {integrity: sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==} + '@sentry/core@10.48.0': + resolution: {integrity: sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g==} engines: {node: '>=18'} - '@sentry/react@10.47.0': - resolution: {integrity: sha512-ZtJV6xxF8jUVE9e3YQUG3Do0XapG1GjniyLyqMPgN6cNvs/HaRJODf7m60By+VGqcl5XArEjEPTvx8CdPUXDfA==} + '@sentry/react@10.48.0': + resolution: {integrity: sha512-uc93vKjmu6gNns+JAX4qquuxWpAMit0uGPA1TYlMjct9NG1uX3TkDPJAr9Pgd1lOXx8mKqCmj5fK33QeExMpPw==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x @@ -3643,42 +3579,42 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.3.4': - resolution: {integrity: sha512-ohS8fX8UIP3LN6+mDZJLCDS4Qd2rsmGwes6V6fD0sbLOmIyCVY5y68r6NHMMGJKFRwadDQOmtOt8Vc6snExrIQ==} + '@storybook/addon-docs@10.3.5': + resolution: {integrity: sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA==} peerDependencies: - storybook: ^10.3.4 + storybook: ^10.3.5 - '@storybook/addon-links@10.3.4': - resolution: {integrity: sha512-4Kcdv0U5WEyteN08Mv4oAUXTigF8OHMLA7Bpf1VEQrtJfQsxoUjXzItOHhCyBvphufkZzbU0n6wCC8upEb7X7w==} + '@storybook/addon-links@10.3.5': + resolution: {integrity: sha512-Xe2wCGZ+hpZ0cDqAIBHk+kPc8nODNbu585ghd5bLrlYJMDVXoNM/fIlkrLgjIDVbfpgeJLUEg7vldJrn+FyOLw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.3.4 + storybook: ^10.3.5 peerDependenciesMeta: react: optional: true - '@storybook/addon-onboarding@10.3.4': - resolution: {integrity: sha512-59BgxVX3FJxS+cruvk7gfm/0azRJokDeHNNubEhgKIS8ih0ol9vJ7YbAJEP7QWOf22RghWtQSO0yMmTyZTPTyQ==} + '@storybook/addon-onboarding@10.3.5': + resolution: {integrity: sha512-s3/gIy9Tqxji27iclLY+KSk8kGeow1JxXMl1lPLyu8n6XVvv+tFrUPhAvUTs+fVenG6JQEWc0uzpYBdFRWbMtw==} peerDependencies: - storybook: ^10.3.4 + storybook: ^10.3.5 - '@storybook/addon-themes@10.3.4': - resolution: {integrity: sha512-5734o52qtW8svu2vhKPncISWLr1FZrXZoN+u1q0BjTrbL6qTNE1AzIMCBEwn0TNdn16vC3ZsDJOj1dW4dD13cw==} + '@storybook/addon-themes@10.3.5': + resolution: {integrity: sha512-Mv+C7GuZ0MhGRx5C+rv8sCEjgYsDTLBvq68101V0s8Vwh3gKd6W9cbS31HoOeLAiIMiPPZ8C1iWudA3Oumdtlw==} peerDependencies: - storybook: ^10.3.4 + storybook: ^10.3.5 - '@storybook/builder-vite@10.3.4': - resolution: {integrity: sha512-dNQyBZpBKvwmhSTpjrsuxxY8FqFCh0hgu5+46h2WbgQ2Te3pO458heWkGb+QO7mC6FmkXO6j6zgYzXticD6F2A==} + '@storybook/builder-vite@10.3.5': + resolution: {integrity: sha512-i4KwCOKbhtlbQIbhm53+Kk7bMnxa0cwTn1pxmtA/x5wm1Qu7FrrBQV0V0DNjkUqzcSKo1CjspASJV/HlY0zYlw==} peerDependencies: - storybook: ^10.3.4 + storybook: ^10.3.5 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.3.4': - resolution: {integrity: sha512-WPP0Z39o82WiohPkhPOs6z+9yJ+bVvqPz4d+QUPfE6FMvOOBLojlwOcGx6Xmclyn5H/CKwywFrjuz4mBO/nHhA==} + '@storybook/csf-plugin@10.3.5': + resolution: {integrity: sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw==} peerDependencies: esbuild: 0.27.2 rollup: 4.59.0 - storybook: ^10.3.4 + storybook: ^10.3.5 vite: '*' webpack: '*' peerDependenciesMeta: @@ -3700,40 +3636,40 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/nextjs-vite@10.3.4': - resolution: {integrity: sha512-TgfAgyAx2SvhLPMXFFHskQWMfOeGEY5kDXRC+SqDnuHapUiFignqvuq1Dh8I+Al1QNrFSa/q/TQiFPDHXPVm9g==} + '@storybook/nextjs-vite@10.3.5': + resolution: {integrity: sha512-PdgekGAnr4m/xhrvtl+ZVh68vKTfJN/AewxmqxqxSlwk0dO7B+uVGjO79WmEZwIlLvdT+3HIThTEfC1ozfpM7A==} peerDependencies: next: ^14.1.0 || ^15.0.0 || ^16.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.3.4 + storybook: ^10.3.5 typescript: '*' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - '@storybook/react-dom-shim@10.3.4': - resolution: {integrity: sha512-VIm9YzreGubnOtQOZ6iqEfj6KncHvAkrCR/IilqnJq7DidPWuykrFszyajTASRMiY+p+TElOW+O1PGpv55qNGw==} + '@storybook/react-dom-shim@10.3.5': + resolution: {integrity: sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.3.4 + storybook: ^10.3.5 - '@storybook/react-vite@10.3.4': - resolution: {integrity: sha512-xaMt7NdvlAb+CwXn5TOiluQ+0WkkMN3mZhCThocpblWGoyfmHH7bgQ5ZwzT+IIp8DGOsAi/HkNmSyS7Z8HRLJg==} + '@storybook/react-vite@10.3.5': + resolution: {integrity: sha512-UB5sJHeh26bfd8sNMx2YPGYRYmErIdTRaLOT28m4bykQIa1l9IgVktsYg/geW7KsJU0lXd3oTbnUjLD+enpi3w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.3.4 + storybook: ^10.3.5 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/react@10.3.4': - resolution: {integrity: sha512-I5ifYqjrqyuhOFjalpy47kMKMXX7QU/qmHj0h/547s9Bg6sEU7xRhJnneXx1RJsEJTySjC4SmGfEU+FJz4Foiw==} + '@storybook/react@10.3.5': + resolution: {integrity: sha512-tpLTLaVGoA6fLK3ReyGzZUricq7lyPaV2hLPpj5wqdXLV/LpRtAHClUpNoPDYSBjlnSjL81hMZijbkGC3mA+gw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.3.4 + storybook: ^10.3.5 typescript: '>= 4.9.x' peerDependenciesMeta: typescript: @@ -3936,27 +3872,27 @@ packages: vue: optional: true - '@tanstack/devtools@0.11.1': - resolution: {integrity: sha512-g3nHgVP76kT9190d6O32AjANoEnujLEB+51PDtBzlah8hvKeEygK53cunN+HXhjlfhM4PoOCi8/B96cdJVSnLg==} + '@tanstack/devtools@0.11.2': + resolution: {integrity: sha512-K8+tsBx+ptTLqqd4dOF10B6laj1g+XYImqYZL9n0jBINGaT+sOf17PKV9pbBt8kdbZeIGsHaJ5OZWCyZoHqN4A==} engines: {node: '>=18'} hasBin: true peerDependencies: solid-js: 1.9.11 - '@tanstack/eslint-plugin-query@5.96.1': - resolution: {integrity: sha512-BDJU+Q+zESjarSSFmbzpCBh+1wDxwW+DyQlvwIukF24MHYOoRPH4ouJRTlDdbp3BnIkeylZaHHSgIvxY9lgI/g==} + '@tanstack/eslint-plugin-query@5.99.0': + resolution: {integrity: sha512-jVp1AEL7S7BeuQvH5SN1F5UdrNW/AbryKDeWUUMeAKNzh9C+Ik/bRSa/HeuJLlmaN+WOUkdDFbtCK0go7BxnUQ==} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ^5.4.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ^5.4.0 || ^6.0.0 peerDependenciesMeta: typescript: optional: true - '@tanstack/form-core@1.28.6': - resolution: {integrity: sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog==} + '@tanstack/form-core@1.29.0': + resolution: {integrity: sha512-uyeKEdJBfbj0bkBSwvSYVRtWLOaXvfNX3CeVw1HqGOXVLxpBBGAqWdYLc+UoX/9xcoFwFXrjR9QqMPzvwm2yyQ==} - '@tanstack/form-devtools@0.2.20': - resolution: {integrity: sha512-4cW/eU5DBTrWP53mxwHKp4NQWTIQ3XCA91pMWK7dFNNClIwFnxoSJoKwyUa6b8kRIO6uq1Sjk2mhkAtj5kB22A==} + '@tanstack/form-devtools@0.2.21': + resolution: {integrity: sha512-8mxR1/QDw37mNVSFsr4ZN8+bdamH9LU1/iQ3I7/sfTzFmMsNzUOysX3OZf053eaS4Gaw44PT0pH7U0FWD98QKw==} peerDependencies: solid-js: 1.9.11 @@ -3964,14 +3900,14 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} - '@tanstack/query-core@5.96.1': - resolution: {integrity: sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==} + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} - '@tanstack/query-devtools@5.96.1': - resolution: {integrity: sha512-A4+uQTWbiqZDgrLeyjpFYLfMaWaKWpkwTkR1cUfocVj6vPYgym7QTG2se9A01WSxceDdmgxOqvn1ivcTvgWD8w==} + '@tanstack/query-devtools@5.99.0': + resolution: {integrity: sha512-m4ufXaJ8FjWXw7xDtyzE/6fkZAyQFg9WrbMrUpt8ZecRJx58jiFOZ2lxZMphZdIpAnIeto/S8stbwLKLusyckQ==} - '@tanstack/react-devtools@0.10.1': - resolution: {integrity: sha512-cvcd0EqN7Q2LYatQXxFhOkEa9RUQXZlhXnM1mwuibxmyRX+CMyohUZcgjodtIfgh+RT0Pmvt49liTdZby5ovZw==} + '@tanstack/react-devtools@0.10.2': + resolution: {integrity: sha512-1BmZyxOrI5SqmRJ5MgkYZNNdnlLsJxQRI2YgorrAvcF2MxK6x5RcuStvD8+YlXoMw3JtNukPxoITirKAnKYDQA==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=16.8' @@ -3979,13 +3915,13 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-form-devtools@0.2.20': - resolution: {integrity: sha512-aXtorJ7p3TbzOapjaxbjGX/c0uQh/wbYSwgzFt3qatNMb1xL4HM/j00Bx7hDENZNBCf8MF8YEEtvpBmnGb4rnQ==} + '@tanstack/react-form-devtools@0.2.21': + resolution: {integrity: sha512-WBQ7NOcb3FM9UA4juZVyWUyJkyl62vHFbEBybZuvBFw3wq/v9pDGS01Ye8kepGXDg1+LQsOOxyDR65AKsdqSYQ==} peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-form@1.28.6': - resolution: {integrity: sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA==} + '@tanstack/react-form@1.29.0': + resolution: {integrity: sha512-jj425NNX0QKqbUzqSNiYI3HCPHSk2df47acXCJyXczWOTmG81ECZGkgofgqamFsSU9kMiH6Di5RLUnftrlhWSw==} peerDependencies: '@tanstack/react-start': '*' react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3993,14 +3929,14 @@ packages: '@tanstack/react-start': optional: true - '@tanstack/react-query-devtools@5.96.1': - resolution: {integrity: sha512-3ZZ58fupIXtJFM0evj8YvWrauaZPUrQEqRYaq9e4ER/WPqTKeWEucqWCXn+KJLgWlcot5JIIUtQNynbovGjTTA==} + '@tanstack/react-query-devtools@5.99.0': + resolution: {integrity: sha512-CqqX7LCU9yOfCY/vBURSx2YSD83ryfX+QkfkaKionTfg1s2Hdm572Ro99gW3QPoJjzvsj1HM4pnN4nbDy3MXKA==} peerDependencies: - '@tanstack/react-query': ^5.96.1 + '@tanstack/react-query': ^5.99.0 react: ^18 || ^19 - '@tanstack/react-query@5.96.1': - resolution: {integrity: sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==} + '@tanstack/react-query@5.99.0': + resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} peerDependencies: react: ^18 || ^19 @@ -4055,18 +3991,18 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tsslint/cli@3.0.2': - resolution: {integrity: sha512-8lyZcDEs86zitz0wZ5QRdswY6xGz8j+WL11baN4rlpwahtPgYatujpYV5gpoKeyMAyerlNTdQh6u2LUJLoLNyQ==} + '@tsslint/cli@3.0.3': + resolution: {integrity: sha512-Pt1AuEZoh+dK4QYt95oCjBdBp2h2iYY9pSerf9BTLgfsjeyEsNk7Juhn51sFlAuEnWDNvI8mLULzsIkayd0nUQ==} engines: {node: '>=22.6.0'} hasBin: true peerDependencies: typescript: '*' - '@tsslint/compat-eslint@3.0.2': - resolution: {integrity: sha512-2TzSJPybCEfU/kHNi9UybwI//A7Fe14CwqmNuJ4fR4WYGpfIclXqfDJwsn5U1NzrWbHjWzRSntJITQPNw1SCNA==} + '@tsslint/compat-eslint@3.0.3': + resolution: {integrity: sha512-UGWrE4fu8fUCLkc+zMQNsEfuEkGHjndpa5oSQmzhmo9BQJYAqqH1s2kGIiDsAYwaQTUts4SjclXaITq3pZhkrA==} - '@tsslint/config@3.0.2': - resolution: {integrity: sha512-oHzteAwL6NHVrLzJnrpqMwewEFOydhDH228weO4wkHW8SwvE4oVV5qrKmjwL69ClYt5Le3y2aGDzGou+GuTbKg==} + '@tsslint/config@3.0.3': + resolution: {integrity: sha512-3yFyM4Sj+0LxwmcokwNPuS9pWUBMIhO8vwHiG4vGuquTvF4cgZqDPyQ3GN4hDb5qAZ56iqYtMoBEiSZXlJDYPQ==} engines: {node: '>=22.6.0'} hasBin: true peerDependencies: @@ -4078,12 +4014,12 @@ packages: tsl: optional: true - '@tsslint/core@3.0.2': - resolution: {integrity: sha512-Cu50e9vBojEMQjbqMoshkgLSoBj1BKbbmhSvzgbo07TiQ1wrOblZjvhU8ygB1fAIIHgU4laExX3pLU5OOeeR9g==} + '@tsslint/core@3.0.3': + resolution: {integrity: sha512-EpCKw34f2XyypH5xlxKCwnTgPGpZxbPXfvpwddT3DCxsIzUDJY4SpVJULAZFPAjJd49vopG0kNhXn0C/b+kHcg==} engines: {node: '>=22.6.0'} - '@tsslint/types@3.0.2': - resolution: {integrity: sha512-RbF3TIxu/YQwRpYrH5j2EL3ff4+Lr2SSmwCJmPJfi832F0hpgJj6xB9xKEorrUj0ZaTHE1QOr5SOMe5B6Qv+2Q==} + '@tsslint/types@3.0.3': + resolution: {integrity: sha512-3Jlb5UTPrzqu1D1qOrzjwy0QW2n41A1+ILKvzgViFrtiTwurM5Tav6V7Y4AFxO0xatCA0VHAzzifK0r5znaKbw==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -4256,8 +4192,8 @@ packages: '@types/negotiator@0.6.4': resolution: {integrity: sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -4303,11 +4239,11 @@ packages: '@types/zen-observable@0.8.3': resolution: {integrity: sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==} - '@typescript-eslint/eslint-plugin@8.58.0': - resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + '@typescript-eslint/eslint-plugin@8.58.2': + resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.58.0 + '@typescript-eslint/parser': ^8.58.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' @@ -4318,8 +4254,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.58.0': - resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + '@typescript-eslint/parser@8.58.2': + resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4331,8 +4267,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.58.0': - resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + '@typescript-eslint/project-service@8.58.2': + resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -4347,8 +4283,8 @@ packages: resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.58.0': - resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + '@typescript-eslint/scope-manager@8.58.2': + resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.57.2': @@ -4357,14 +4293,14 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.58.0': - resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + '@typescript-eslint/tsconfig-utils@8.58.2': + resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.58.0': - resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + '@typescript-eslint/type-utils@8.58.2': + resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4374,8 +4310,8 @@ packages: resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.58.0': - resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + '@typescript-eslint/types@8.58.2': + resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.57.2': @@ -4384,8 +4320,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.58.0': - resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + '@typescript-eslint/typescript-estree@8.58.2': + resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -4397,8 +4333,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.58.0': - resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + '@typescript-eslint/utils@8.58.2': + resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4408,47 +4344,47 @@ packages: resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.58.0': - resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + '@typescript-eslint/visitor-keys@8.58.2': + resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260401.1': - resolution: {integrity: sha512-9PCc1D4/zLic30g1upOw6ZmUl98fnrXYRv5wIZ6fxm1zZAObieRKUX3Jbr8M9N8iQQFxPIZPniIScsxAbmbJvw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260413.1': + resolution: {integrity: sha512-CDgxIPvAWRCfOiQKvSk4wUkAoRW4Cy6vfAUBPNHSeLalIt43ToF0LOAsa5uLyRGsftjfMYY0A4qFOmgDvBhgzQ==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260401.1': - resolution: {integrity: sha512-wwzca1KrjSVC6ApXfITsg/wF4GGbhVYebc7zChpuyi+phrHfw6ThTPB5XFUH4nA32vqw0Hn/6KACipMgzg8GPA==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260413.1': + resolution: {integrity: sha512-oiMmUtNMaqBh+eUogX53ichcEf7d+7upC0qa7xS9zWl85XEPKlrZCZpZ79yixw1PkdpjqJJigI11bmCi/JVv+g==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260401.1': - resolution: {integrity: sha512-1hgKibGi4QZF1J0hKltgY4nj4yKDmI4Ang5ar80I+YeUdGxV/fP2kU3bJang7QtHuSso6W+a52SF62zgqbzdow==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260413.1': + resolution: {integrity: sha512-hPKanfs9c+7953gIYw13CNxN0HqFAOfJjnWk4SHqSBe3Pj9pxoeJvvRWlofp5C833eOZK6gZB7ll0/uNb0djtA==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260401.1': - resolution: {integrity: sha512-bbIkRZYjtyoyCJ3wFES7qn3EwYO5Go1hxArL5X5oWiBmUHq5gMIxTZDv5mpWxopVf9Eyh4ErHefXjf1s4J+6Ag==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260413.1': + resolution: {integrity: sha512-0lSXBzBVsxIGrFv/PxoswzMptsnU6BgSk7GMAUt/o1dVw36R2XrSs538vwKnujaJwt4iIdMS0uGdpUC5s9jkzQ==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260401.1': - resolution: {integrity: sha512-1ysZ4c/Wa3RYIlrwVceYlhb1m1hxQ4P2x92valZXH0bNWEPb+oiQ4Yf35O/vi5h8zDdX/ZQ59vivYl27cF1VVA==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260413.1': + resolution: {integrity: sha512-8Cr477HRmHZ5YyLfikNvw7qp3/WmnRjzIzJhUDrAx5173OBe8BdyV9jPemFHKDPqwI1AUMTijvptOFoQE7429w==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260401.1': - resolution: {integrity: sha512-fZYLCRe36y1BuzRFFpU2/RQ212l6Y1dccRMh8oTB8HlAVAAwtbkb6cjEn0Ablj4Dy16+Ih8R9uHsxPLNhtKw1Q==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260413.1': + resolution: {integrity: sha512-ulJD9ZbIQyTBIDx8zzAzQLtbvQDGHSWrNRgkgBU5Os2NTYADQRco4pU747R9wZPMLopy3IeNck6m8vwPoYMk1g==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260401.1': - resolution: {integrity: sha512-I6ses4SjWvpbvSpm1BPFRrDeqrzu7JTchJG/a26iwwmTLv4fAGLc5/o6Kv9Naygozop1W3KOcVM5i3A9oxiIjQ==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260413.1': + resolution: {integrity: sha512-x7DsSXnLQBf5XBBR8luHf1Nc/T1eByUmrOSEThW6825UB7lHoPlqKdhIoUNnTnS4nXQMxLwcusD4P1EP23GPJw==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260401.1': - resolution: {integrity: sha512-xJcN9WlY/P6xKjCMH4+DTzZSj/EKR6H9avuqUKs4eKyPEvaQ4bX+9Ys3Vl2qhlUaD7IRWY7HN7db0LHAGlWRSA==} + '@typescript/native-preview@7.0.0-dev.20260413.1': + resolution: {integrity: sha512-twzr3V4QLEbXaESuI2DqdzutOVFGpkY3VZDR9sF8YlLsAXkwyQvZo58cKM77mZcsHoCR4lCYcdTatWTTa/+8tw==} hasBin: true '@ungap/structured-clone@1.3.0': @@ -4505,8 +4441,8 @@ packages: babel-plugin-react-compiler: optional: true - '@vitejs/plugin-rsc@0.5.21': - resolution: {integrity: sha512-uNayLT8IKvWoznvQyfwKuGiEFV28o7lxUDnw/Av36VCuGpDFZnMmvVCwR37gTvnSmnpul9V0tdJqY3tBKEaDqw==} + '@vitejs/plugin-rsc@0.5.24': + resolution: {integrity: sha512-FQ7o1Zf1GUB8L5qlIuV2mvIv/KahG2qUYW2gMpxyIN3zF7voDsfvA/t8w/TLjYC0T6p3JwMnK3N+YzMGf/m75A==} peerDependencies: react: '*' react-dom: '*' @@ -4516,17 +4452,17 @@ packages: react-server-dom-webpack: optional: true - '@vitest/coverage-v8@4.1.2': - resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==} + '@vitest/coverage-v8@4.1.4': + resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} peerDependencies: - '@vitest/browser': 4.1.2 - vitest: 4.1.2 + '@vitest/browser': 4.1.4 + vitest: 4.1.4 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/eslint-plugin@1.6.14': - resolution: {integrity: sha512-PXZ5ysw4eHU9h8nDtBvVcGC7Z2C/T9CFdheqSw1NNXFYqViojub0V9bgdYI67iBTOcra2mwD0EYldlY9bGPf2Q==} + '@vitest/eslint-plugin@1.6.15': + resolution: {integrity: sha512-dTMjrdngmcB+DxomlKQ+SUubCTvd0m2hQQFpv5sx+GRodmeoxr2PVbphk57SVp250vpxphk9Ccwyv6fQ6+2gkA==} engines: {node: '>=18'} peerDependencies: '@typescript-eslint/eslint-plugin': '*' @@ -4547,8 +4483,8 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.1.2': - resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + '@vitest/pretty-format@4.1.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} @@ -4556,11 +4492,11 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.1.2': - resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} - '@voidzero-dev/vite-plus-core@0.1.15': - resolution: {integrity: sha512-0qAbqwcvQwiC8xGKSSuFtsjJUEM4LZzpXF7dffRazghGEQ8HH8NAvVryp/PiMSFwreJlV3rujwL4amKjnwCHpg==} + '@voidzero-dev/vite-plus-core@0.1.16': + resolution: {integrity: sha512-fOyf14CXjcXqANFs2fCXEX+0Tn9ZjmqfFV+qTnARwIF1Kzl8WquO4XtvlDgs/fTQ91H4AyoNUgkvWdKS+C4xYA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 @@ -4568,7 +4504,7 @@ packages: '@tsdown/exe': 0.21.7 '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.1.0 - esbuild: 0.27.2 + esbuild: ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 publint: ^0.3.0 @@ -4619,48 +4555,48 @@ packages: yaml: optional: true - '@voidzero-dev/vite-plus-darwin-arm64@0.1.15': - resolution: {integrity: sha512-arFq8phXg96rQ5J+FYvkBYdEGxIhP1ePAXlUeQY2hV8hJPzse+CdxusWxcjfpTgvFi+dpsKzE4KSNS22PyBo7w==} + '@voidzero-dev/vite-plus-darwin-arm64@0.1.16': + resolution: {integrity: sha512-InG0ZmuGh7DTrn7zWQ0UvKapElphKI6G1oYfys+jraedG70EhIIee9gtO+mTE1T0bF67SgAcLXwNyaiNda0XwA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@voidzero-dev/vite-plus-darwin-x64@0.1.15': - resolution: {integrity: sha512-2eY+gTEIZvLH33nQmcL2tKlf+iHfClaqaSMYIlUpTp/CN+xqh4Ir4y2vN1XGEuFDIW0FshSZTg3ulPtduneEDA==} + '@voidzero-dev/vite-plus-darwin-x64@0.1.16': + resolution: {integrity: sha512-LGNrECstuhkCRKRj/dE98Xcprw8HU3VMIMJnZsnDR2C5RB2HADNIu21at/a/G3giA9eWm7uhtPp9FvUtTCK9TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.15': - resolution: {integrity: sha512-jJgz84pp61oHeXAYIUXKsVwQsMQ7NHK0+dBe6v1Q+Z034xXsyBrxi/JASSeVmCpAd6CN+xzOCsfMyn3whVTTxQ==} + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.16': + resolution: {integrity: sha512-AoFKu6dIOtlkp/mwmtU8ES2uzoaxCHhIym1Tk7qMxyvke4IXnye6VDc4kPMRQwD8mwR3T3bO0HuaEEHxrIWDxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.15': - resolution: {integrity: sha512-F0Wig+We0ERhGecf3fDIwM/kfqT0vP2htH0vKUnV/inHIVbPc1MsrjcExX1eJ6KFSp5YTfchRN8HGecqtsudPA==} + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.16': + resolution: {integrity: sha512-PloCsGTRIhcXIpUOJ6PqVG8gYNpq+ooJNyqy5sQ82BRnJuo8oV7uBLFvg0X9B3Bzh+vO1F8/+92+o5TiL35JMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.15': - resolution: {integrity: sha512-aT5Yr2GphvRjoc2URmELDqjWwhe5VPvyy15Tzum+jPhEjY4I/lPXxKXEROjQe3TIv6MmFSHCe3oNCSaFdUE1pA==} + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.16': + resolution: {integrity: sha512-nY9/2g+qjhwsW5U3MrFLlx+bOBsdOJiO2HzbxQy7jo/S3jPTnXhFlrRegQuAmqrHAXrSdNwgblgRpICKhx1xZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.15': - resolution: {integrity: sha512-Q6qMBMdVp5v84YVzFvMUpzVIHLfJuwZQR/KUtAOn/hzpfNITigKR2GrZZDgQvszFW+0CPhDFcK3kqLkxlJCdFg==} + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.16': + resolution: {integrity: sha512-JGKEAMoXqzdr9lHT/13uRNV9uzrSYXAFhjAfIC8WEQMG2VUFksvq5/TOc26hzmzbqu+bxRmfN8h1aVTDL8KwFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-test@0.1.15': - resolution: {integrity: sha512-jxMUEX6PDpzMUz+KOVOoB8HiODMf5mWjH19pof0k9l/RZT4iLDyVXB+p9PoWjbVrEMMGzq9BTOVob7wfOZeZEA==} + '@voidzero-dev/vite-plus-test@0.1.16': + resolution: {integrity: sha512-d/rJPX/heMzoAFdnpZsp04MAa6nw1yH1tA4mVCV4m8goVcE9nAvt69mjLMzE8N/rYIQOSgenf3hDXuQRuD6OKQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@edge-runtime/vm': '*' @@ -4684,14 +4620,14 @@ packages: jsdom: optional: true - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.15': - resolution: {integrity: sha512-EePrs+NIUy3gE60qaXPXzj8mw+JAXEBfGKsfweYBgNK6jo9ZXZto5ViKTuQsVVuWLVaELZSjoudbkzXB8wnJoQ==} + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.16': + resolution: {integrity: sha512-IugPUCLY7HmiPcCeuHKUqO1+G2vxHnYzAGhS02AixD0sJLTAIKCUANDOiVUFf/HMw+jh/UkugW7MWek8lf/JrQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.15': - resolution: {integrity: sha512-vfYfwOG/5a/WUtgGrbUCatRkc5x0Rq/9GDlCzQQIAFGDB5BfyIjGbdCOqamQWOh+yQbeOHwvgAhqjZ7Dv1oo/w==} + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.16': + resolution: {integrity: sha512-tq93CIeMs92HF7rdylJknRiyzMOWMKCmpw+g8nl5Q5nmUDNLUsrL3CGfbyqjgbruuPnIr761r9MfydPqZU/cYg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5406,6 +5342,9 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -5646,8 +5585,8 @@ packages: '@eslint/json': optional: true - eslint-markdown@0.6.0: - resolution: {integrity: sha512-NrgfiNto5IJrW1F/Akf2hJYoJTCbXoClOUvtUMDgoqmQNH0VRihNvFh+MFay4E0HV2eozfgxsLSGxnndtRJA8w==} + eslint-markdown@0.6.1: + resolution: {integrity: sha512-eiHSRFnzcPWN/0YDrtELW/+GnGylAoyXVBDh0iVAttyC5rWAaZfgSrzlFUTlS7Jz4XEL36PFLsoEcXlbvl5qPQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} peerDependencies: eslint: ^9.31.0 || ^10.0.0-rc.0 @@ -5665,8 +5604,8 @@ packages: peerDependencies: eslint: '*' - eslint-plugin-better-tailwindcss@4.3.2: - resolution: {integrity: sha512-1DLX2QmHmOj3u667f8vEI0zKoRc0Y1qJt33tfIeIkpTyzWaz9b2GzWBLD4bR+WJ/kxzC0Skcbx7cMerRWQ6OYg==} + eslint-plugin-better-tailwindcss@4.4.1: + resolution: {integrity: sha512-ueFciTgj2M+4YklYdtvpbMA3Nn22z60sQoSA4bnctOP4h0daUhJKAsDaGi888N00qWtIUqeK5Ikt6xnNnHPg2g==} engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -5708,8 +5647,8 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 - eslint-plugin-jsdoc@62.8.1: - resolution: {integrity: sha512-e9358PdHgvcMF98foNd3L7hVCw70Lt+YcSL7JzlJebB8eT5oRJtW6bHMQKoAwJtw6q0q0w/fRIr2kwnHdFDI6A==} + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -5720,8 +5659,8 @@ packages: peerDependencies: eslint: '>=9.38.0' - eslint-plugin-markdown-preferences@0.41.0: - resolution: {integrity: sha512-Pu150jKH1Cf5sW/Igck0VbuT0A9qFpIPG1dDvyAt2lG8tA3VzPDkwxBusO8JqQ9NRIrm3pat0X6cfanSki3WZQ==} + eslint-plugin-markdown-preferences@0.41.1: + resolution: {integrity: sha512-Xi4rlT7oBZ8PMGDl7J9khgO2vF9X0F/6ag05/25Vyq7r3llaK95x9D6DpzXidxC2Gagl/e8bp2Hw47r4I3wWSA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: '@eslint/markdown': ^7.4.0 || ^8.0.0 @@ -5733,15 +5672,17 @@ packages: peerDependencies: eslint: '>=8.23.0' - eslint-plugin-no-barrel-files@1.2.2: - resolution: {integrity: sha512-DF2bnHuEHClmL1+maBO5TD2HnnRsLj8J69FFtVkjObkELyjCXaWBsk+URJkqBpdOWURlL+raGX9AEpWCAiOV0g==} + eslint-plugin-no-barrel-files@1.3.1: + resolution: {integrity: sha512-y7OX5kyH7PMNRFhLF6SmM4JapxvaxExrgWPndPNTzilpO5uBqybuN480g3E8TTxT3OLOOhQDynmcJ0dnipIyNA==} + peerDependencies: + eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 eslint-plugin-no-only-tests@3.3.0: resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} engines: {node: '>=5.0.0'} - eslint-plugin-perfectionist@5.7.0: - resolution: {integrity: sha512-WRHj7OZS/INutQ/gKN5C1ZGnMhkQ3oKZQAA2I7rl5yM8keBtSd9oj/qlJaHuwh5873FhMPqYlttcadF0YsTN7g==} + eslint-plugin-perfectionist@5.8.0: + resolution: {integrity: sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==} engines: {node: ^20.0.0 || >=22.0.0} peerDependencies: eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 @@ -5802,11 +5743,11 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 - eslint-plugin-storybook@10.3.4: - resolution: {integrity: sha512-6jRb9ucYWKRkbuxpN+83YA3wAWuKn6rp+OVXivy0FPa82v8eciHG8OidbznmzrfcRJYkNWUb7GrPjG/rf4Vqaw==} + eslint-plugin-storybook@10.3.5: + resolution: {integrity: sha512-rEFkfU3ypF44GpB4tiJ9EFDItueoGvGi3+weLHZax2ON2MB7VIDsxdSUGvIU5tMURg+oWYlpzCyLm4TpDq2deA==} peerDependencies: eslint: '>=8' - storybook: ^10.3.4 + storybook: ^10.3.5 eslint-plugin-toml@1.3.1: resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} @@ -5879,8 +5820,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.1.0: - resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + eslint@10.2.0: + resolution: {integrity: sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -5956,9 +5897,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-target-bus@1.0.0: - resolution: {integrity: sha512-uPcWKbj/BJU3Tbw9XqhHqET4/LBOhvv3/SJWr7NksxA6TC5YqBpaZgawE9R+WpYFCBFSAE4Vun+xQS6w4ABdlA==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -6071,17 +6009,6 @@ packages: engines: {node: '>=18.3.0'} hasBin: true - foxact@0.3.0: - resolution: {integrity: sha512-CSlMlC0KlKQQEO83iLeQCLuT1V0OqnMWj7mjLstIDV8baMe1w4F7z3cz3/T+6Z8W12jqkQj07rwlw4Gi39knGg==} - peerDependencies: - react: '*' - react-dom: '*' - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -6150,8 +6077,8 @@ packages: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} - globals@17.4.0: - resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} engines: {node: '>=18'} globrex@0.1.2: @@ -6168,8 +6095,8 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - happy-dom@20.8.9: - resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==} + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} engines: {node: '>=20.0.0'} has-ansi@4.0.1: @@ -6229,8 +6156,8 @@ packages: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} engines: {node: '>=6'} - hono@4.12.10: - resolution: {integrity: sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==} + hono@4.12.12: + resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} engines: {node: '>=16.9.0'} hosted-git-info@9.0.2: @@ -6261,8 +6188,8 @@ packages: i18next-resources-to-backend@1.2.1: resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} - i18next@26.0.3: - resolution: {integrity: sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==} + i18next@26.0.4: + resolution: {integrity: sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -6402,9 +6329,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -6439,8 +6363,8 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jotai@2.19.0: - resolution: {integrity: sha512-r2wwxEXP1F2JteDLZEOPoIpAHhV89paKsN5GWVYndPNMMP/uVZDcC+fNj0A8NjKgaPWzdyO8Vp8YcYKe0uCEqQ==} + jotai@2.19.1: + resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==} engines: {node: '>=12.20.0'} peerDependencies: '@babel/core': '>=7.0.0' @@ -6484,6 +6408,10 @@ packages: resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} engines: {node: '>=20.0.0'} + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6513,9 +6441,6 @@ packages: resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} @@ -6526,8 +6451,8 @@ packages: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - katex@0.16.44: - resolution: {integrity: sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==} + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} hasBin: true keyv@4.5.4: @@ -6536,8 +6461,8 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - knip@6.2.0: - resolution: {integrity: sha512-4OMUMJARvNble8e8TeFv12flp4fKzAITrQec1eKO4g2eA4HnNqEa8CXy2UOPLjuYuAETpe0N0r25jF9yY9FLig==} + knip@6.4.1: + resolution: {integrity: sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -6547,9 +6472,9 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - ky@1.14.3: - resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} - engines: {node: '>=18'} + ky@2.0.0: + resolution: {integrity: sha512-KzI4Vz5AbZFAUFYGx28PCSfFWUo6/qj9Br/P6KRwDieE1xfdz0tIONepJcLw/1xLocN13GgvfJGasa+pfSkbHg==} + engines: {node: '>=22'} lamejs@1.2.1: resolution: {integrity: sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==} @@ -6577,8 +6502,8 @@ packages: '@lexical/utils': '>=0.28.0' lexical: '>=0.28.0' - lexical@0.42.0: - resolution: {integrity: sha512-GY9Lg3YEIU7nSFaiUlLspZ1fm4NfIcfABaxy9nT+fRVDkX7iV005T5Swil83gXUmxFUNKGal3j+hUxHOUDr+Aw==} + lexical@0.43.0: + resolution: {integrity: sha512-waSeXyt1HxTFpU8KNRA3IQcvjvpw0lZNaSbGopfOi4bLV0FF9zYpqiScTnEUMP/b1W7qWmD4Z2Detw43XICxqQ==} lib0@0.2.117: resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} @@ -6817,8 +6742,8 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - mdn-data@2.23.0: - resolution: {integrity: sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -7056,8 +6981,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.2: - resolution: {integrity: sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==} + next@16.2.3: + resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -7170,8 +7095,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.18.1: - resolution: {integrity: sha512-Hgb0wMfuXBYL0ddY+1hAG8IIfC40ADwPnBuUaC6ENAuCtTF4dHwsy7mCYtQ2e7LoGvfoSJRY0+kqQRiembJ/jQ==} + oxlint-tsgolint@0.20.0: + resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==} hasBin: true oxlint@1.58.0: @@ -7287,9 +7212,6 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - periscopic@4.0.2: - resolution: {integrity: sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7301,8 +7223,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pinyin-pro@3.28.0: - resolution: {integrity: sha512-mMRty6RisoyYNphJrTo3pnvp3w8OMZBrXm9YSWkxhAfxKj1KZk2y8T2PDIZlDDRsvZ0No+Hz6FI4sZpA6Ey25g==} + pinyin-pro@3.28.1: + resolution: {integrity: sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==} pixelmatch@7.1.0: resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} @@ -7360,8 +7282,8 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -7407,8 +7329,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -7445,10 +7367,10 @@ packages: resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} engines: {node: ^20.9.0 || >=22} - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: - react: ^19.2.4 + react: ^19.2.5 react-draggable@4.5.0: resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} @@ -7476,14 +7398,14 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - react-i18next@17.0.2: - resolution: {integrity: sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==} + react-i18next@16.5.8: + resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==} peerDependencies: - i18next: '>= 26.0.1' + i18next: '>= 25.6.2' react: '>= 16.8.0' react-dom: '*' react-native: '*' - typescript: ^5 || ^6 + typescript: ^5 peerDependenciesMeta: react-dom: optional: true @@ -7540,12 +7462,12 @@ packages: react: '>=16.3.0' react-dom: '>=16.3.0' - react-server-dom-webpack@19.2.4: - resolution: {integrity: sha512-zEhkWv6RhXDctC2N7yEUHg3751nvFg81ydHj8LTTZuukF/IF1gcOKqqAL6Ds+kS5HtDVACYPik0IvzkgYXPhlQ==} + react-server-dom-webpack@19.2.5: + resolution: {integrity: sha512-bYhdd2cZJhXHqyJBoloYaJrn8MrL9Egf3ZZVn0OrIODCCORm2goFD7C+xszf6xgfsSJi0rtgB/ichcuHfkJ4yQ==} engines: {node: '>=0.10.0'} peerDependencies: - react: ^19.2.4 - react-dom: ^19.2.4 + react: ^19.2.5 + react-dom: ^19.2.5 webpack: ^5.59.0 react-sortablejs@6.1.4: @@ -7572,8 +7494,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} reactflow@11.11.4: @@ -7730,11 +7652,6 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rolldown@1.0.0-rc.12: - resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -7743,9 +7660,6 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} - rsc-html-stream@0.0.7: - resolution: {integrity: sha512-v9+fuY7usTgvXdNl8JmfXCvSsQbq2YMd60kOeeMIqCJFZ69fViuIxztHei7v5mlMMa2h3SqS+v44Gu9i9xANZA==} - run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -7812,9 +7726,6 @@ packages: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} - server-only@0.0.1: - resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -7890,8 +7801,8 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - srvx@0.11.13: - resolution: {integrity: sha512-oknN6qduuMPafxKtHucUeG32Q963pjriA5g3/Bl05cwEsUe5VVbIU4qR9LrALHbipSCyBe+VmfDGGydqazDRkw==} + srvx@0.11.15: + resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} engines: {node: '>=20.16.0'} hasBin: true @@ -7908,8 +7819,8 @@ packages: resolution: {integrity: sha512-9SN0XIjBBXCT6ZXXVnScJN4KP2RyFg6B8sEoFlugVHMANysfaEni4LTWlvUQQ/R0wgZl1Ovt9KBQbzn21kHoZA==} engines: {node: '>=20.19.0'} - storybook@10.3.4: - resolution: {integrity: sha512-866YXZy9k59tLPl9SN3KZZOFeBC/swxkuBVtW8iQjJIzfCrvk7zXQd8RSQ4ignmCdArVvY4lGMCAT4yNaZSt1g==} + storybook@10.3.5: + resolution: {integrity: sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -8027,8 +7938,8 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} - tailwind-csstree@0.1.5: - resolution: {integrity: sha512-ZHCKXz+TcBj7CJYStiuAtNenPpdHMrhgotOSNJ3UQTSTgwTfAyoyTA2SNW4oD8+2T6xt6awM7CZSU2+PXx9V3w==} + tailwind-csstree@0.3.1: + resolution: {integrity: sha512-v147gLOR+E+9H4dNaP9rBeS/S/CTQJMRItlX9jLOXjdBGfSRauLwiz7LBCViaQmn6URXIlOdN6iMzSzOaeoUUw==} engines: {node: '>=18.18'} peerDependencies: '@eslint/css': '>=1.0.0' @@ -8124,11 +8035,11 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tldts-core@7.0.27: - resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} - tldts@7.0.27: - resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} hasBin: true to-regex-range@5.0.1: @@ -8251,8 +8162,8 @@ packages: resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} engines: {node: '>=14'} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} undici@7.24.0: resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==} @@ -8414,17 +8325,17 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vinext@0.0.39: - resolution: {integrity: sha512-2WCW5wKy4YkCGFtoUGDmfxMBPULGOXEqd7Rczwi4cMEx2SG8UnY5/56XXGAnjf8aIIrx8ggOVWJSebH96QdjZg==} + vinext@0.0.41: + resolution: {integrity: sha512-fpQjNp6cIqjYGH2/kbhN2SdIYHEu79RdlII23SWsY1Qp7LM+je8GfTJH1sxw6dASxPhZKZB/jCmTm5d2/D25zw==} engines: {node: '>=22'} hasBin: true peerDependencies: '@mdx-js/rollup': ^3.0.0 '@vitejs/plugin-react': ^5.1.4 || ^6.0.0 - '@vitejs/plugin-rsc': ^0.5.21 - react: '>=19.2.0' - react-dom: '>=19.2.0' - react-server-dom-webpack: ^19.2.4 + '@vitejs/plugin-rsc': ^0.5.23 + react: ^19.2.5 + react-dom: ^19.2.5 + react-server-dom-webpack: ^19.2.5 vite: ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@mdx-js/rollup': @@ -8457,8 +8368,8 @@ packages: storybook: ^0.0.0-0 || ^9.0.0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - vite-plus@0.1.15: - resolution: {integrity: sha512-PBUvTq4D4BJcuusCA3mrSQmXcGVdPX9CIPpS7Y6+T+LbDsrmAZ+ITl9FzuE6zXvpT6Nht9cpHtwOLJw7m3adog==} + vite-plus@0.1.16: + resolution: {integrity: sha512-sgYHc5zWLSDInaHb/abvEA7UOwh7sUWuyNt+Slphj55jPvzodT8Dqw115xyKwDARTuRFSpm1eo/t58qZ8/NylQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -8475,53 +8386,10 @@ packages: peerDependencies: vite: '*' - vite@8.0.3: - resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: 0.27.2 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: 2.8.3 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitefu@1.1.2: - resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: vite: optional: true @@ -8686,9 +8554,6 @@ packages: zen-observable@0.10.0: resolution: {integrity: sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==} - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -8742,27 +8607,27 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@amplitude/analytics-browser@2.38.1': + '@amplitude/analytics-browser@2.39.0': dependencies: - '@amplitude/analytics-core': 2.44.1 - '@amplitude/plugin-autocapture-browser': 1.25.1 - '@amplitude/plugin-custom-enrichment-browser': 0.1.3 - '@amplitude/plugin-network-capture-browser': 1.9.12 - '@amplitude/plugin-page-url-enrichment-browser': 0.7.4 - '@amplitude/plugin-page-view-tracking-browser': 2.9.5 - '@amplitude/plugin-web-vitals-browser': 1.1.27 + '@amplitude/analytics-core': 2.45.0 + '@amplitude/plugin-autocapture-browser': 1.25.2 + '@amplitude/plugin-custom-enrichment-browser': 0.1.4 + '@amplitude/plugin-network-capture-browser': 1.9.13 + '@amplitude/plugin-page-url-enrichment-browser': 0.7.5 + '@amplitude/plugin-page-view-tracking-browser': 2.9.6 + '@amplitude/plugin-web-vitals-browser': 1.1.28 tslib: 2.8.1 - '@amplitude/analytics-client-common@2.4.42': + '@amplitude/analytics-client-common@2.4.43': dependencies: '@amplitude/analytics-connector': 1.6.4 - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-core': 2.45.0 '@amplitude/analytics-types': 2.11.1 tslib: 2.8.1 '@amplitude/analytics-connector@1.6.4': {} - '@amplitude/analytics-core@2.44.1': + '@amplitude/analytics-core@2.45.0': dependencies: '@amplitude/analytics-connector': 1.6.4 '@types/zen-observable': 0.8.3 @@ -8776,48 +8641,48 @@ snapshots: dependencies: js-base64: 3.7.8 - '@amplitude/plugin-autocapture-browser@1.25.1': + '@amplitude/plugin-autocapture-browser@1.25.2': dependencies: - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-core': 2.45.0 tslib: 2.8.1 - '@amplitude/plugin-custom-enrichment-browser@0.1.3': + '@amplitude/plugin-custom-enrichment-browser@0.1.4': dependencies: - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-core': 2.45.0 tslib: 2.8.1 - '@amplitude/plugin-network-capture-browser@1.9.12': + '@amplitude/plugin-network-capture-browser@1.9.13': dependencies: - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-core': 2.45.0 tslib: 2.8.1 - '@amplitude/plugin-page-url-enrichment-browser@0.7.4': + '@amplitude/plugin-page-url-enrichment-browser@0.7.5': dependencies: - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-core': 2.45.0 tslib: 2.8.1 - '@amplitude/plugin-page-view-tracking-browser@2.9.5': + '@amplitude/plugin-page-view-tracking-browser@2.9.6': dependencies: - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-core': 2.45.0 tslib: 2.8.1 - '@amplitude/plugin-session-replay-browser@1.27.6(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0)': + '@amplitude/plugin-session-replay-browser@1.27.7(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0)': dependencies: - '@amplitude/analytics-client-common': 2.4.42 - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-client-common': 2.4.43 + '@amplitude/analytics-core': 2.45.0 '@amplitude/analytics-types': 2.11.1 '@amplitude/rrweb-plugin-console-record': 2.0.0-alpha.36(@amplitude/rrweb@2.0.0-alpha.37) '@amplitude/rrweb-record': 2.0.0-alpha.36 - '@amplitude/session-replay-browser': 1.35.1(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0) + '@amplitude/session-replay-browser': 1.36.0(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0) idb-keyval: 6.2.2 tslib: 2.8.1 transitivePeerDependencies: - '@amplitude/rrweb' - rollup - '@amplitude/plugin-web-vitals-browser@1.1.27': + '@amplitude/plugin-web-vitals-browser@1.1.28': dependencies: - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-core': 2.45.0 tslib: 2.8.1 web-vitals: 5.1.0 @@ -8841,7 +8706,7 @@ snapshots: '@amplitude/rrweb-snapshot@2.0.0-alpha.37': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 '@amplitude/rrweb-types@2.0.0-alpha.36': {} @@ -8862,10 +8727,10 @@ snapshots: base64-arraybuffer: 1.0.2 mitt: 3.0.1 - '@amplitude/session-replay-browser@1.35.1(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0)': + '@amplitude/session-replay-browser@1.36.0(@amplitude/rrweb@2.0.0-alpha.37)(rollup@4.59.0)': dependencies: - '@amplitude/analytics-client-common': 2.4.42 - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-client-common': 2.4.43 + '@amplitude/analytics-core': 2.45.0 '@amplitude/analytics-types': 2.11.1 '@amplitude/experiment-core': 0.7.2 '@amplitude/rrweb-packer': 2.0.0-alpha.36 @@ -8883,56 +8748,56 @@ snapshots: '@amplitude/targeting@0.2.0': dependencies: - '@amplitude/analytics-client-common': 2.4.42 - '@amplitude/analytics-core': 2.44.1 + '@amplitude/analytics-client-common': 2.4.43 + '@amplitude/analytics-core': 2.45.0 '@amplitude/analytics-types': 2.11.1 '@amplitude/experiment-core': 0.7.2 idb: 8.0.0 tslib: 2.8.1 - '@antfu/eslint-config@8.0.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.2)(@typescript-eslint/rule-tester@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2))(@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.18.1))(typescript@6.0.2)': + '@antfu/eslint-config@8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.2.0 - '@e18e/eslint-plugin': 0.3.0(eslint@10.1.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.18.1)) - '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.1.0(jiti@2.6.1)) + '@e18e/eslint-plugin': 0.3.0(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.2.0(jiti@2.6.1)) '@eslint/markdown': 8.0.1 - '@stylistic/eslint-plugin': 5.10.0(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@vitest/eslint-plugin': 1.6.14(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.0(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@vitest/eslint-plugin': 1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) ansis: 4.2.0 cac: 7.0.0 - eslint: 10.1.0(jiti@2.6.1) - eslint-config-flat-gitignore: 2.3.0(eslint@10.1.0(jiti@2.6.1)) + eslint: 10.2.0(jiti@2.6.1) + eslint-config-flat-gitignore: 2.3.0(eslint@10.2.0(jiti@2.6.1)) eslint-flat-config-utils: 3.1.0 - eslint-merge-processors: 2.0.0(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-antfu: 3.2.2(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2))(@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-import-lite: 0.6.0(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-jsdoc: 62.8.1(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-jsonc: 3.1.2(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-n: 17.24.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + eslint-merge-processors: 2.0.0(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-import-lite: 0.6.0(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-jsonc: 3.1.2(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) eslint-plugin-no-only-tests: 3.3.0 - eslint-plugin-perfectionist: 5.7.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-pnpm: 1.6.0(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-regexp: 3.1.0(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-toml: 1.3.1(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-unicorn: 64.0.0(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.1.0(jiti@2.6.1)))(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@2.6.1))) - eslint-plugin-yml: 3.3.1(eslint@10.1.0(jiti@2.6.1)) - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.31)(eslint@10.1.0(jiti@2.6.1)) - globals: 17.4.0 + eslint-plugin-perfectionist: 5.8.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint-plugin-pnpm: 1.6.0(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-regexp: 3.1.0(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-toml: 1.3.1(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-unicorn: 64.0.0(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.2.0(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1))) + eslint-plugin-yml: 3.3.1(eslint@10.2.0(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.31)(eslint@10.2.0(jiti@2.6.1)) + globals: 17.5.0 local-pkg: 1.1.2 parse-gitignore: 2.0.0 toml-eslint-parser: 1.0.3 - vue-eslint-parser: 10.4.0(eslint@10.1.0(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.2.0(jiti@2.6.1)) yaml-eslint-parser: 2.0.0 optionalDependencies: - '@eslint-react/eslint-plugin': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@next/eslint-plugin-next': 16.2.2 - eslint-plugin-react-refresh: 0.5.2(eslint@10.1.0(jiti@2.6.1)) + '@eslint-react/eslint-plugin': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@next/eslint-plugin-next': 16.2.3 + eslint-plugin-react-refresh: 0.5.2(eslint@10.2.0(jiti@2.6.1)) transitivePeerDependencies: - '@eslint/json' - '@typescript-eslint/rule-tester' @@ -9053,27 +8918,28 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@base-ui/react@1.4.0(@date-fns/tz@1.4.1)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@base-ui/utils': 0.2.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@date-fns/tz': 1.4.1 + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - tabbable: 6.4.0 - use-sync-external-store: 1.6.0(react@19.2.4) + date-fns: 4.1.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - '@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@base-ui/utils@0.2.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) reselect: 5.1.1 - use-sync-external-store: 1.6.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 @@ -9098,13 +8964,13 @@ snapshots: '@chevrotain/utils@11.1.2': {} - '@chromatic-com/storybook@5.1.1(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@chromatic-com/storybook@5.1.2(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: '@neoconfetti/react': 1.0.0 chromatic: 13.3.5 filesize: 10.1.6 jsonfile: 6.2.0 - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) strip-ansi: 7.2.0 transitivePeerDependencies: - '@chromatic-com/cypress' @@ -9184,18 +9050,18 @@ snapshots: dependencies: regexp-match-indices: 1.0.2 - '@cucumber/cucumber@12.7.0': + '@cucumber/cucumber@12.8.0': dependencies: '@cucumber/ci-environment': 13.0.0 '@cucumber/cucumber-expressions': 19.0.0 '@cucumber/gherkin': 38.0.0 - '@cucumber/gherkin-streams': 6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@32.0.1))(@cucumber/messages@32.0.1) + '@cucumber/gherkin-streams': 6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0) '@cucumber/gherkin-utils': 11.0.0 - '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.0.1) - '@cucumber/junit-xml-formatter': 0.9.0(@cucumber/messages@32.0.1) - '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.0.1) - '@cucumber/messages': 32.0.1 - '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.7.0)(@cucumber/messages@32.0.1) + '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.2.0) + '@cucumber/junit-xml-formatter': 0.13.2(@cucumber/messages@32.2.0) + '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) + '@cucumber/messages': 32.2.0 + '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.8.0)(@cucumber/messages@32.2.0) '@cucumber/tag-expressions': 9.1.0 assertion-error-formatter: 3.0.0 capital-case: 1.0.4 @@ -9214,7 +9080,6 @@ snapshots: lodash.merge: 4.6.2 lodash.mergewith: 4.6.2 luxon: 3.7.2 - mime: 3.0.0 mkdirp: 3.0.1 mz: 2.7.0 progress: 2.0.3 @@ -9227,70 +9092,73 @@ snapshots: yaml: 2.8.3 yup: 1.7.1 - '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@32.0.1))(@cucumber/messages@32.0.1)': + '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0)': dependencies: '@cucumber/gherkin': 38.0.0 - '@cucumber/message-streams': 4.0.1(@cucumber/messages@32.0.1) - '@cucumber/messages': 32.0.1 + '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) + '@cucumber/messages': 32.2.0 commander: 14.0.0 source-map-support: 0.5.21 '@cucumber/gherkin-utils@11.0.0': dependencies: '@cucumber/gherkin': 38.0.0 - '@cucumber/messages': 32.0.1 + '@cucumber/messages': 32.2.0 '@teppeis/multimaps': 3.0.0 commander: 14.0.2 source-map-support: 0.5.21 '@cucumber/gherkin@38.0.0': dependencies: - '@cucumber/messages': 32.0.1 + '@cucumber/messages': 32.2.0 - '@cucumber/html-formatter@23.0.0(@cucumber/messages@32.0.1)': + '@cucumber/html-formatter@23.0.0(@cucumber/messages@32.2.0)': dependencies: - '@cucumber/messages': 32.0.1 + '@cucumber/messages': 32.2.0 - '@cucumber/junit-xml-formatter@0.9.0(@cucumber/messages@32.0.1)': + '@cucumber/junit-xml-formatter@0.13.2(@cucumber/messages@32.2.0)': dependencies: - '@cucumber/messages': 32.0.1 - '@cucumber/query': 14.7.0(@cucumber/messages@32.0.1) + '@cucumber/messages': 32.2.0 + '@cucumber/query': 14.7.0(@cucumber/messages@32.2.0) '@teppeis/multimaps': 3.0.0 luxon: 3.7.2 xmlbuilder: 15.1.1 - '@cucumber/message-streams@4.0.1(@cucumber/messages@32.0.1)': + '@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0)': dependencies: - '@cucumber/messages': 32.0.1 + '@cucumber/messages': 32.2.0 + mime: 3.0.0 - '@cucumber/messages@32.0.1': + '@cucumber/messages@32.2.0': dependencies: class-transformer: 0.5.1 reflect-metadata: 0.2.2 - '@cucumber/pretty-formatter@1.0.1(@cucumber/cucumber@12.7.0)(@cucumber/messages@32.0.1)': + '@cucumber/pretty-formatter@1.0.1(@cucumber/cucumber@12.8.0)(@cucumber/messages@32.2.0)': dependencies: - '@cucumber/cucumber': 12.7.0 - '@cucumber/messages': 32.0.1 + '@cucumber/cucumber': 12.8.0 + '@cucumber/messages': 32.2.0 ansi-styles: 5.2.0 cli-table3: 0.6.5 figures: 3.2.0 ts-dedent: 2.2.0 - '@cucumber/query@14.7.0(@cucumber/messages@32.0.1)': + '@cucumber/query@14.7.0(@cucumber/messages@32.2.0)': dependencies: - '@cucumber/messages': 32.0.1 + '@cucumber/messages': 32.2.0 '@teppeis/multimaps': 3.0.0 lodash.sortby: 4.7.0 '@cucumber/tag-expressions@9.1.0': {} - '@e18e/eslint-plugin@0.3.0(eslint@10.1.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.18.1))': + '@date-fns/tz@1.4.1': {} + + '@e18e/eslint-plugin@0.3.0(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))': dependencies: - eslint-plugin-depend: 1.5.0(eslint@10.1.0(jiti@2.6.1)) + eslint-plugin-depend: 1.5.0(eslint@10.2.0(jiti@2.6.1)) optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) - oxlint: 1.58.0(oxlint-tsgolint@0.18.1) + eslint: 10.2.0(jiti@2.6.1) + oxlint: 1.58.0(oxlint-tsgolint@0.20.0) '@egoist/tailwindcss-icons@1.9.2(tailwindcss@4.2.2)': dependencies: @@ -9318,11 +9186,19 @@ snapshots: '@es-joy/jsdoccomment@0.84.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/types': 8.58.2 comment-parser: 1.4.5 esquery: 1.7.0 jsdoc-type-pratt-parser: 7.1.1 + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.8 + '@typescript-eslint/types': 8.58.2 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + '@es-joy/resolve.exports@1.2.0': {} '@esbuild/aix-ppc64@0.27.2': @@ -9403,15 +9279,15 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.2.0(jiti@2.6.1))': dependencies: escape-string-regexp: 4.0.0 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) ignore: 7.0.5 - '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.0(jiti@2.6.1))': dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/eslint-utils@4.9.1(eslint@9.27.0(jiti@2.6.1))': @@ -9421,77 +9297,77 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint-react/ast@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/ast@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) string-ts: 2.3.1 typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@eslint-react/core@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/core@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@eslint-react/eslint-plugin@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@eslint-react/shared': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) - eslint-plugin-react-dom: 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-react-naming-convention: 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-react-rsc: 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-react-web-api: 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-react-x: 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) + eslint-plugin-react-dom: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint-plugin-react-naming-convention: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint-plugin-react-rsc: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint-plugin-react-web-api: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint-plugin-react-x: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@eslint-react/shared@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/shared@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 6.0.2 zod: 4.3.6 transitivePeerDependencies: - supports-color - '@eslint-react/var@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/var@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@eslint/compat@2.0.3(eslint@10.1.0(jiti@2.6.1))': + '@eslint/compat@2.0.3(eslint@10.2.0(jiti@2.6.1))': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.0 optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) '@eslint/config-array@0.20.1': dependencies: @@ -9501,9 +9377,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-array@0.23.3': + '@eslint/config-array@0.23.4': dependencies: - '@eslint/object-schema': 3.0.3 + '@eslint/object-schema': 3.0.4 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 transitivePeerDependencies: @@ -9511,9 +9387,9 @@ snapshots: '@eslint/config-helpers@0.2.3': {} - '@eslint/config-helpers@0.5.3': + '@eslint/config-helpers@0.5.4': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.0 '@eslint/core@0.14.0': dependencies: @@ -9527,13 +9403,13 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/core@1.1.1': + '@eslint/core@1.2.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/css-tree@3.6.9': + '@eslint/css-tree@4.0.1': dependencies: - mdn-data: 2.23.0 + mdn-data: 2.27.1 source-map-js: 1.2.1 '@eslint/eslintrc@3.3.5': @@ -9550,9 +9426,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@10.2.0(jiti@2.6.1))': optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) '@eslint/js@9.27.0': {} @@ -9572,7 +9448,7 @@ snapshots: '@eslint/markdown@8.0.1': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.0 '@eslint/plugin-kit': 0.6.1 github-slugger: 2.0.0 mdast-util-from-markdown: 2.0.3 @@ -9588,7 +9464,7 @@ snapshots: '@eslint/object-schema@2.1.7': {} - '@eslint/object-schema@3.0.3': {} + '@eslint/object-schema@3.0.4': {} '@eslint/plugin-kit@0.3.5': dependencies: @@ -9602,7 +9478,12 @@ snapshots: '@eslint/plugin-kit@0.6.1': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.0 + levn: 0.4.1 + + '@eslint/plugin-kit@0.7.0': + dependencies: + '@eslint/core': 1.2.0 levn: 0.4.1 '@floating-ui/core@1.7.5': @@ -9614,53 +9495,53 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@floating-ui/dom': 1.7.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@floating-ui/react@0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react@0.26.28(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) tabbable: 6.4.0 - '@floating-ui/react@0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react@0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) tabbable: 6.4.0 '@floating-ui/utils@0.2.11': {} - '@formatjs/fast-memoize@3.1.1': {} + '@formatjs/fast-memoize@3.1.2': {} - '@formatjs/intl-localematcher@0.8.2': + '@formatjs/intl-localematcher@0.8.3': dependencies: - '@formatjs/fast-memoize': 3.1.1 + '@formatjs/fast-memoize': 3.1.2 - '@headlessui/react@2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@headlessui/react@2.2.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@floating-ui/react': 0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/react-virtual': 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - use-sync-external-store: 1.6.0(react@19.2.4) + '@floating-ui/react': 0.26.28(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-aria/focus': 3.21.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-aria/interactions': 3.27.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-virtual': 3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) - '@heroicons/react@2.2.0(react@19.2.4)': + '@heroicons/react@2.2.0(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 - '@hono/node-server@1.19.12(hono@4.12.10)': + '@hono/node-server@1.19.14(hono@4.12.12)': dependencies: - hono: 4.12.10 + hono: 4.12.12 '@humanfs/core@0.19.1': {} @@ -9816,11 +9697,11 @@ snapshots: dependencies: minipass: 7.1.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.2) - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' optionalDependencies: typescript: 6.0.2 @@ -9848,169 +9729,169 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lexical/clipboard@0.42.0': + '@lexical/clipboard@0.43.0': dependencies: - '@lexical/html': 0.42.0 - '@lexical/list': 0.42.0 - '@lexical/selection': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/html': 0.43.0 + '@lexical/list': 0.43.0 + '@lexical/selection': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/code-core@0.42.0': + '@lexical/code-core@0.43.0': dependencies: - lexical: 0.42.0 + lexical: 0.43.0 - '@lexical/devtools-core@0.42.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@lexical/devtools-core@0.43.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@lexical/html': 0.42.0 - '@lexical/link': 0.42.0 - '@lexical/mark': 0.42.0 - '@lexical/table': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@lexical/html': 0.43.0 + '@lexical/link': 0.43.0 + '@lexical/mark': 0.43.0 + '@lexical/table': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@lexical/dragon@0.42.0': + '@lexical/dragon@0.43.0': dependencies: - '@lexical/extension': 0.42.0 - lexical: 0.42.0 + '@lexical/extension': 0.43.0 + lexical: 0.43.0 - '@lexical/extension@0.42.0': + '@lexical/extension@0.43.0': dependencies: - '@lexical/utils': 0.42.0 - '@preact/signals-core': 1.14.0 - lexical: 0.42.0 + '@lexical/utils': 0.43.0 + '@preact/signals-core': 1.14.1 + lexical: 0.43.0 - '@lexical/hashtag@0.42.0': + '@lexical/hashtag@0.43.0': dependencies: - '@lexical/text': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/text': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/history@0.42.0': + '@lexical/history@0.43.0': dependencies: - '@lexical/extension': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/extension': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/html@0.42.0': + '@lexical/html@0.43.0': dependencies: - '@lexical/selection': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/selection': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/link@0.42.0': + '@lexical/link@0.43.0': dependencies: - '@lexical/extension': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/extension': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/list@0.42.0': + '@lexical/list@0.43.0': dependencies: - '@lexical/extension': 0.42.0 - '@lexical/selection': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/extension': 0.43.0 + '@lexical/selection': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/mark@0.42.0': + '@lexical/mark@0.43.0': dependencies: - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/markdown@0.42.0': + '@lexical/markdown@0.43.0': dependencies: - '@lexical/code-core': 0.42.0 - '@lexical/link': 0.42.0 - '@lexical/list': 0.42.0 - '@lexical/rich-text': 0.42.0 - '@lexical/text': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/code-core': 0.43.0 + '@lexical/link': 0.43.0 + '@lexical/list': 0.43.0 + '@lexical/rich-text': 0.43.0 + '@lexical/text': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/offset@0.42.0': + '@lexical/offset@0.43.0': dependencies: - lexical: 0.42.0 + lexical: 0.43.0 - '@lexical/overflow@0.42.0': + '@lexical/overflow@0.43.0': dependencies: - lexical: 0.42.0 + lexical: 0.43.0 - '@lexical/plain-text@0.42.0': + '@lexical/plain-text@0.43.0': dependencies: - '@lexical/clipboard': 0.42.0 - '@lexical/dragon': 0.42.0 - '@lexical/selection': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/clipboard': 0.43.0 + '@lexical/dragon': 0.43.0 + '@lexical/selection': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/react@0.42.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.30)': + '@lexical/react@0.43.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yjs@13.6.30)': dependencies: - '@floating-ui/react': 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@lexical/devtools-core': 0.42.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@lexical/dragon': 0.42.0 - '@lexical/extension': 0.42.0 - '@lexical/hashtag': 0.42.0 - '@lexical/history': 0.42.0 - '@lexical/link': 0.42.0 - '@lexical/list': 0.42.0 - '@lexical/mark': 0.42.0 - '@lexical/markdown': 0.42.0 - '@lexical/overflow': 0.42.0 - '@lexical/plain-text': 0.42.0 - '@lexical/rich-text': 0.42.0 - '@lexical/table': 0.42.0 - '@lexical/text': 0.42.0 - '@lexical/utils': 0.42.0 - '@lexical/yjs': 0.42.0(yjs@13.6.30) - lexical: 0.42.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-error-boundary: 6.1.1(react@19.2.4) + '@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@lexical/devtools-core': 0.43.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@lexical/dragon': 0.43.0 + '@lexical/extension': 0.43.0 + '@lexical/hashtag': 0.43.0 + '@lexical/history': 0.43.0 + '@lexical/link': 0.43.0 + '@lexical/list': 0.43.0 + '@lexical/mark': 0.43.0 + '@lexical/markdown': 0.43.0 + '@lexical/overflow': 0.43.0 + '@lexical/plain-text': 0.43.0 + '@lexical/rich-text': 0.43.0 + '@lexical/table': 0.43.0 + '@lexical/text': 0.43.0 + '@lexical/utils': 0.43.0 + '@lexical/yjs': 0.43.0(yjs@13.6.30) + lexical: 0.43.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-error-boundary: 6.1.1(react@19.2.5) transitivePeerDependencies: - yjs - '@lexical/rich-text@0.42.0': + '@lexical/rich-text@0.43.0': dependencies: - '@lexical/clipboard': 0.42.0 - '@lexical/dragon': 0.42.0 - '@lexical/selection': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/clipboard': 0.43.0 + '@lexical/dragon': 0.43.0 + '@lexical/selection': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/selection@0.42.0': + '@lexical/selection@0.43.0': dependencies: - lexical: 0.42.0 + lexical: 0.43.0 - '@lexical/table@0.42.0': + '@lexical/table@0.43.0': dependencies: - '@lexical/clipboard': 0.42.0 - '@lexical/extension': 0.42.0 - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/clipboard': 0.43.0 + '@lexical/extension': 0.43.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - '@lexical/text@0.42.0': + '@lexical/text@0.43.0': dependencies: - lexical: 0.42.0 + lexical: 0.43.0 - '@lexical/utils@0.42.0': + '@lexical/utils@0.43.0': dependencies: - '@lexical/selection': 0.42.0 - lexical: 0.42.0 + '@lexical/selection': 0.43.0 + lexical: 0.43.0 - '@lexical/yjs@0.42.0(yjs@13.6.30)': + '@lexical/yjs@0.43.0(yjs@13.6.30)': dependencies: - '@lexical/offset': 0.42.0 - '@lexical/selection': 0.42.0 - lexical: 0.42.0 + '@lexical/offset': 0.43.0 + '@lexical/selection': 0.43.0 + lexical: 0.43.0 yjs: 13.6.30 - '@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3))': dependencies: '@mdx-js/mdx': 3.1.1 source-map: 0.7.6 optionalDependencies: - webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) + webpack: 5.105.4(uglify-js@3.19.3) transitivePeerDependencies: - supports-color @@ -10044,11 +9925,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)': + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@types/mdx': 2.0.13 '@types/react': 19.2.14 - react: 19.2.4 + react: 19.2.5 '@mdx-js/rollup@3.1.1(rollup@4.59.0)': dependencies: @@ -10068,12 +9949,12 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@monaco-editor/loader': 1.7.0 monaco-editor: 0.55.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: @@ -10086,41 +9967,41 @@ snapshots: '@next/env@16.0.0': {} - '@next/env@16.2.2': {} + '@next/env@16.2.3': {} - '@next/eslint-plugin-next@16.2.2': + '@next/eslint-plugin-next@16.2.3': dependencies: fast-glob: 3.3.1 - '@next/mdx@16.2.2(@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4))': + '@next/mdx@16.2.3(@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5))': dependencies: source-map: 0.7.6 optionalDependencies: - '@mdx-js/loader': 3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) + '@mdx-js/loader': 3.1.1(webpack@5.105.4(uglify-js@3.19.3)) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) - '@next/swc-darwin-arm64@16.2.2': + '@next/swc-darwin-arm64@16.2.3': optional: true - '@next/swc-darwin-x64@16.2.2': + '@next/swc-darwin-x64@16.2.3': optional: true - '@next/swc-linux-arm64-gnu@16.2.2': + '@next/swc-linux-arm64-gnu@16.2.3': optional: true - '@next/swc-linux-arm64-musl@16.2.2': + '@next/swc-linux-arm64-musl@16.2.3': optional: true - '@next/swc-linux-x64-gnu@16.2.2': + '@next/swc-linux-x64-gnu@16.2.3': optional: true - '@next/swc-linux-x64-musl@16.2.2': + '@next/swc-linux-x64-musl@16.2.3': optional: true - '@next/swc-win32-arm64-msvc@16.2.2': + '@next/swc-win32-arm64-msvc@16.2.3': optional: true - '@next/swc-win32-x64-msvc@16.2.2': + '@next/swc-win32-x64-msvc@16.2.3': optional: true '@nodelib/fs.scandir@2.1.5': @@ -10141,63 +10022,63 @@ snapshots: '@nolyfill/side-channel@1.0.44': {} - '@orpc/client@1.13.13': + '@orpc/client@1.13.14': dependencies: - '@orpc/shared': 1.13.13 - '@orpc/standard-server': 1.13.13 - '@orpc/standard-server-fetch': 1.13.13 - '@orpc/standard-server-peer': 1.13.13 + '@orpc/shared': 1.13.14 + '@orpc/standard-server': 1.13.14 + '@orpc/standard-server-fetch': 1.13.14 + '@orpc/standard-server-peer': 1.13.14 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/contract@1.13.13': + '@orpc/contract@1.13.14': dependencies: - '@orpc/client': 1.13.13 - '@orpc/shared': 1.13.13 + '@orpc/client': 1.13.14 + '@orpc/shared': 1.13.14 '@standard-schema/spec': 1.1.0 openapi-types: 12.1.3 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/openapi-client@1.13.13': + '@orpc/openapi-client@1.13.14': dependencies: - '@orpc/client': 1.13.13 - '@orpc/contract': 1.13.13 - '@orpc/shared': 1.13.13 - '@orpc/standard-server': 1.13.13 + '@orpc/client': 1.13.14 + '@orpc/contract': 1.13.14 + '@orpc/shared': 1.13.14 + '@orpc/standard-server': 1.13.14 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/shared@1.13.13': + '@orpc/shared@1.13.14': dependencies: radash: 12.1.1 type-fest: 5.5.0 - '@orpc/standard-server-fetch@1.13.13': + '@orpc/standard-server-fetch@1.13.14': dependencies: - '@orpc/shared': 1.13.13 - '@orpc/standard-server': 1.13.13 + '@orpc/shared': 1.13.14 + '@orpc/standard-server': 1.13.14 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/standard-server-peer@1.13.13': + '@orpc/standard-server-peer@1.13.14': dependencies: - '@orpc/shared': 1.13.13 - '@orpc/standard-server': 1.13.13 + '@orpc/shared': 1.13.14 + '@orpc/standard-server': 1.13.14 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/standard-server@1.13.13': + '@orpc/standard-server@1.13.14': dependencies: - '@orpc/shared': 1.13.13 + '@orpc/shared': 1.13.14 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/tanstack-query@1.13.13(@orpc/client@1.13.13)(@tanstack/query-core@5.96.1)': + '@orpc/tanstack-query@1.13.14(@orpc/client@1.13.14)(@tanstack/query-core@5.99.0)': dependencies: - '@orpc/client': 1.13.13 - '@orpc/shared': 1.13.13 - '@tanstack/query-core': 5.96.1 + '@orpc/client': 1.13.14 + '@orpc/shared': 1.13.14 + '@tanstack/query-core': 5.99.0 transitivePeerDependencies: - '@opentelemetry/api' @@ -10268,11 +10149,11 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.121.0': optional: true - '@oxc-project/runtime@0.122.0': {} + '@oxc-project/runtime@0.123.0': {} '@oxc-project/types@0.121.0': {} - '@oxc-project/types@0.122.0': {} + '@oxc-project/types@0.123.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -10396,22 +10277,22 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.43.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.18.1': + '@oxlint-tsgolint/darwin-arm64@0.20.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.18.1': + '@oxlint-tsgolint/darwin-x64@0.20.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.18.1': + '@oxlint-tsgolint/linux-arm64@0.20.0': optional: true - '@oxlint-tsgolint/linux-x64@0.18.1': + '@oxlint-tsgolint/linux-x64@0.20.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.18.1': + '@oxlint-tsgolint/win32-arm64@0.20.0': optional: true - '@oxlint-tsgolint/win32-x64@0.18.1': + '@oxlint-tsgolint/win32-x64@0.20.0': optional: true '@oxlint/binding-android-arm-eabi@1.58.0': @@ -10540,239 +10421,239 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@preact/signals-core@1.14.0': {} + '@preact/signals-core@1.14.1': {} '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@react-aria/focus@3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/focus@3.21.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.1(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-aria/utils': 3.33.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-types/shared': 3.33.1(react@19.2.5) '@swc/helpers': 0.5.20 clsx: 2.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@react-aria/interactions@3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/interactions@3.27.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.4) - '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/ssr': 3.9.10(react@19.2.5) + '@react-aria/utils': 3.33.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.33.1(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.5) '@swc/helpers': 0.5.20 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@react-aria/ssr@3.9.10(react@19.2.4)': + '@react-aria/ssr@3.9.10(react@19.2.5)': dependencies: '@swc/helpers': 0.5.20 - react: 19.2.4 + react: 19.2.5 - '@react-aria/utils@3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@react-aria/utils@3.33.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-aria/ssr': 3.9.10(react@19.2.5) '@react-stately/flags': 3.1.2 - '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.1(react@19.2.4) + '@react-stately/utils': 3.11.0(react@19.2.5) + '@react-types/shared': 3.33.1(react@19.2.5) '@swc/helpers': 0.5.20 clsx: 2.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) '@react-stately/flags@3.1.2': dependencies: '@swc/helpers': 0.5.20 - '@react-stately/utils@3.11.0(react@19.2.4)': + '@react-stately/utils@3.11.0(react@19.2.5)': dependencies: '@swc/helpers': 0.5.20 - react: 19.2.4 + react: 19.2.5 - '@react-types/shared@3.33.1(react@19.2.4)': + '@react-types/shared@3.33.1(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 - '@reactflow/background@11.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/background@11.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) classcat: 5.0.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/controls@11.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/controls@11.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) classcat: 5.0.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/core@11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/core@11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@types/d3': 7.4.3 '@types/d3-drag': 3.0.7 @@ -10782,113 +10663,61 @@ snapshots: d3-drag: 3.0.0 d3-selection: 3.0.0 d3-zoom: 3.0.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/minimap@11.7.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/minimap@11.7.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@types/d3-selection': 3.0.11 '@types/d3-zoom': 3.0.8 classcat: 5.0.5 d3-selection: 3.0.0 d3-zoom: 3.0.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/node-resizer@2.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/node-resizer@2.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) classcat: 5.0.5 d3-drag: 3.0.0 d3-selection: 3.0.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@reactflow/node-toolbar@1.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@reactflow/node-toolbar@1.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) classcat: 5.0.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer - '@remixicon/react@4.9.0(react@19.2.4)': + '@remixicon/react@4.9.0(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 '@resvg/resvg-wasm@2.4.0': {} '@rgrove/parse-xml@4.2.0': {} - '@rolldown/binding-android-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': - dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - optional: true - - '@rolldown/pluginutils@1.0.0-rc.12': {} - - '@rolldown/pluginutils@1.0.0-rc.5': {} + '@rolldown/pluginutils@1.0.0-rc.15': {} '@rolldown/pluginutils@1.0.0-rc.7': {} @@ -10982,39 +10811,39 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true - '@sentry-internal/browser-utils@10.47.0': + '@sentry-internal/browser-utils@10.48.0': dependencies: - '@sentry/core': 10.47.0 + '@sentry/core': 10.48.0 - '@sentry-internal/feedback@10.47.0': + '@sentry-internal/feedback@10.48.0': dependencies: - '@sentry/core': 10.47.0 + '@sentry/core': 10.48.0 - '@sentry-internal/replay-canvas@10.47.0': + '@sentry-internal/replay-canvas@10.48.0': dependencies: - '@sentry-internal/replay': 10.47.0 - '@sentry/core': 10.47.0 + '@sentry-internal/replay': 10.48.0 + '@sentry/core': 10.48.0 - '@sentry-internal/replay@10.47.0': + '@sentry-internal/replay@10.48.0': dependencies: - '@sentry-internal/browser-utils': 10.47.0 - '@sentry/core': 10.47.0 + '@sentry-internal/browser-utils': 10.48.0 + '@sentry/core': 10.48.0 - '@sentry/browser@10.47.0': + '@sentry/browser@10.48.0': dependencies: - '@sentry-internal/browser-utils': 10.47.0 - '@sentry-internal/feedback': 10.47.0 - '@sentry-internal/replay': 10.47.0 - '@sentry-internal/replay-canvas': 10.47.0 - '@sentry/core': 10.47.0 + '@sentry-internal/browser-utils': 10.48.0 + '@sentry-internal/feedback': 10.48.0 + '@sentry-internal/replay': 10.48.0 + '@sentry-internal/replay-canvas': 10.48.0 + '@sentry/core': 10.48.0 - '@sentry/core@10.47.0': {} + '@sentry/core@10.48.0': {} - '@sentry/react@10.47.0(react@19.2.4)': + '@sentry/react@10.48.0(react@19.2.5)': dependencies: - '@sentry/browser': 10.47.0 - '@sentry/core': 10.47.0 - react: 19.2.4 + '@sentry/browser': 10.48.0 + '@sentry/core': 10.48.0 + react: 19.2.5 '@shikijs/core@4.0.2': dependencies: @@ -11101,15 +10930,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.3.4(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/csf-plugin': 10.3.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-dom-shim': 10.3.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -11118,62 +10947,61 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.3.4(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-links@10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) optionalDependencies: - react: 19.2.4 + react: 19.2.5 - '@storybook/addon-onboarding@10.3.4(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-onboarding@10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/addon-themes@10.3.4(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-themes@10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.3.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/builder-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - '@storybook/csf-plugin': 10.3.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.27.2 rollup: 4.59.0 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + webpack: 5.105.4(uglify-js@3.19.3) '@storybook/global@5.0.0': {} - '@storybook/icons@2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@storybook/icons@2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@storybook/nextjs-vite@10.3.4(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/nextjs-vite@10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - '@storybook/builder-vite': 10.3.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - '@storybook/react': 10.3.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) - '@storybook/react-vite': 10.3.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - next: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + '@storybook/react-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3)) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: @@ -11184,27 +11012,27 @@ snapshots: - supports-color - webpack - '@storybook/react-dom-shim@10.3.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/react-dom-shim@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/react-vite@10.3.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/react-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.3.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - '@storybook/react': 10.3.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) empathic: 2.0.0 magic-string: 0.30.21 - react: 19.2.4 + react: 19.2.5 react-docgen: 8.0.3 - react-dom: 19.2.4(react@19.2.4) + react-dom: 19.2.5(react@19.2.5) resolve: 1.22.11 - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tsconfig-paths: 4.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - esbuild - rollup @@ -11212,34 +11040,34 @@ snapshots: - typescript - webpack - '@storybook/react@10.3.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)': + '@storybook/react@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.3.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) - react: 19.2.4 + '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + react: 19.2.5 react-docgen: 8.0.3 react-docgen-typescript: 2.4.0(typescript@6.0.2) - react-dom: 19.2.4(react@19.2.4) - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-dom: 19.2.5(react@19.2.5) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@streamdown/math@1.0.2(react@19.2.4)': + '@streamdown/math@1.0.2(react@19.2.5)': dependencies: - katex: 0.16.44 - react: 19.2.4 + katex: 0.16.45 + react: 19.2.5 rehype-katex: 7.0.1 remark-math: 6.0.0 transitivePeerDependencies: - supports-color - '@stylistic/eslint-plugin@5.10.0(eslint@10.1.0(jiti@2.6.1))': + '@stylistic/eslint-plugin@5.10.0(eslint@10.2.0(jiti@2.6.1))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/types': 8.58.0 - eslint: 10.1.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@typescript-eslint/types': 8.58.2 + eslint: 10.2.0(jiti@2.6.1) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 @@ -11335,7 +11163,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 - postcss: 8.5.8 + postcss: 8.5.9 tailwindcss: 4.2.2 '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': @@ -11343,12 +11171,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@tailwindcss/vite@4.2.2(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.2(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' '@tanstack/devtools-client@0.0.6': dependencies: @@ -11372,13 +11200,13 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-utils@0.4.0(@types/react@19.2.14)(react@19.2.4)(solid-js@1.9.11)': + '@tanstack/devtools-utils@0.4.0(@types/react@19.2.14)(react@19.2.5)(solid-js@1.9.11)': optionalDependencies: '@types/react': 19.2.14 - react: 19.2.4 + react: 19.2.5 solid-js: 1.9.11 - '@tanstack/devtools@0.11.1(csstype@3.2.3)(solid-js@1.9.11)': + '@tanstack/devtools@0.11.2(csstype@3.2.3)(solid-js@1.9.11)': dependencies: '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.11) '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.11) @@ -11394,26 +11222,26 @@ snapshots: - csstype - utf-8-validate - '@tanstack/eslint-plugin-query@5.96.1(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@tanstack/eslint-plugin-query@5.99.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@tanstack/form-core@1.28.6': + '@tanstack/form-core@1.29.0': dependencies: '@tanstack/devtools-event-client': 0.4.3 '@tanstack/pacer-lite': 0.1.1 '@tanstack/store': 0.9.3 - '@tanstack/form-devtools@0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11)': + '@tanstack/form-devtools@0.2.21(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11)': dependencies: '@tanstack/devtools-ui': 0.5.1(csstype@3.2.3)(solid-js@1.9.11) - '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.4)(solid-js@1.9.11) - '@tanstack/form-core': 1.28.6 + '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.5)(solid-js@1.9.11) + '@tanstack/form-core': 1.29.0 clsx: 2.1.1 dayjs: 1.11.20 goober: 2.1.18(csstype@3.2.3) @@ -11427,28 +11255,28 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} - '@tanstack/query-core@5.96.1': {} + '@tanstack/query-core@5.99.0': {} - '@tanstack/query-devtools@5.96.1': {} + '@tanstack/query-devtools@5.99.0': {} - '@tanstack/react-devtools@0.10.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)': + '@tanstack/react-devtools@0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.11)': dependencies: - '@tanstack/devtools': 0.11.1(csstype@3.2.3)(solid-js@1.9.11) + '@tanstack/devtools': 0.11.2(csstype@3.2.3)(solid-js@1.9.11) '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: - bufferutil - csstype - solid-js - utf-8-validate - '@tanstack/react-form-devtools@0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11)': + '@tanstack/react-form-devtools@0.2.21(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11)': dependencies: - '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.4)(solid-js@1.9.11) - '@tanstack/form-devtools': 0.2.20(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11) - react: 19.2.4 + '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.5)(solid-js@1.9.11) + '@tanstack/form-devtools': 0.2.21(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) + react: 19.2.5 transitivePeerDependencies: - '@types/react' - csstype @@ -11456,37 +11284,37 @@ snapshots: - solid-js - vue - '@tanstack/react-form@1.28.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-form@1.29.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/form-core': 1.28.6 - '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 + '@tanstack/form-core': 1.29.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 transitivePeerDependencies: - react-dom - '@tanstack/react-query-devtools@5.96.1(@tanstack/react-query@5.96.1(react@19.2.4))(react@19.2.4)': + '@tanstack/react-query-devtools@5.99.0(@tanstack/react-query@5.99.0(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/query-devtools': 5.96.1 - '@tanstack/react-query': 5.96.1(react@19.2.4) - react: 19.2.4 + '@tanstack/query-devtools': 5.99.0 + '@tanstack/react-query': 5.99.0(react@19.2.5) + react: 19.2.5 - '@tanstack/react-query@5.96.1(react@19.2.4)': + '@tanstack/react-query@5.99.0(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.96.1 - react: 19.2.4 + '@tanstack/query-core': 5.99.0 + react: 19.2.5 - '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/store': 0.9.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - use-sync-external-store: 1.6.0(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) - '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-virtual@3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/virtual-core': 3.13.23 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) '@tanstack/store@0.9.3': {} @@ -11514,12 +11342,12 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -11528,11 +11356,11 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tsslint/cli@3.0.2(@tsslint/compat-eslint@3.0.2(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2)': + '@tsslint/cli@3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2)': dependencies: '@clack/prompts': 0.8.2 - '@tsslint/config': 3.0.2(@tsslint/compat-eslint@3.0.2(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) - '@tsslint/core': 3.0.2 + '@tsslint/config': 3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) + '@tsslint/core': 3.0.3 '@volar/language-core': 2.4.28 '@volar/language-hub': 0.0.1 '@volar/typescript': 2.4.28 @@ -11542,32 +11370,32 @@ snapshots: - '@tsslint/compat-eslint' - tsl - '@tsslint/compat-eslint@3.0.2(jiti@2.6.1)(typescript@6.0.2)': + '@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2)': dependencies: - '@tsslint/types': 3.0.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.27.0(jiti@2.6.1))(typescript@6.0.2) + '@tsslint/types': 3.0.3 + '@typescript-eslint/parser': 8.58.2(eslint@9.27.0(jiti@2.6.1))(typescript@6.0.2) eslint: 9.27.0(jiti@2.6.1) transitivePeerDependencies: - jiti - supports-color - typescript - '@tsslint/config@3.0.2(@tsslint/compat-eslint@3.0.2(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2)': + '@tsslint/config@3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2)': dependencies: - '@tsslint/types': 3.0.2 + '@tsslint/types': 3.0.3 minimatch: 10.2.4 ts-api-utils: 2.5.0(typescript@6.0.2) optionalDependencies: - '@tsslint/compat-eslint': 3.0.2(jiti@2.6.1)(typescript@6.0.2) + '@tsslint/compat-eslint': 3.0.3(jiti@2.6.1)(typescript@6.0.2) transitivePeerDependencies: - typescript - '@tsslint/core@3.0.2': + '@tsslint/core@3.0.3': dependencies: - '@tsslint/types': 3.0.2 + '@tsslint/types': 3.0.3 minimatch: 10.2.4 - '@tsslint/types@3.0.2': {} + '@tsslint/types@3.0.3': {} '@tybys/wasm-util@0.10.1': dependencies: @@ -11771,15 +11599,15 @@ snapshots: '@types/negotiator@0.6.4': {} - '@types/node@25.5.0': + '@types/node@25.6.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.19.2 '@types/normalize-package-data@2.4.4': {} '@types/papaparse@5.5.2': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/qs@6.15.0': {} @@ -11806,24 +11634,24 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 optional: true '@types/zen-observable@0.8.3': {} - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.58.2 + eslint: 10.2.0(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.2) @@ -11831,36 +11659,36 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/parser@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.27.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/parser@8.58.2(eslint@9.27.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3(supports-color@8.1.1) eslint: 9.27.0(jiti@2.6.1) typescript: 6.0.2 @@ -11869,29 +11697,29 @@ snapshots: '@typescript-eslint/project-service@8.57.2(typescript@6.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.2) + '@typescript-eslint/types': 8.58.2 debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@6.0.2)': + '@typescript-eslint/project-service@8.58.2(typescript@6.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.2) + '@typescript-eslint/types': 8.58.2 debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/rule-tester@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/parser': 8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.2) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) ajv: 6.14.0 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 semver: 7.7.4 @@ -11904,26 +11732,26 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/visitor-keys': 8.57.2 - '@typescript-eslint/scope-manager@8.58.0': + '@typescript-eslint/scope-manager@8.58.2': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 '@typescript-eslint/tsconfig-utils@8.57.2(typescript@6.0.2)': dependencies: typescript: 6.0.2 - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.58.2(typescript@6.0.2)': dependencies: typescript: 6.0.2 - '@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: @@ -11931,7 +11759,7 @@ snapshots: '@typescript-eslint/types@8.57.2': {} - '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/types@8.58.2': {} '@typescript-eslint/typescript-estree@8.57.2(typescript@6.0.2)': dependencies: @@ -11948,12 +11776,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2)': + '@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2)': dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/project-service': 8.58.2(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.2) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 semver: 7.7.4 @@ -11963,24 +11791,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/utils@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -11990,41 +11818,41 @@ snapshots: '@typescript-eslint/types': 8.57.2 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.58.0': + '@typescript-eslint/visitor-keys@8.58.2': dependencies: - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/types': 8.58.2 eslint-visitor-keys: 5.0.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260401.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260413.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260401.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260413.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260401.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260413.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260401.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260413.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260401.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260413.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260401.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260413.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260401.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260413.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260401.1': + '@typescript/native-preview@7.0.0-dev.20260413.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260401.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260401.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260401.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260401.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260401.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260401.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260401.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260413.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260413.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260413.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260413.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260413.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260413.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260413.1 '@ungap/structured-clone@1.3.0': {} @@ -12032,13 +11860,13 @@ snapshots: dependencies: unpic: 4.2.2 - '@unpic/react@1.0.2(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@unpic/react@1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@unpic/core': 1.0.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: - next: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) '@upsetjs/venn.js@2.0.0': optionalDependencies: @@ -12054,12 +11882,12 @@ snapshots: '@resvg/resvg-wasm': 2.4.0 satori: 0.16.0 - '@vitejs/devtools-kit@0.1.11(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0)': + '@vitejs/devtools-kit@0.1.11(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0)': dependencies: '@vitejs/devtools-rpc': 0.1.11(typescript@6.0.2)(ws@8.20.0) birpc: 4.0.0 ohash: 2.0.11 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - typescript - ws @@ -12076,32 +11904,31 @@ snapshots: transitivePeerDependencies: - typescript - '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - '@vitejs/plugin-rsc@0.5.21(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)': + '@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)': dependencies: - '@rolldown/pluginutils': 1.0.0-rc.5 + '@rolldown/pluginutils': 1.0.0-rc.15 es-module-lexer: 2.0.0 estree-walker: 3.0.3 magic-string: 0.30.21 - periscopic: 4.0.2 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - srvx: 0.11.13 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + srvx: 0.11.15 strip-literal: 3.1.0 turbo-stream: 3.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vitefu: 1.1.2(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) optionalDependencies: - react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) - '@vitest/coverage-v8@4.1.2(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@vitest/coverage-v8@4.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.2 + '@vitest/utils': 4.1.4 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -12110,31 +11937,17 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - '@vitest/coverage-v8@4.1.2(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))': + '@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.2 - ast-v8-to-istanbul: 1.0.0 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - std-env: 4.0.0 - tinyrainbow: 3.1.0 - vitest: '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' - - '@vitest/eslint-plugin@1.6.14(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': - dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) typescript: 6.0.2 - vitest: '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - supports-color @@ -12150,7 +11963,7 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.2': + '@vitest/pretty-format@4.1.4': dependencies: tinyrainbow: 3.1.0 @@ -12164,21 +11977,20 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.2': + '@vitest/utils@4.1.4': dependencies: - '@vitest/pretty-format': 4.1.2 + '@vitest/pretty-format': 4.1.4 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': dependencies: - '@oxc-project/runtime': 0.122.0 - '@oxc-project/types': 0.122.0 + '@oxc-project/runtime': 0.123.0 + '@oxc-project/types': 0.123.0 lightningcss: 1.32.0 - postcss: 8.5.8 + postcss: 8.5.9 optionalDependencies: - '@types/node': 25.5.0 - esbuild: 0.27.2 + '@types/node': 25.6.0 fsevents: 2.3.3 jiti: 2.6.1 sass: 1.98.0 @@ -12187,29 +11999,29 @@ snapshots: typescript: 6.0.2 yaml: 2.8.3 - '@voidzero-dev/vite-plus-darwin-arm64@0.1.15': + '@voidzero-dev/vite-plus-darwin-arm64@0.1.16': optional: true - '@voidzero-dev/vite-plus-darwin-x64@0.1.15': + '@voidzero-dev/vite-plus-darwin-x64@0.1.16': optional: true - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.15': + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.16': optional: true - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.15': + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.16': optional: true - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.15': + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.16': optional: true - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.15': + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.16': optional: true - '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + '@voidzero-dev/vite-plus-core': 0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) es-module-lexer: 1.7.0 obug: 2.1.1 pixelmatch: 7.1.0 @@ -12219,11 +12031,11 @@ snapshots: tinybench: 2.9.0 tinyexec: 1.0.4 tinyglobby: 0.2.15 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' ws: 8.20.0 optionalDependencies: - '@types/node': 25.5.0 - happy-dom: 20.8.9 + '@types/node': 25.6.0 + happy-dom: 20.9.0 transitivePeerDependencies: - '@arethetypeswrong/core' - '@tsdown/css' @@ -12245,50 +12057,10 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - es-module-lexer: 1.7.0 - obug: 2.1.1 - pixelmatch: 7.1.0 - pngjs: 7.0.0 - sirv: 3.0.2 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - ws: 8.20.0 - optionalDependencies: - '@types/node': 25.5.0 - happy-dom: 20.8.9 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@tsdown/css' - - '@tsdown/exe' - - '@vitejs/devtools' - - bufferutil - - esbuild - - jiti - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - utf-8-validate - - yaml - - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.15': + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.16': optional: true - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.15': + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.16': optional: true '@volar/language-core@2.4.28': @@ -12327,7 +12099,7 @@ snapshots: '@vue/shared': 3.5.31 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.8 + postcss: 8.5.9 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.31': @@ -12437,12 +12209,12 @@ snapshots: acorn@8.16.0: {} - agentation@3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + agentation@3.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - ahooks@3.9.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + ahooks@3.9.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@babel/runtime': 7.29.2 '@types/js-cookie': 3.0.6 @@ -12450,8 +12222,8 @@ snapshots: intersection-observer: 0.12.2 js-cookie: 3.0.5 lodash: 4.18.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) react-fast-compare: 3.2.2 resize-observer-polyfill: 1.5.1 screenfull: 5.2.0 @@ -12730,14 +12502,14 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -13046,6 +12818,8 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.0 + date-fns@4.1.0: {} + dayjs@1.11.20: {} debug@4.4.3(supports-color@8.1.1): @@ -13135,11 +12909,11 @@ snapshots: dotenv@16.6.1: {} - echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.4): + echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.5): dependencies: echarts: 6.0.0 fast-deep-equal: 3.1.3 - react: 19.2.4 + react: 19.2.5 size-sensor: 1.0.3 echarts@6.0.0: @@ -13155,11 +12929,11 @@ snapshots: dependencies: embla-carousel: 8.6.0 - embla-carousel-react@8.6.0(react@19.2.4): + embla-carousel-react@8.6.0(react@19.2.5): dependencies: embla-carousel: 8.6.0 embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) - react: 19.2.4 + react: 19.2.5 embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): dependencies: @@ -13258,102 +13032,102 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.1(eslint@10.1.0(jiti@2.6.1)): + eslint-compat-utils@0.5.1(eslint@10.2.0(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) semver: 7.7.4 - eslint-config-flat-gitignore@2.3.0(eslint@10.1.0(jiti@2.6.1)): + eslint-config-flat-gitignore@2.3.0(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@eslint/compat': 2.0.3(eslint@10.1.0(jiti@2.6.1)) - eslint: 10.1.0(jiti@2.6.1) + '@eslint/compat': 2.0.3(eslint@10.2.0(jiti@2.6.1)) + eslint: 10.2.0(jiti@2.6.1) eslint-flat-config-utils@3.1.0: dependencies: - '@eslint/config-helpers': 0.5.3 + '@eslint/config-helpers': 0.5.4 pathe: 2.0.3 - eslint-json-compat-utils@0.2.3(eslint@10.1.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): + eslint-json-compat-utils@0.2.3(eslint@10.2.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) esquery: 1.7.0 jsonc-eslint-parser: 3.1.0 - eslint-markdown@0.6.0(eslint@10.1.0(jiti@2.6.1)): + eslint-markdown@0.6.1(eslint@10.2.0(jiti@2.6.1)): dependencies: '@eslint/markdown': 7.5.1 micromark-util-normalize-identifier: 2.0.1 parse5: 8.0.0 optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) transitivePeerDependencies: - supports-color - eslint-merge-processors@2.0.0(eslint@10.1.0(jiti@2.6.1)): + eslint-merge-processors@2.0.0(eslint@10.2.0(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-antfu@3.2.2(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-antfu@3.2.2(eslint@10.2.0(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-better-tailwindcss@4.3.2(eslint@10.1.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.18.1))(tailwindcss@4.2.2)(typescript@6.0.2): + eslint-plugin-better-tailwindcss@4.4.1(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2): dependencies: - '@eslint/css-tree': 3.6.9 + '@eslint/css-tree': 4.0.1 '@valibot/to-json-schema': 1.6.0(valibot@1.3.1(typescript@6.0.2)) enhanced-resolve: 5.20.1 jiti: 2.6.1 synckit: 0.11.12 - tailwind-csstree: 0.1.5 + tailwind-csstree: 0.3.1 tailwindcss: 4.2.2 tsconfig-paths-webpack-plugin: 4.2.0 valibot: 1.3.1(typescript@6.0.2) optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) - oxlint: 1.58.0(oxlint-tsgolint@0.18.1) + eslint: 10.2.0(jiti@2.6.1) + oxlint: 1.58.0(oxlint-tsgolint@0.20.0) transitivePeerDependencies: - '@eslint/css' - typescript - eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2))(@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)): dependencies: '@es-joy/jsdoccomment': 0.84.0 - '@typescript-eslint/rule-tester': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/rule-tester': 8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-depend@1.5.0(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-depend@1.5.0(eslint@10.2.0(jiti@2.6.1)): dependencies: empathic: 2.0.0 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) module-replacements: 2.11.0 semver: 7.7.4 - eslint-plugin-es-x@7.8.0(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-es-x@7.8.0(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - eslint: 10.1.0(jiti@2.6.1) - eslint-compat-utils: 0.5.1(eslint@10.1.0(jiti@2.6.1)) + eslint: 10.2.0(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-hyoban@0.14.1(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-hyoban@0.14.1(eslint@10.2.0(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-import-lite@0.6.0(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-import-lite@0.6.0(eslint@10.2.0(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-jsdoc@62.8.1(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-jsdoc@62.9.0(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@es-joy/jsdoccomment': 0.84.0 + '@es-joy/jsdoccomment': 0.86.0 '@es-joy/resolve.exports': 1.2.0 are-docs-informative: 0.0.2 - comment-parser: 1.4.5 + comment-parser: 1.4.6 debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) espree: 11.2.0 esquery: 1.7.0 html-entities: 2.6.0 @@ -13365,27 +13139,27 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-jsonc@3.1.2(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-jsonc@3.1.2(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@eslint/core': 1.1.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@eslint/core': 1.2.0 '@eslint/plugin-kit': 0.6.1 '@ota-meshi/ast-token-store': 0.3.0 diff-sequences: 29.6.3 - eslint: 10.1.0(jiti@2.6.1) - eslint-json-compat-utils: 0.2.3(eslint@10.1.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) + eslint: 10.2.0(jiti@2.6.1) + eslint-json-compat-utils: 0.2.3(eslint@10.2.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) jsonc-eslint-parser: 3.1.0 natural-compare: 1.4.0 synckit: 0.11.12 transitivePeerDependencies: - '@eslint/json' - eslint-plugin-markdown-preferences@0.41.0(@eslint/markdown@8.0.1)(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-markdown-preferences@0.41.1(@eslint/markdown@8.0.1)(eslint@10.2.0(jiti@2.6.1)): dependencies: '@eslint/markdown': 8.0.1 diff-sequences: 29.6.3 emoji-regex-xs: 2.0.1 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) mdast-util-from-markdown: 2.0.3 mdast-util-frontmatter: 2.0.1 mdast-util-gfm: 3.1.0 @@ -13400,12 +13174,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-n@17.24.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-n@17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) enhanced-resolve: 5.20.1 - eslint: 10.1.0(jiti@2.6.1) - eslint-plugin-es-x: 7.8.0(eslint@10.1.0(jiti@2.6.1)) + eslint: 10.2.0(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.2.0(jiti@2.6.1)) get-tsconfig: 4.13.7 globals: 15.15.0 globrex: 0.1.2 @@ -13415,29 +13189,29 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-no-barrel-files@1.2.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-no-barrel-files@1.3.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) transitivePeerDependencies: - - eslint - supports-color - typescript eslint-plugin-no-only-tests@3.3.0: {} - eslint-plugin-perfectionist@5.7.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-perfectionist@5.8.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) natural-orderby: 5.0.0 transitivePeerDependencies: - supports-color - typescript - eslint-plugin-pnpm@1.6.0(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-pnpm@1.6.0(eslint@10.2.0(jiti@2.6.1)): dependencies: empathic: 2.0.0 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) jsonc-eslint-parser: 3.1.0 pathe: 2.0.3 pnpm-workspace-yaml: 1.6.0 @@ -13445,87 +13219,87 @@ snapshots: yaml: 2.8.3 yaml-eslint-parser: 2.0.0 - eslint-plugin-react-dom@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-dom@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/core': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/core': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) compare-versions: 6.1.1 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 6.0.2 transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-naming-convention@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/core': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/core': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) compare-versions: 6.1.1 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) string-ts: 2.3.1 ts-pattern: 5.9.0 typescript: 6.0.2 transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-react-rsc@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-rsc@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) + '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 6.0.2 transitivePeerDependencies: - supports-color - eslint-plugin-react-web-api@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-web-api@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/core': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/core': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) birecord: 0.1.1 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 6.0.2 transitivePeerDependencies: - supports-color - eslint-plugin-react-x@3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-x@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/core': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/core': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) compare-versions: 6.1.1 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) string-ts: 2.3.1 ts-api-utils: 2.5.0(typescript@6.0.2) ts-pattern: 5.9.0 @@ -13533,25 +13307,25 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-regexp@3.1.0(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-regexp@3.1.0(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 comment-parser: 1.4.6 - eslint: 10.1.0(jiti@2.6.1) - jsdoc-type-pratt-parser: 7.1.1 + eslint: 10.2.0(jiti@2.6.1) + jsdoc-type-pratt-parser: 7.2.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-sonarjs@4.0.2(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-sonarjs@4.0.2(eslint@10.2.0(jiti@2.6.1)): dependencies: '@eslint-community/regexpp': 4.12.2 builtin-modules: 3.3.0 bytes: 3.1.2 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) functional-red-black-tree: 1.0.1 - globals: 17.4.0 + globals: 17.5.0 jsx-ast-utils-x: 0.1.0 lodash.merge: 4.6.2 minimatch: 10.2.4 @@ -13560,37 +13334,37 @@ snapshots: ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 - eslint-plugin-storybook@10.3.4(eslint@10.1.0(jiti@2.6.1))(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2): + eslint-plugin-storybook@10.3.5(eslint@10.2.0(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2): dependencies: - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.6.1) - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint: 10.2.0(jiti@2.6.1) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-toml@1.3.1(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-toml@1.3.1(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.0 '@eslint/plugin-kit': 0.6.1 '@ota-meshi/ast-token-store': 0.3.0 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) toml-eslint-parser: 1.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-unicorn@64.0.0(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-unicorn@64.0.0(eslint@10.2.0(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) change-case: 5.4.4 ci-info: 4.4.0 clean-regexp: 1.0.0 core-js-compat: 3.49.0 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) find-up-simple: 1.0.1 - globals: 17.4.0 + globals: 17.5.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 jsesc: 3.1.0 @@ -13600,44 +13374,44 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.1.0(jiti@2.6.1)))(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@2.6.1))): + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.2.0(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - eslint: 10.1.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + eslint: 10.2.0(jiti@2.6.1) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.4 - vue-eslint-parser: 10.4.0(eslint@10.1.0(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.2.0(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@stylistic/eslint-plugin': 5.10.0(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.0(jiti@2.6.1)) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-yml@3.3.1(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-yml@3.3.1(eslint@10.2.0(jiti@2.6.1)): dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.0 '@eslint/plugin-kit': 0.6.1 '@ota-meshi/ast-token-store': 0.3.0 debug: 4.4.3(supports-color@8.1.1) diff-sequences: 29.6.3 escape-string-regexp: 5.0.0 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) natural-compare: 1.4.0 yaml-eslint-parser: 2.0.0 transitivePeerDependencies: - supports-color - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.31)(eslint@10.1.0(jiti@2.6.1)): + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.31)(eslint@10.2.0(jiti@2.6.1)): dependencies: '@vue/compiler-sfc': 3.5.31 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) eslint-scope@5.1.1: dependencies: @@ -13662,14 +13436,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.1.0(jiti@2.6.1): + eslint@10.2.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.3 - '@eslint/config-helpers': 0.5.3 - '@eslint/core': 1.1.1 - '@eslint/plugin-kit': 0.6.1 + '@eslint/config-array': 0.23.4 + '@eslint/config-helpers': 0.5.4 + '@eslint/core': 1.2.0 + '@eslint/plugin-kit': 0.7.0 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -13804,8 +13578,6 @@ snapshots: esutils@2.0.3: {} - event-target-bus@1.0.0: {} - events@3.3.0: {} expand-template@2.0.3: @@ -13913,15 +13685,6 @@ snapshots: dependencies: fd-package-json: 2.0.0 - foxact@0.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - client-only: 0.0.1 - event-target-bus: 1.0.0 - server-only: 0.0.1 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - fs-constants@1.0.0: optional: true @@ -13976,7 +13739,7 @@ snapshots: globals@15.15.0: {} - globals@17.4.0: {} + globals@17.5.0: {} globrex@0.1.2: {} @@ -13988,9 +13751,9 @@ snapshots: hachure-fill@0.5.2: {} - happy-dom@20.8.9: + happy-dom@20.9.0: dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -14155,7 +13918,7 @@ snapshots: hex-rgb@4.3.0: {} - hono@4.12.10: {} + hono@4.12.12: {} hosted-git-info@9.0.2: dependencies: @@ -14186,7 +13949,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@26.0.3(typescript@6.0.2): + i18next@26.0.4(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: @@ -14291,10 +14054,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 - is-stream@2.0.1: {} is-wsl@3.1.1: @@ -14320,18 +14079,18 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 merge-stream: 2.0.0 supports-color: 8.1.1 jiti@2.6.1: {} - jotai@2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5): optionalDependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 '@types/react': 19.2.14 - react: 19.2.4 + react: 19.2.5 js-audio-recorder@1.0.7: {} @@ -14351,6 +14110,8 @@ snapshots: jsdoc-type-pratt-parser@7.1.1: {} + jsdoc-type-pratt-parser@7.2.0: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -14371,8 +14132,6 @@ snapshots: eslint-visitor-keys: 5.0.1 semver: 7.7.4 - jsonc-parser@3.3.1: {} - jsonfile@6.2.0: dependencies: universalify: 2.0.1 @@ -14383,7 +14142,7 @@ snapshots: jsx-ast-utils-x@0.1.0: {} - katex@0.16.44: + katex@0.16.45: dependencies: commander: 8.3.0 @@ -14393,7 +14152,7 @@ snapshots: khroma@2.1.0: {} - knip@6.2.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + knip@6.4.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): dependencies: '@nodelib/fs.walk': 1.2.8 fast-glob: 3.3.3 @@ -14420,7 +14179,7 @@ snapshots: kolorist@1.8.0: {} - ky@1.14.3: {} + ky@2.0.0: {} lamejs@1.2.1: dependencies: @@ -14448,12 +14207,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lexical-code-no-prism@0.41.0(@lexical/utils@0.42.0)(lexical@0.42.0): + lexical-code-no-prism@0.41.0(@lexical/utils@0.43.0)(lexical@0.43.0): dependencies: - '@lexical/utils': 0.42.0 - lexical: 0.42.0 + '@lexical/utils': 0.43.0 + lexical: 0.43.0 - lexical@0.42.0: {} + lexical@0.43.0: {} lib0@0.2.117: dependencies: @@ -14790,7 +14549,7 @@ snapshots: mdn-data@2.0.30: {} - mdn-data@2.23.0: {} + mdn-data@2.27.1: {} merge-stream@2.0.0: {} @@ -14811,7 +14570,7 @@ snapshots: dagre-d3-es: 7.0.14 dayjs: 1.11.20 dompurify: 3.3.2 - katex: 0.16.44 + katex: 0.16.45 khroma: 2.1.0 lodash-es: 4.18.0 marked: 16.4.2 @@ -14918,7 +14677,7 @@ snapshots: dependencies: '@types/katex': 0.16.8 devlop: 1.1.0 - katex: 0.16.44 + katex: 0.16.45 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 @@ -15197,30 +14956,30 @@ snapshots: neo-async@2.6.2: {} - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next-themes@0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0): + next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0): dependencies: - '@next/env': 16.2.2 + '@next/env': 16.2.3 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.12 caniuse-lite: 1.0.30001781 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.2 - '@next/swc-darwin-x64': 16.2.2 - '@next/swc-linux-arm64-gnu': 16.2.2 - '@next/swc-linux-arm64-musl': 16.2.2 - '@next/swc-linux-x64-gnu': 16.2.2 - '@next/swc-linux-x64-musl': 16.2.2 - '@next/swc-win32-arm64-msvc': 16.2.2 - '@next/swc-win32-x64-msvc': 16.2.2 + '@next/swc-darwin-arm64': 16.2.3 + '@next/swc-darwin-x64': 16.2.3 + '@next/swc-linux-arm64-gnu': 16.2.3 + '@next/swc-linux-arm64-musl': 16.2.3 + '@next/swc-linux-x64-gnu': 16.2.3 + '@next/swc-linux-x64-musl': 16.2.3 + '@next/swc-win32-arm64-msvc': 16.2.3 + '@next/swc-win32-x64-msvc': 16.2.3 '@playwright/test': 1.59.1 sass: 1.98.0 sharp: 0.34.5 @@ -15255,12 +15014,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.9(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4): + nuqs@2.8.9(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react@19.2.5): dependencies: '@standard-schema/spec': 1.0.0 - react: 19.2.4 + react: 19.2.5 optionalDependencies: - next: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) object-assign@4.1.1: {} @@ -15387,16 +15146,16 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.43.0 '@oxfmt/binding-win32-x64-msvc': 0.43.0 - oxlint-tsgolint@0.18.1: + oxlint-tsgolint@0.20.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.18.1 - '@oxlint-tsgolint/darwin-x64': 0.18.1 - '@oxlint-tsgolint/linux-arm64': 0.18.1 - '@oxlint-tsgolint/linux-x64': 0.18.1 - '@oxlint-tsgolint/win32-arm64': 0.18.1 - '@oxlint-tsgolint/win32-x64': 0.18.1 + '@oxlint-tsgolint/darwin-arm64': 0.20.0 + '@oxlint-tsgolint/darwin-x64': 0.20.0 + '@oxlint-tsgolint/linux-arm64': 0.20.0 + '@oxlint-tsgolint/linux-x64': 0.20.0 + '@oxlint-tsgolint/win32-arm64': 0.20.0 + '@oxlint-tsgolint/win32-x64': 0.20.0 - oxlint@1.58.0(oxlint-tsgolint@0.18.1): + oxlint@1.58.0(oxlint-tsgolint@0.20.0): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.58.0 '@oxlint/binding-android-arm64': 1.58.0 @@ -15417,7 +15176,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.58.0 '@oxlint/binding-win32-ia32-msvc': 1.58.0 '@oxlint/binding-win32-x64-msvc': 1.58.0 - oxlint-tsgolint: 0.18.1 + oxlint-tsgolint: 0.20.0 p-limit@3.1.0: dependencies: @@ -15522,19 +15281,13 @@ snapshots: perfect-debounce@2.1.0: {} - periscopic@4.0.2: - dependencies: - '@types/estree': 1.0.8 - is-reference: 3.0.3 - zimmerframe: 1.1.4 - picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.4: {} - pinyin-pro@3.28.0: {} + pinyin-pro@3.28.1: {} pixelmatch@7.1.0: dependencies: @@ -15600,7 +15353,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.8: + postcss@8.5.9: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -15651,11 +15404,11 @@ snapshots: punycode@2.3.1: {} - qrcode.react@4.2.0(react@19.2.4): + qrcode.react@4.2.0(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 - qs@6.15.0: + qs@6.15.1: dependencies: side-channel: '@nolyfill/side-channel@1.0.44' @@ -15673,15 +15426,15 @@ snapshots: strip-json-comments: 2.0.1 optional: true - re-resizable@6.11.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + re-resizable@6.11.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - react-18-input-autosize@3.0.0(react@19.2.4): + react-18-input-autosize@3.0.0(react@19.2.5): dependencies: prop-types: 15.8.1 - react: 19.2.4 + react: 19.2.5 react-docgen-typescript@2.4.0(typescript@6.0.2): dependencies: @@ -15702,143 +15455,143 @@ snapshots: transitivePeerDependencies: - supports-color - react-dom@19.2.4(react@19.2.4): + react-dom@19.2.5(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 scheduler: 0.27.0 - react-draggable@4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-draggable@4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: clsx: 2.1.1 prop-types: 15.8.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - react-easy-crop@5.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-easy-crop@5.5.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: normalize-wheel: 1.0.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) tslib: 2.8.1 - react-error-boundary@6.1.1(react@19.2.4): + react-error-boundary@6.1.1(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 react-fast-compare@3.2.2: {} - react-hotkeys-hook@5.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-hotkeys-hook@5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - react-i18next@17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2): + react-i18next@16.5.8(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 26.0.3(typescript@6.0.2) - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) + i18next: 26.0.4(typescript@6.0.2) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: - react-dom: 19.2.4(react@19.2.4) + react-dom: 19.2.5(react@19.2.5) typescript: 6.0.2 react-is@16.13.1: {} react-is@17.0.2: {} - react-multi-email@1.0.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-multi-email@1.0.25(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) react-papaparse@4.4.0: dependencies: '@types/papaparse': 5.5.2 papaparse: 5.5.3 - react-pdf-highlighter@8.0.0-rc.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-pdf-highlighter@8.0.0-rc.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: pdfjs-dist: 4.4.168 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-rnd: 10.5.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-rnd: 10.5.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-debounce: 4.0.0 - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - react-rnd@10.5.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-rnd@10.5.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - re-resizable: 6.11.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + re-resizable: 6.11.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tslib: 2.6.2 - react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)): + react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)): dependencies: acorn-loose: 8.5.2 neo-async: 2.6.2 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + webpack: 5.105.4(uglify-js@3.19.3) webpack-sources: 3.3.4 - react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7): + react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sortablejs@1.15.7): dependencies: '@types/sortablejs': 1.15.9 classnames: 2.3.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) sortablejs: 1.15.7 tiny-invariant: 1.2.0 - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): dependencies: get-nonce: 1.0.1 - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): + react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.5): dependencies: '@babel/runtime': 7.29.2 - react: 19.2.4 - use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) - use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.5) + use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.5) transitivePeerDependencies: - '@types/react' - react@19.2.4: {} + react@19.2.5: {} - reactflow@11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + reactflow@11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@reactflow/background': 11.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/controls': 11.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/minimap': 11.7.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/node-resizer': 2.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@reactflow/node-toolbar': 1.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@reactflow/background': 11.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/controls': 11.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/minimap': 11.7.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/node-resizer': 2.2.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@reactflow/node-toolbar': 1.3.14(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: - '@types/react' - immer @@ -15950,7 +15703,7 @@ snapshots: '@types/katex': 0.16.8 hast-util-from-html-isomorphic: 2.0.0 hast-util-to-text: 4.0.2 - katex: 0.16.44 + katex: 0.16.45 unist-util-visit-parents: 6.0.2 vfile: 6.0.3 @@ -16064,30 +15817,6 @@ snapshots: robust-predicates@3.0.3: {} - rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): - dependencies: - '@oxc-project/types': 0.122.0 - '@rolldown/pluginutils': 1.0.0-rc.12 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-x64': 1.0.0-rc.12 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -16126,8 +15855,6 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - rsc-html-stream@0.0.7: {} - run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -16195,8 +15922,6 @@ snapshots: seroval@1.5.1: {} - server-only@0.0.1: {} - sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -16307,7 +16032,7 @@ snapshots: spdx-license-ids@3.0.23: {} - srvx@0.11.13: {} + srvx@0.11.15: {} stackframe@1.3.4: {} @@ -16317,10 +16042,10 @@ snapshots: std-semver@1.0.8: {} - storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 @@ -16330,7 +16055,7 @@ snapshots: open: 10.2.0 recast: 0.23.11 semver: 7.7.4 - use-sync-external-store: 1.6.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.5) ws: 8.20.0 transitivePeerDependencies: - '@testing-library/dom' @@ -16339,15 +16064,15 @@ snapshots: - react-dom - utf-8-validate - streamdown@2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + streamdown@2.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: clsx: 2.1.1 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 marked: 17.0.5 mermaid: 11.14.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) rehype-harden: 1.1.8 rehype-raw: 7.0.0 rehype-sanitize: 6.0.0 @@ -16416,10 +16141,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5): dependencies: client-only: 0.0.1 - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@babel/core': 7.29.0 @@ -16453,7 +16178,7 @@ snapshots: tagged-tag@1.0.0: {} - tailwind-csstree@0.1.5: {} + tailwind-csstree@0.3.1: {} tailwind-merge@3.5.0: {} @@ -16486,15 +16211,14 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser-webpack-plugin@5.4.0(esbuild@0.27.2)(uglify-js@3.19.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)): + terser-webpack-plugin@5.4.0(uglify-js@3.19.3)(webpack@5.105.4(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) + webpack: 5.105.4(uglify-js@3.19.3) optionalDependencies: - esbuild: 0.27.2 uglify-js: 3.19.3 terser@5.46.1: @@ -16537,11 +16261,11 @@ snapshots: tinyspy@4.0.4: {} - tldts-core@7.0.27: {} + tldts-core@7.0.28: {} - tldts@7.0.27: + tldts@7.0.28: dependencies: - tldts-core: 7.0.27 + tldts-core: 7.0.28 to-regex-range@5.0.1: dependencies: @@ -16638,7 +16362,7 @@ snapshots: unbash@2.2.0: {} - undici-types@7.18.2: {} + undici-types@7.19.2: {} undici@7.24.0: {} @@ -16726,50 +16450,50 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4): + use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - use-context-selector@2.0.0(react@19.2.4)(scheduler@0.27.0): + use-context-selector@2.0.0(react@19.2.5)(scheduler@0.27.0): dependencies: - react: 19.2.4 + react: 19.2.5 scheduler: 0.27.0 - use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4): + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4): + use-latest@1.3.0(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): dependencies: detect-node-es: 1.1.0 - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 use-strict@1.0.1: {} - use-sync-external-store@1.6.0(react@19.2.4): + use-sync-external-store@1.6.0(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 util-arity@1.1.0: {} @@ -16803,22 +16527,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.0.39(bf3f106951d7257e1336e695c204ec32): + vinext@0.0.41(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2): dependencies: - '@unpic/react': 1.0.2(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@unpic/react': 1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@vercel/og': 0.8.6 - '@vitejs/plugin-react': 6.0.1(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@vitejs/plugin-react': 6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) magic-string: 0.30.21 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - rsc-html-stream: 0.0.7 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plugin-commonjs: 0.10.4 - vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) optionalDependencies: '@mdx-js/rollup': 3.1.1(rollup@4.59.0) - '@vitejs/plugin-rsc': 0.5.21(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4) - react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + '@vitejs/plugin-rsc': 0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5) + react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) transitivePeerDependencies: - next - supports-color @@ -16837,9 +16560,9 @@ snapshots: fast-glob: 3.3.3 magic-string: 0.30.21 - vite-plugin-inspect@12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0): + vite-plugin-inspect@12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0): dependencies: - '@vitejs/devtools-kit': 0.1.11(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) + '@vitejs/devtools-kit': 0.1.11(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) ansis: 4.2.0 error-stack-parser-es: 1.0.5 obug: 2.1.1 @@ -16848,47 +16571,43 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - typescript - ws - vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(storybook@10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2): + vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.3.4 - next: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) - storybook: 10.3.4(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) + storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) transitivePeerDependencies: - supports-color - typescript - vite-plus@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): + vite-plus@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): dependencies: - '@oxc-project/types': 0.122.0 - '@voidzero-dev/vite-plus-core': 0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - '@voidzero-dev/vite-plus-test': 0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - cac: 7.0.0 - cross-spawn: 7.0.6 - jsonc-parser: 3.3.1 + '@oxc-project/types': 0.123.0 + '@voidzero-dev/vite-plus-core': 0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) oxfmt: 0.43.0 - oxlint: 1.58.0(oxlint-tsgolint@0.18.1) - oxlint-tsgolint: 0.18.1 - picocolors: 1.1.1 + oxlint: 1.58.0(oxlint-tsgolint@0.20.0) + oxlint-tsgolint: 0.20.0 optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.15 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.15 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.15 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.15 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.15 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.15 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.15 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.15 + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.16 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.16 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.16 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.16 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.16 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.16 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.16 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.16 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -16917,105 +16636,36 @@ snapshots: - vite - yaml - vite-plus@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): - dependencies: - '@oxc-project/types': 0.122.0 - '@voidzero-dev/vite-plus-core': 0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - '@voidzero-dev/vite-plus-test': 0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) - cac: 7.0.0 - cross-spawn: 7.0.6 - jsonc-parser: 3.3.1 - oxfmt: 0.43.0 - oxlint: 1.58.0(oxlint-tsgolint@0.18.1) - oxlint-tsgolint: 0.18.1 - picocolors: 1.1.1 - optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.15 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.15 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.15 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.15 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.15 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.15 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.15 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.15 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - utf-8-validate - - vite - - yaml - - vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): + vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.2) optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): + vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.2) - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - supports-color - typescript - vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - tinyglobby: 0.2.15 + vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): optionalDependencies: - '@types/node': 25.5.0 - esbuild: 0.27.2 - fsevents: 2.3.3 - jiti: 2.6.1 - sass: 1.98.0 - terser: 5.46.1 - tsx: 4.21.0 - yaml: 2.8.3 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vitefu@1.1.2(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): - optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - - vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' void-elements@3.1.0: {} @@ -17036,10 +16686,10 @@ snapshots: vscode-uri@3.1.0: {} - vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@2.6.1)): + vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1)): dependencies: debug: 4.4.3(supports-color@8.1.1) - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.0(jiti@2.6.1) eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 espree: 11.2.0 @@ -17063,7 +16713,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3): + webpack@5.105.4(uglify-js@3.19.3): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -17087,7 +16737,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.2 - terser-webpack-plugin: 5.4.0(esbuild@0.27.2)(uglify-js@3.19.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + terser-webpack-plugin: 5.4.0(uglify-js@3.19.3)(webpack@5.105.4(uglify-js@3.19.3)) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: @@ -17161,31 +16811,29 @@ snapshots: zen-observable@0.10.0: {} - zimmerframe@1.1.4: {} - zod@4.3.6: {} zrender@6.0.0: dependencies: tslib: 2.3.0 - zundo@2.3.0(zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))): + zundo@2.3.0(zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))): dependencies: - zustand: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + zustand: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) - zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4): + zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5): dependencies: - use-sync-external-store: 1.6.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 immer: 11.1.4 - react: 19.2.4 + react: 19.2.5 - zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): optionalDependencies: '@types/react': 19.2.14 immer: 11.1.4 - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b17ea2588..98f1fcfa86 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,100 +40,103 @@ overrides: svgo@>=3.0.0 <3.3.3: 3.3.3 tar@<=7.5.10: 7.5.11 undici@>=7.0.0 <7.24.0: 7.24.0 - vite: npm:@voidzero-dev/vite-plus-core@0.1.15 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.15 + vite: npm:@voidzero-dev/vite-plus-core@0.1.16 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.16 yaml@>=2.0.0 <2.8.3: 2.8.3 yauzl@<3.2.1: 3.2.1 catalog: - "@amplitude/analytics-browser": 2.38.1 - "@amplitude/plugin-session-replay-browser": 1.27.6 - "@antfu/eslint-config": 8.0.0 - "@base-ui/react": 1.3.0 - "@chromatic-com/storybook": 5.1.1 - "@cucumber/cucumber": 12.7.0 + "@amplitude/analytics-browser": 2.39.0 + "@amplitude/plugin-session-replay-browser": 1.27.7 + "@antfu/eslint-config": 8.2.0 + "@base-ui/react": 1.4.0 + "@date-fns/tz": 1.4.1 + "@chromatic-com/storybook": 5.1.2 + "@cucumber/cucumber": 12.8.0 "@egoist/tailwindcss-icons": 1.9.2 "@emoji-mart/data": 1.2.1 "@eslint-react/eslint-plugin": 3.0.0 "@eslint/js": 10.0.1 "@floating-ui/react": 0.27.19 - "@formatjs/intl-localematcher": 0.8.2 - "@headlessui/react": 2.2.9 + "@formatjs/intl-localematcher": 0.8.3 + "@headlessui/react": 2.2.10 "@heroicons/react": 2.2.0 - "@hono/node-server": 1.19.12 + "@hono/node-server": 1.19.14 "@iconify-json/heroicons": 1.2.3 "@iconify-json/ri": 1.2.10 - "@lexical/code": 0.42.0 - "@lexical/link": 0.42.0 - "@lexical/list": 0.42.0 - "@lexical/react": 0.42.0 - "@lexical/selection": 0.42.0 - "@lexical/text": 0.42.0 - "@lexical/utils": 0.42.0 + "@lexical/code": 0.43.0 + "@lexical/link": 0.43.0 + "@lexical/list": 0.43.0 + "@lexical/react": 0.43.0 + "@lexical/selection": 0.43.0 + "@lexical/text": 0.43.0 + "@lexical/utils": 0.43.0 "@mdx-js/loader": 3.1.1 "@mdx-js/react": 3.1.1 "@mdx-js/rollup": 3.1.1 "@monaco-editor/react": 4.7.0 - "@next/eslint-plugin-next": 16.2.2 - "@next/mdx": 16.2.2 - "@orpc/client": 1.13.13 - "@orpc/contract": 1.13.13 - "@orpc/openapi-client": 1.13.13 - "@orpc/tanstack-query": 1.13.13 + "@next/eslint-plugin-next": 16.2.3 + "@next/mdx": 16.2.3 + "@orpc/client": 1.13.14 + "@orpc/contract": 1.13.14 + "@orpc/openapi-client": 1.13.14 + "@orpc/tanstack-query": 1.13.14 "@playwright/test": 1.59.1 "@remixicon/react": 4.9.0 "@rgrove/parse-xml": 4.2.0 - "@sentry/react": 10.47.0 - "@storybook/addon-docs": 10.3.4 - "@storybook/addon-links": 10.3.4 - "@storybook/addon-onboarding": 10.3.4 - "@storybook/addon-themes": 10.3.4 - "@storybook/nextjs-vite": 10.3.4 - "@storybook/react": 10.3.4 + "@sentry/react": 10.48.0 + "@storybook/addon-docs": 10.3.5 + "@storybook/addon-links": 10.3.5 + "@storybook/addon-onboarding": 10.3.5 + "@storybook/addon-themes": 10.3.5 + "@storybook/nextjs-vite": 10.3.5 + "@storybook/react": 10.3.5 "@streamdown/math": 1.0.2 "@svgdotjs/svg.js": 3.2.5 "@t3-oss/env-nextjs": 0.13.11 "@tailwindcss/postcss": 4.2.2 "@tailwindcss/typography": 0.5.19 "@tailwindcss/vite": 4.2.2 - "@tanstack/eslint-plugin-query": 5.96.1 - "@tanstack/react-devtools": 0.10.1 - "@tanstack/react-form": 1.28.6 - "@tanstack/react-form-devtools": 0.2.20 - "@tanstack/react-query": 5.96.1 - "@tanstack/react-query-devtools": 5.96.1 + "@tanstack/eslint-plugin-query": 5.99.0 + "@tanstack/react-devtools": 0.10.2 + "@tanstack/react-form": 1.29.0 + "@tanstack/react-form-devtools": 0.2.21 + "@tanstack/react-query": 5.99.0 + "@tanstack/react-query-devtools": 5.99.0 "@tanstack/react-virtual": 3.13.23 "@testing-library/dom": 10.4.1 "@testing-library/jest-dom": 6.9.1 "@testing-library/react": 16.3.2 "@testing-library/user-event": 14.6.1 - "@tsslint/cli": 3.0.2 - "@tsslint/compat-eslint": 3.0.2 - "@tsslint/config": 3.0.2 + "@tsslint/cli": 3.0.3 + "@tsslint/compat-eslint": 3.0.3 + "@tsslint/config": 3.0.3 "@types/js-cookie": 3.0.6 "@types/js-yaml": 4.0.9 "@types/negotiator": 0.6.4 - "@types/node": 25.5.0 + "@types/node": 25.6.0 "@types/postcss-js": 4.1.0 "@types/qs": 6.15.0 "@types/react": 19.2.14 "@types/react-dom": 19.2.3 "@types/sortablejs": 1.15.9 - "@typescript-eslint/eslint-plugin": 8.58.0 - "@typescript-eslint/parser": 8.58.0 - "@typescript/native-preview": 7.0.0-dev.20260401.1 + "@typescript-eslint/eslint-plugin": 8.58.2 + "@typescript-eslint/parser": 8.58.2 + "@typescript/native-preview": 7.0.0-dev.20260413.1 "@vitejs/plugin-react": 6.0.1 - "@vitejs/plugin-rsc": 0.5.21 - "@vitest/coverage-v8": 4.1.2 + "@vitejs/plugin-rsc": 0.5.24 + "@vitest/coverage-v8": 4.1.4 abcjs: 6.6.2 agentation: 3.0.2 ahooks: 3.9.7 - autoprefixer: 10.4.27 + autoprefixer: 10.5.0 class-variance-authority: 0.7.1 + client-only: 0.0.1 clsx: 2.1.1 cmdk: 1.1.1 code-inspector-plugin: 1.5.1 copy-to-clipboard: 3.3.3 cron-parser: 5.5.0 + date-fns: 4.1.0 dayjs: 1.11.20 decimal.js: 10.6.0 dompurify: 3.3.3 @@ -144,58 +147,57 @@ catalog: embla-carousel-react: 8.6.0 emoji-mart: 5.6.0 es-toolkit: 1.45.1 - eslint: 10.1.0 - eslint-markdown: 0.6.0 - eslint-plugin-better-tailwindcss: 4.3.2 + eslint: 10.2.0 + eslint-markdown: 0.6.1 + eslint-plugin-better-tailwindcss: 4.4.1 eslint-plugin-hyoban: 0.14.1 - eslint-plugin-markdown-preferences: 0.41.0 - eslint-plugin-no-barrel-files: 1.2.2 + eslint-plugin-markdown-preferences: 0.41.1 + eslint-plugin-no-barrel-files: 1.3.1 eslint-plugin-react-refresh: 0.5.2 eslint-plugin-sonarjs: 4.0.2 - eslint-plugin-storybook: 10.3.4 + eslint-plugin-storybook: 10.3.5 fast-deep-equal: 3.1.3 - foxact: 0.3.0 - happy-dom: 20.8.9 + happy-dom: 20.9.0 hast-util-to-jsx-runtime: 2.3.6 - hono: 4.12.10 + hono: 4.12.12 html-entities: 2.6.0 html-to-image: 1.11.13 - i18next: 26.0.3 + i18next: 26.0.4 i18next-resources-to-backend: 1.2.1 iconify-import-svg: 0.1.2 immer: 11.1.4 - jotai: 2.19.0 + jotai: 2.19.1 js-audio-recorder: 1.0.7 js-cookie: 3.0.5 js-yaml: 4.1.1 jsonschema: 1.5.0 - katex: 0.16.44 - knip: 6.2.0 - ky: 1.14.3 + katex: 0.16.45 + knip: 6.4.1 + ky: 2.0.0 lamejs: 1.2.1 - lexical: 0.42.0 + lexical: 0.43.0 mermaid: 11.14.0 mime: 4.1.0 mitt: 3.0.1 negotiator: 1.0.0 - next: 16.2.2 + next: 16.2.3 next-themes: 0.4.6 nuqs: 2.8.9 - pinyin-pro: 3.28.0 - postcss: 8.5.8 + pinyin-pro: 3.28.1 + postcss: 8.5.9 postcss-js: 5.1.0 qrcode.react: 4.2.0 - qs: 6.15.0 - react: 19.2.4 + qs: 6.15.1 + react: 19.2.5 react-18-input-autosize: 3.0.0 - react-dom: 19.2.4 + react-dom: 19.2.5 react-easy-crop: 5.5.7 react-hotkeys-hook: 5.2.4 - react-i18next: 17.0.2 + react-i18next: 16.5.8 react-multi-email: 1.0.25 react-papaparse: 4.4.0 react-pdf-highlighter: 8.0.0-rc.0 - react-server-dom-webpack: 19.2.4 + react-server-dom-webpack: 19.2.5 react-sortablejs: 6.1.4 react-textarea-autosize: 8.5.9 reactflow: 11.11.4 @@ -206,24 +208,24 @@ catalog: shiki: 4.0.2 sortablejs: 1.15.7 std-semver: 1.0.8 - storybook: 10.3.4 + storybook: 10.3.5 streamdown: 2.5.0 string-ts: 2.3.1 tailwind-merge: 3.5.0 tailwindcss: 4.2.2 - tldts: 7.0.27 - tsdown: 0.21.7 + tldts: 7.0.28 + tsdown: 0.21.8 tsx: 4.21.0 typescript: 6.0.2 uglify-js: 3.19.3 unist-util-visit: 5.1.0 use-context-selector: 2.0.0 uuid: 13.0.0 - vinext: 0.0.39 - vite: npm:@voidzero-dev/vite-plus-core@0.1.15 + vinext: 0.0.41 + vite: npm:@voidzero-dev/vite-plus-core@0.1.16 vite-plugin-inspect: 12.0.0-beta.1 - vite-plus: 0.1.15 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.15 + vite-plus: 0.1.16 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.16 vitest-canvas-mock: 1.1.4 zod: 4.3.6 zundo: 2.3.0 diff --git a/sdks/nodejs-client/package.json b/sdks/nodejs-client/package.json index da9f7353ac..e058edb0ca 100644 --- a/sdks/nodejs-client/package.json +++ b/sdks/nodejs-client/package.json @@ -62,6 +62,7 @@ "@vitest/coverage-v8": "catalog:", "eslint": "catalog:", "typescript": "catalog:", + "vite": "catalog:", "vite-plus": "catalog:", "vitest": "catalog:" } diff --git a/web/.env.example b/web/.env.example index 62d4fa6c56..93cbc22fc8 100644 --- a/web/.env.example +++ b/web/.env.example @@ -48,6 +48,9 @@ NEXT_PUBLIC_CSP_WHITELIST= # Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking NEXT_PUBLIC_ALLOW_EMBED= +# Allow inline style attributes in Markdown rendering (self-hosted opt-in). +NEXT_PUBLIC_ALLOW_INLINE_STYLES=false + # Allow rendering unsafe URLs which have "data:" scheme. NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false diff --git a/web/Dockerfile b/web/Dockerfile index 030651bf27..4971f86f97 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -42,7 +42,7 @@ COPY . . WORKDIR /app/web ENV NODE_OPTIONS="--max-old-space-size=4096" -RUN pnpm build +RUN pnpm build && pnpm build:vinext # production stage @@ -56,6 +56,7 @@ ENV APP_API_URL=http://127.0.0.1:5001 ENV MARKETPLACE_API_URL=https://marketplace.dify.ai ENV MARKETPLACE_URL=https://marketplace.dify.ai ENV PORT=3000 +ENV EXPERIMENTAL_ENABLE_VINEXT=false ENV NEXT_TELEMETRY_DISABLED=1 # set timezone @@ -73,9 +74,10 @@ RUN addgroup -S -g ${dify_uid} dify && \ WORKDIR /app -COPY --from=builder --chown=dify:dify /app/web/public ./web/public -COPY --from=builder --chown=dify:dify /app/web/.next/standalone ./ -COPY --from=builder --chown=dify:dify /app/web/.next/static ./web/.next/static +COPY --from=builder --chown=dify:dify /app/web/public ./targets/next/web/public +COPY --from=builder --chown=dify:dify /app/web/.next/standalone ./targets/next/ +COPY --from=builder --chown=dify:dify /app/web/.next/static ./targets/next/web/.next/static +COPY --from=builder --chown=dify:dify /app/web/dist/standalone ./targets/vinext COPY --chown=dify:dify --chmod=755 web/docker/entrypoint.sh ./entrypoint.sh diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx new file mode 100644 index 0000000000..d1ca233d96 --- /dev/null +++ b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx @@ -0,0 +1,224 @@ +import type { DataSet } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DatasetInfo from '@/app/components/app-sidebar/dataset-info' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +const mockReplace = vi.fn() +const mockInvalidDatasetList = vi.fn() +const mockInvalidDatasetDetail = vi.fn() +const mockExportPipeline = vi.fn() +const mockCheckIsUsedInApp = vi.fn() +const mockDeleteDataset = vi.fn() +const mockDownloadBlob = vi.fn() + +let mockDataset: DataSet + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ + dataset: mockDataset, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) => + selector({ isCurrentWorkspaceDatasetOperator: false }), +})) + +vi.mock('@/hooks/use-knowledge', () => ({ + useKnowledge: () => ({ + formatIndexingTechniqueAndMethod: () => 'indexing-technique', + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset', 'detail'], + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidDatasetDetail, +})) + +vi.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipeline, + }), +})) + +vi.mock('@/service/datasets', () => ({ + checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args), + deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/app/components/datasets/rename-modal', () => ({ + default: ({ + show, + onClose, + onSuccess, + }: { + show: boolean + onClose: () => void + onSuccess: () => void + }) => show + ? ( +
+ + +
+ ) + : null, +})) + +const createDataset = (overrides: Partial = {}): DataSet => ({ + id: 'dataset-1', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: { + icon: '📙', + icon_background: '#FFF4ED', + icon_type: 'emoji', + icon_url: '', + }, + description: 'Dataset description', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: 'high_quality' as DataSet['indexing_technique'], + created_by: 'user-1', + updated_by: 'user-1', + updated_at: 1690000000, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 1, + total_document_count: 1, + word_count: 1000, + provider: 'internal', + embedding_model: 'text-embedding-3', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + tags: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + runtime_mode: 'rag_pipeline', + pipeline_id: 'pipeline-1', + enable_api: false, + is_multimodal: false, + is_published: true, + ...overrides, +}) + +const openDropdown = () => { + fireEvent.click(screen.getByRole('button')) +} + +describe('App Sidebar Dataset Info Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDataset = createDataset() + mockExportPipeline.mockResolvedValue({ data: 'pipeline: demo' }) + mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) + mockDeleteDataset.mockResolvedValue({}) + }) + + it('exports the published pipeline from the dropdown menu', async () => { + render() + + expect(screen.getByText('Dataset Name')).toBeInTheDocument() + + openDropdown() + fireEvent.click(await screen.findByText('datasetPipeline.operations.exportPipeline')) + + await waitFor(() => { + expect(mockExportPipeline).toHaveBeenCalledWith({ + pipelineId: 'pipeline-1', + include: false, + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + fileName: 'Dataset Name.pipeline', + })) + }) + }) + + it('opens the rename modal and refreshes dataset caches after a successful rename', async () => { + render() + + openDropdown() + fireEvent.click(await screen.findByText('common.operation.edit')) + + expect(screen.getByTestId('rename-dataset-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'rename-success' })) + + expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) + expect(mockInvalidDatasetDetail).toHaveBeenCalledTimes(1) + }) + + it('checks app usage before deleting and redirects back to the dataset list after confirmation', async () => { + render() + + openDropdown() + fireEvent.click(await screen.findByText('common.operation.delete')) + + await waitFor(() => { + expect(mockCheckIsUsedInApp).toHaveBeenCalledWith('dataset-1') + expect(screen.getByText('dataset.deleteDatasetConfirmTitle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1') + expect(mockInvalidDatasetList).toHaveBeenCalled() + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) +}) diff --git a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx new file mode 100644 index 0000000000..3e3edba5dd --- /dev/null +++ b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx @@ -0,0 +1,199 @@ +import type { SVGProps } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppDetailNav from '@/app/components/app-sidebar' + +const mockSetAppSidebarExpand = vi.fn() + +let mockAppSidebarExpand = 'expand' +let mockPathname = '/app/app-1/logs' +let mockSelectedSegment = 'logs' +let mockIsHovering = true +let keyPressHandler: ((event: { preventDefault: () => void }) => void) | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: { + id: 'app-1', + name: 'Demo App', + mode: 'chat', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: null, + }, + appSidebarExpand: mockAppSidebarExpand, + setAppSidebarExpand: mockSetAppSidebarExpand, + }), +})) + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: () => mockPathname, + useSelectedLayoutSegment: () => mockSelectedSegment, +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + className, + title, + }: { + href: string + children?: React.ReactNode + className?: string + title?: string + }) => ( + + {children} + + ), +})) + +vi.mock('ahooks', () => ({ + useHover: () => mockIsHovering, + useKeyPress: (_key: string, handler: (event: { preventDefault: () => void }) => void) => { + keyPressHandler = handler + }, +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => 'desktop', + MediaType: { + mobile: 'mobile', + desktop: 'desktop', + }, +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: vi.fn(), + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: true, + }), +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + getKeyboardKeyCodeBySystem: () => 'ctrl', + getKeyboardKeyNameBySystem: (key: string) => key, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await vi.importActual('react') + const OpenContext = React.createContext(false) + + return { + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + +
{children}
+
+ ), + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => ( + + ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + const open = React.useContext(OpenContext) + return open ?
{children}
: null + }, + } +}) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/app-sidebar/app-info', () => ({ + default: ({ + expand, + onlyShowDetail, + openState, + }: { + expand: boolean + onlyShowDetail?: boolean + openState?: boolean + }) => ( +
+ ), +})) + +const MockIcon = (props: SVGProps) => + +const navigation = [ + { name: 'Overview', href: '/app/app-1/overview', icon: MockIcon, selectedIcon: MockIcon }, + { name: 'Logs', href: '/app/app-1/logs', icon: MockIcon, selectedIcon: MockIcon }, +] + +describe('App Sidebar Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mockAppSidebarExpand = 'expand' + mockPathname = '/app/app-1/logs' + mockSelectedSegment = 'logs' + mockIsHovering = true + keyPressHandler = null + }) + + it('renders the expanded sidebar, marks the active nav item, and toggles collapse by click and shortcut', () => { + render() + + expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'true') + + const logsLink = screen.getByRole('link', { name: /Logs/i }) + expect(logsLink.className).toContain('bg-components-menu-item-bg-active') + + fireEvent.click(screen.getByRole('button')) + expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') + + const preventDefault = vi.fn() + keyPressHandler?.({ preventDefault }) + + expect(preventDefault).toHaveBeenCalled() + expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') + }) + + it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', () => { + mockPathname = '/app/app-1/workflow' + mockSelectedSegment = 'workflow' + localStorage.setItem('workflow-canvas-maximize', 'true') + + render() + + expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + expect(screen.getByText('Demo App')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Overview/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Logs/i })).toBeInTheDocument() + }) +}) diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx new file mode 100644 index 0000000000..49443eb4ec --- /dev/null +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -0,0 +1,139 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppPublisher from '@/app/components/app/app-publisher' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +const mockFetchAppDetailDirect = vi.fn() +const mockSetAppDetail = vi.fn() +const mockRefetch = vi.fn() + +let mockAppDetail: { + id: string + name: string + mode: AppModeEnum + access_mode: AccessMode + description: string + icon: string + icon_type: string + icon_background: string + site: { + app_base_url: string + access_token: string + } +} | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + webapp_auth: { + enabled: true, + }, + }, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (value: number) => `ago:${value}`, + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ + data: { result: true }, + isLoading: false, + refetch: mockRefetch, + }), + useAppWhiteListSubjects: () => ({ + data: { groups: [], members: [] }, + isLoading: false, + }), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: ({ + onConfirm, + onClose, + }: { + onConfirm: () => Promise + onClose: () => void + }) => ( +
+ + +
+ ), +})) + +describe('App Access Control Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetail = { + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.CHAT, + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + description: 'Demo app description', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } + mockFetchAppDetailDirect.mockResolvedValue({ + ...mockAppDetail, + access_mode: AccessMode.PUBLIC, + }) + }) + + it('refreshes app detail after confirming access control updates', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' })) + fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific')) + + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'confirm-access-control' })) + + await waitFor(() => { + expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) + expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ + id: 'app-1', + access_mode: AccessMode.PUBLIC, + })) + }) + + await waitFor(() => { + expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx new file mode 100644 index 0000000000..5c330cf71e --- /dev/null +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -0,0 +1,243 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppPublisher from '@/app/components/app/app-publisher' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' + +const mockTrackEvent = vi.fn() +const mockRefetch = vi.fn() +const mockFetchInstalledAppList = vi.fn() +const mockFetchAppDetailDirect = vi.fn() +const mockToastError = vi.fn() +const mockOpenAsyncWindow = vi.fn() +const mockSetAppDetail = vi.fn() + +let mockAppDetail: { + id: string + name: string + mode: AppModeEnum + access_mode: AccessMode + description: string + icon: string + icon_type: string + icon_background: string + site: { + app_base_url: string + access_token: string + } +} | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('ahooks', () => ({ + useKeyPress: vi.fn(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + webapp_auth: { + enabled: true, + }, + }, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (value: number) => `ago:${value}`, + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => mockOpenAsyncWindow, +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ + data: { result: true }, + isLoading: false, + refetch: mockRefetch, + }), + useAppWhiteListSubjects: () => ({ + data: { groups: [], members: [] }, + isLoading: false, + }), +})) + +vi.mock('@/service/explore', () => ({ + fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( + isShow + ? ( +
+ +
+ ) + : null + ), +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await vi.importActual('react') + const OpenContext = React.createContext(false) + + return { + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + +
{children}
+
+ ), + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => ( +
+ {children} +
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + const open = React.useContext(OpenContext) + return open ?
{children}
: null + }, + } +}) + +vi.mock('@/app/components/workflow/utils', () => ({ + getKeyboardKeyCodeBySystem: () => 'ctrl', + getKeyboardKeyNameBySystem: (key: string) => key, +})) + +describe('App Publisher Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetail = { + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.CHAT, + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + description: 'Demo app description', + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } + mockFetchInstalledAppList.mockResolvedValue({ + installed_apps: [{ id: 'installed-1' }], + }) + mockFetchAppDetailDirect.mockResolvedValue({ + id: 'app-1', + access_mode: AccessMode.PUBLIC, + }) + mockOpenAsyncWindow.mockImplementation(async ( + resolver: () => Promise, + options?: { onError?: (error: Error) => void }, + ) => { + try { + return await resolver() + } + catch (error) { + options?.onError?.(error as Error) + } + }) + }) + + it('publishes from the summary panel and tracks the publish event', async () => { + const onPublish = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + + expect(screen.getByText('common.latestPublished')).toBeInTheDocument() + expect(screen.getByText('common.publishUpdate')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.publishUpdate')) + + await waitFor(() => { + expect(onPublish).toHaveBeenCalledTimes(1) + expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({ + action_mode: 'app', + app_id: 'app-1', + app_name: 'Demo App', + })) + }) + + expect(mockRefetch).toHaveBeenCalled() + }) + + it('opens embedded modal and resolves the installed explore target', async () => { + render() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.embedIntoSite')) + + expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.openInExplore')) + + await waitFor(() => { + expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1') + expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1) + }) + }) + + it('shows a toast error when no installed explore app is available', async () => { + mockFetchInstalledAppList.mockResolvedValue({ + installed_apps: [], + }) + + render() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.openInExplore')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('No app found in Explore') + }) + }) +}) diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx new file mode 100644 index 0000000000..2a02c063fd --- /dev/null +++ b/web/__tests__/base/chat-flow.test.tsx @@ -0,0 +1,154 @@ +import type { RefObject } from 'react' +import type { ChatConfig } from '@/app/components/base/chat/types' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' +import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ChatWithHistory from '@/app/components/base/chat/chat-with-history' +import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks' +import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import useDocumentTitle from '@/hooks/use-document-title' + +vi.mock('@/app/components/base/chat/chat-with-history/hooks', () => ({ + useChatWithHistory: vi.fn(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(), + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, +})) + +vi.mock('@/hooks/use-document-title', () => ({ + default: vi.fn(), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + })), + usePathname: vi.fn(() => '/'), + useSearchParams: vi.fn(() => new URLSearchParams()), + useParams: vi.fn(() => ({})), +})) + +type HookReturn = ReturnType + +const mockAppData = { + site: { title: 'Test Chat', chat_color_theme: 'blue', chat_color_theme_inverted: false }, +} as unknown as AppData + +const defaultHookReturn: HookReturn = { + isInstalledApp: false, + appId: 'test-app-id', + currentConversationId: '', + currentConversationItem: undefined, + handleConversationIdInfoChange: vi.fn(), + appData: mockAppData, + appParams: {} as ChatConfig, + appMeta: {} as AppMeta, + appPinnedConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appConversationDataLoading: false, + appChatListData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData, + appChatListDataLoading: false, + appPrevChatTree: [], + pinnedConversationList: [], + conversationList: [], + setShowNewConversationItemInList: vi.fn(), + newConversationInputs: {}, + newConversationInputsRef: { current: {} } as unknown as RefObject>, + handleNewConversationInputsChange: vi.fn(), + inputsForms: [], + handleNewConversation: vi.fn(), + handleStartChat: vi.fn(), + handleChangeConversation: vi.fn(), + handlePinConversation: vi.fn(), + handleUnpinConversation: vi.fn(), + conversationDeleting: false, + handleDeleteConversation: vi.fn(), + conversationRenaming: false, + handleRenameConversation: vi.fn(), + handleNewConversationCompleted: vi.fn(), + newConversationId: '', + chatShouldReloadKey: 'test-reload-key', + handleFeedback: vi.fn(), + currentChatInstanceRef: { current: { handleStop: vi.fn() } }, + sidebarCollapseState: false, + handleSidebarCollapse: vi.fn(), + clearChatList: false, + setClearChatList: vi.fn(), + isResponding: false, + setIsResponding: vi.fn(), + currentConversationInputs: {}, + setCurrentConversationInputs: vi.fn(), + allInputsHidden: false, + initUserVariables: {}, +} + +describe('Base Chat Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc) + vi.mocked(useChatWithHistory).mockReturnValue(defaultHookReturn) + renderHook(() => useThemeContext()).result.current.buildTheme() + }) + + // Chat-with-history shell integration across layout, responsive shell, and theme setup. + describe('Chat With History Shell', () => { + it('builds theme, updates the document title, and expands the collapsed desktop sidebar on hover', async () => { + const themeBuilder = renderHook(() => useThemeContext()).result.current + const { container } = render() + + const titles = screen.getAllByText('Test Chat') + expect(titles.length).toBeGreaterThan(0) + expect(useDocumentTitle).toHaveBeenCalledWith('Test Chat') + + await waitFor(() => { + expect(themeBuilder.theme.primaryColor).toBe('blue') + expect(themeBuilder.theme.chatColorThemeInverted).toBe(false) + }) + + vi.mocked(useChatWithHistory).mockReturnValue({ + ...defaultHookReturn, + sidebarCollapseState: true, + }) + + const { container: collapsedContainer } = render() + const hoverArea = collapsedContainer.querySelector('.absolute.top-0.z-20') + + expect(container.querySelector('.chat-history-shell')).toBeInTheDocument() + expect(hoverArea).toBeInTheDocument() + + if (hoverArea) { + fireEvent.mouseEnter(hoverArea) + expect(hoverArea).toHaveClass('left-0') + + fireEvent.mouseLeave(hoverArea) + expect(hoverArea).toHaveClass('left-[-248px]') + } + }) + + it('falls back to the mobile loading shell when site metadata is unavailable', () => { + vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile) + vi.mocked(useChatWithHistory).mockReturnValue({ + ...defaultHookReturn, + appData: null, + appChatListDataLoading: true, + }) + + const { container } = render() + + expect(useDocumentTitle).toHaveBeenCalledWith('Chat') + expect(screen.getByRole('status')).toBeInTheDocument() + expect(container.querySelector('.mobile-chat-shell')).toBeInTheDocument() + expect(container.querySelector('.rounded-t-2xl')).toBeInTheDocument() + expect(container.querySelector('.rounded-2xl')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/base/file-uploader-flow.test.tsx b/web/__tests__/base/file-uploader-flow.test.tsx new file mode 100644 index 0000000000..81dccedfe5 --- /dev/null +++ b/web/__tests__/base/file-uploader-flow.test.tsx @@ -0,0 +1,106 @@ +import type { FileUpload } from '@/app/components/base/features/types' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FileUploaderInAttachmentWrapper from '@/app/components/base/file-uploader/file-uploader-in-attachment' +import FileUploaderInChatInput from '@/app/components/base/file-uploader/file-uploader-in-chat-input' +import { FileContextProvider } from '@/app/components/base/file-uploader/store' +import { TransferMethod } from '@/types/app' + +const mockUploadRemoteFileInfo = vi.fn() + +vi.mock('@/next/navigation', () => ({ + useParams: () => ({}), +})) + +vi.mock('@/service/common', () => ({ + uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['document'], + allowed_file_extensions: [], + allowed_file_upload_methods: [TransferMethod.remote_url], + number_limits: 5, + preview_config: { + enabled: false, + mode: 'current_page', + file_type_list: [], + }, + ...overrides, +} as FileUpload) + +const renderChatInput = (fileConfig: FileUpload, readonly = false) => { + return render( + + + , + ) +} + +describe('Base File Uploader Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUploadRemoteFileInfo.mockResolvedValue({ + id: 'remote-file-1', + mime_type: 'application/pdf', + size: 2048, + name: 'guide.pdf', + url: 'https://cdn.example.com/guide.pdf', + }) + }) + + it('uploads a remote file from the attachment wrapper and pushes the updated file list to consumers', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: /fileUploader\.pasteFileLink/i })) + await user.type(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i), 'https://example.com/guide.pdf') + await user.click(screen.getByRole('button', { name: /operation\.ok/i })) + + await waitFor(() => { + expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/guide.pdf', false) + }) + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ + name: 'https://example.com/guide.pdf', + uploadedId: 'remote-file-1', + url: 'https://cdn.example.com/guide.pdf', + progress: 100, + }), + ]) + }) + + expect(screen.getByText('https://example.com/guide.pdf')).toBeInTheDocument() + }) + + it('opens the link picker from chat input and keeps the trigger disabled in readonly mode', async () => { + const user = userEvent.setup() + const fileConfig = createFileConfig() + + const { unmount } = renderChatInput(fileConfig) + + const activeTrigger = screen.getByRole('button') + expect(activeTrigger).toBeEnabled() + + await user.click(activeTrigger) + expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i)).toBeInTheDocument() + expect(screen.queryByText(/fileUploader\.uploadFromComputer/i)).not.toBeInTheDocument() + + unmount() + renderChatInput(fileConfig, true) + + expect(screen.getByRole('button')).toBeDisabled() + }) +}) diff --git a/web/__tests__/base/form-demo-flow.test.tsx b/web/__tests__/base/form-demo-flow.test.tsx new file mode 100644 index 0000000000..afb36528c0 --- /dev/null +++ b/web/__tests__/base/form-demo-flow.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DemoForm from '@/app/components/base/form/form-scenarios/demo' + +describe('Base Form Demo Flow', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reveals contact fields and submits the composed form values through the shared form actions', async () => { + const user = userEvent.setup() + render() + + expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: /^name$/i }), 'Alice') + await user.type(screen.getByRole('textbox', { name: /^surname$/i }), 'Smith') + await user.click(screen.getByText(/i accept the terms and conditions/i)) + + expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: /^email$/i }), 'alice@example.com') + + const preferredMethodLabel = screen.getByText('Preferred Contact Method') + const preferredMethodField = preferredMethodLabel.parentElement?.parentElement + expect(preferredMethodField).toBeTruthy() + + await user.click(within(preferredMethodField as HTMLElement).getByText('Email')) + await user.click(screen.getByText('Whatsapp')) + + const submitButton = screen.getByRole('button', { name: /operation\.submit/i }) + expect(submitButton).toBeEnabled() + await user.click(submitButton) + + await waitFor(() => { + expect(consoleLogSpy).toHaveBeenCalledWith('Form submitted:', expect.objectContaining({ + name: 'Alice', + surname: 'Smith', + isAcceptingTerms: true, + contact: expect.objectContaining({ + email: 'alice@example.com', + preferredContactMethod: 'whatsapp', + }), + })) + }) + }) + + it('removes the nested contact section again when the name field is cleared', async () => { + const user = userEvent.setup() + render() + + const nameInput = screen.getByRole('textbox', { name: /^name$/i }) + await user.type(nameInput, 'Alice') + expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument() + + await user.clear(nameInput) + + await waitFor(() => { + expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx new file mode 100644 index 0000000000..6295d2dc00 --- /dev/null +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -0,0 +1,151 @@ +import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { DataSourceNotionWorkspace } from '@/models/common' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NotionPageSelector from '@/app/components/base/notion-page-selector/base' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' + +const mockInvalidPreImportNotionPages = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +const mockUsePreImportNotionPages = vi.fn() + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ + index, + size: 28, + start: index * 28, + })), + getTotalSize: () => count * 28 + 16, + }), +})) + +vi.mock('@/service/knowledge/use-import', () => ({ + usePreImportNotionPages: (params: { datasetId: string, credentialId: string }) => mockUsePreImportNotionPages(params), + useInvalidPreImportNotionPages: () => mockInvalidPreImportNotionPages, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => + selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), +})) + +const buildCredential = (id: string, name: string, workspaceName: string): DataSourceCredential => ({ + id, + name, + type: CredentialTypeEnum.OAUTH2, + is_default: false, + avatar_url: '', + credential: { + workspace_icon: '', + workspace_name: workspaceName, + }, +}) + +const credentials: DataSourceCredential[] = [ + buildCredential('c1', 'Cred 1', 'Workspace 1'), + buildCredential('c2', 'Cred 2', 'Workspace 2'), +] + +const workspacePagesByCredential: Record = { + c1: [ + { + workspace_id: 'w1', + workspace_icon: '', + workspace_name: 'Workspace 1', + pages: [ + { page_id: 'root-1', page_name: 'Root 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, + { page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1', page_icon: null, type: 'page', is_bound: false }, + { page_id: 'bound-1', page_name: 'Bound 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: true }, + ], + }, + ], + c2: [ + { + workspace_id: 'w2', + workspace_icon: '', + workspace_name: 'Workspace 2', + pages: [ + { page_id: 'external-1', page_name: 'External 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false }, + ], + }, + ], +} + +describe('Base Notion Page Selector Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUsePreImportNotionPages.mockImplementation(({ credentialId }: { credentialId: string }) => ({ + data: { + notion_info: workspacePagesByCredential[credentialId] ?? workspacePagesByCredential.c1, + }, + isFetching: false, + isError: false, + })) + }) + + it('selects a page tree, filters through search, clears search, and previews the current page', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const onPreview = vi.fn() + + render( + , + ) + + await user.click(screen.getByTestId('checkbox-notion-page-checkbox-root-1')) + + expect(onSelect).toHaveBeenLastCalledWith(expect.arrayContaining([ + expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }), + expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }), + ])) + + await user.type(screen.getByTestId('notion-search-input'), 'missing-page') + expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() + + await user.click(screen.getByTestId('notion-search-input-clear')) + expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument() + + await user.click(screen.getByTestId('notion-page-preview-root-1')) + expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' })) + }) + + it('switches workspace credentials and opens the configuration entry point', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const onSelectCredential = vi.fn() + + render( + , + ) + + expect(onSelectCredential).toHaveBeenCalledWith('c1') + + await user.click(screen.getByTestId('notion-credential-selector-btn')) + await user.click(screen.getByTestId('notion-credential-item-c2')) + + expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' }) + expect(onSelect).toHaveBeenCalledWith([]) + + await waitFor(() => { + expect(onSelectCredential).toHaveBeenLastCalledWith('c2') + expect(screen.getByTestId('notion-page-name-external-1')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' })) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) + }) +}) diff --git a/web/__tests__/base/prompt-editor-flow.test.tsx b/web/__tests__/base/prompt-editor-flow.test.tsx new file mode 100644 index 0000000000..5fa96e6ee2 --- /dev/null +++ b/web/__tests__/base/prompt-editor-flow.test.tsx @@ -0,0 +1,191 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { ComponentProps } from 'react' +import type { EventEmitterValue } from '@/context/event-emitter' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { getNearestEditorFromDOMNode } from 'lexical' +import { useEffect } from 'react' +import PromptEditor from '@/app/components/base/prompt-editor' +import { + UPDATE_DATASETS_EVENT_EMITTER, + UPDATE_HISTORY_EVENT_EMITTER, +} from '@/app/components/base/prompt-editor/constants' +import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' + +type Captures = { + eventEmitter: EventEmitter | null + events: EventEmitterValue[] +} + +const EventProbe = ({ captures }: { captures: Captures }) => { + const { eventEmitter } = useEventEmitterContextContext() + + useEffect(() => { + captures.eventEmitter = eventEmitter + }, [captures, eventEmitter]) + + eventEmitter?.useSubscription((value) => { + captures.events.push(value) + }) + + return +} + +const PromptEditorHarness = ({ + captures, + ...props +}: ComponentProps & { captures: Captures }) => ( + + + + +) + +describe('Base Prompt Editor Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Real prompt editor integration should emit block updates and transform editor updates into text output. + describe('Editor Shell', () => { + it('should render with the real editor, emit dataset/history events, and convert update events into text changes', async () => { + const captures: Captures = { eventEmitter: null, events: [] } + const onChange = vi.fn() + const onFocus = vi.fn() + const onBlur = vi.fn() + const user = userEvent.setup() + + const { rerender, container } = render( + , + ) + + expect(screen.getByText('Type prompt')).toBeInTheDocument() + + await waitFor(() => { + expect(captures.eventEmitter).not.toBeNull() + }) + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + expect(editable).toBeInTheDocument() + + await user.click(editable) + await waitFor(() => { + expect(onFocus).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByRole('button', { name: 'outside' })) + await waitFor(() => { + expect(onBlur).toHaveBeenCalledTimes(1) + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'editor-1', + payload: 'first line\nsecond line', + }) + }) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('first line\nsecond line') + }) + + expect(captures.events).toContainEqual({ + type: UPDATE_DATASETS_EVENT_EMITTER, + payload: [{ id: 'ds-1', name: 'Dataset One', type: 'dataset' }], + }) + expect(captures.events).toContainEqual({ + type: UPDATE_HISTORY_EVENT_EMITTER, + payload: { user: 'user-role', assistant: 'assistant-role' }, + }) + + rerender( + , + ) + + await waitFor(() => { + expect(captures.events).toContainEqual({ + type: UPDATE_DATASETS_EVENT_EMITTER, + payload: [{ id: 'ds-2', name: 'Dataset Two', type: 'dataset' }], + }) + }) + expect(captures.events).toContainEqual({ + type: UPDATE_HISTORY_EVENT_EMITTER, + payload: { user: 'user-next', assistant: 'assistant-next' }, + }) + }) + + it('should tolerate updates without onChange and rethrow lexical runtime errors through the configured handler', async () => { + const captures: Captures = { eventEmitter: null, events: [] } + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { container } = render( + , + ) + + await waitFor(() => { + expect(captures.eventEmitter).not.toBeNull() + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'editor-2', + payload: 'silent update', + }) + }) + + const editable = container.querySelector('[contenteditable="false"]') as HTMLElement + const editor = getNearestEditorFromDOMNode(editable) + + expect(editable).toBeInTheDocument() + expect(editor).not.toBeNull() + expect(screen.getByRole('textbox')).toHaveTextContent('silent update') + + expect(() => { + act(() => { + editor?.update(() => { + throw new Error('prompt-editor boom') + }) + }) + }).toThrow('prompt-editor boom') + + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/web/__tests__/custom/custom-page-flow.test.tsx b/web/__tests__/custom/custom-page-flow.test.tsx new file mode 100644 index 0000000000..6eb5ccadb9 --- /dev/null +++ b/web/__tests__/custom/custom-page-flow.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' +import { Plan } from '@/app/components/billing/type' +import CustomPage from '@/app/components/custom/custom-page' +import useWebAppBrand from '@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand' + +const mockSetShowPricingModal = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), +})) + +vi.mock('@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand', () => ({ + __esModule: true, + default: vi.fn(), +})) + +const { useProviderContext } = await import('@/context/provider-context') + +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseWebAppBrand = vi.mocked(useWebAppBrand) + +const createBrandState = (overrides: Partial> = {}): ReturnType => ({ + fileId: '', + imgKey: 1, + uploadProgress: 0, + uploading: false, + webappLogo: 'https://example.com/logo.png', + webappBrandRemoved: false, + uploadDisabled: false, + workspaceLogo: 'https://example.com/workspace-logo.png', + isCurrentWorkspaceManager: true, + isSandbox: false, + handleApply: vi.fn(), + handleCancel: vi.fn(), + handleChange: vi.fn(), + handleRestore: vi.fn(), + handleSwitch: vi.fn(), + ...overrides, +}) + +const setProviderPlan = (planType: Plan, enableBilling = true) => { + mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ + enableBilling, + plan: { + ...defaultPlan, + type: planType, + }, + })) +} + +describe('Custom Page Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + setProviderPlan(Plan.professional) + mockUseWebAppBrand.mockReturnValue(createBrandState()) + }) + + it('shows the billing upgrade banner for sandbox workspaces and opens pricing modal', () => { + setProviderPlan(Plan.sandbox) + + render() + + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('billing.upgradeBtn.encourageShort')) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('renders the branding controls and the sales contact footer for paid workspaces', () => { + const hookState = createBrandState({ + fileId: 'pending-logo', + }) + mockUseWebAppBrand.mockReturnValue(hookState) + + render() + + const contactLink = screen.getByText('custom.customize.contactUs').closest('a') + expect(contactLink).toHaveAttribute('href', contactSalesUrl) + + fireEvent.click(screen.getByRole('switch')) + fireEvent.click(screen.getByRole('button', { name: 'custom.restore' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'custom.apply' })) + + expect(hookState.handleSwitch).toHaveBeenCalledWith(true) + expect(hookState.handleRestore).toHaveBeenCalledTimes(1) + expect(hookState.handleCancel).toHaveBeenCalledTimes(1) + expect(hookState.handleApply).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx new file mode 100644 index 0000000000..6a645c7a43 --- /dev/null +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -0,0 +1,182 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Plan } from '@/app/components/billing/type' +import AccountDropdown from '@/app/components/header/account-dropdown' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' + +const { + mockPush, + mockLogout, + mockResetUser, + mockSetShowAccountSettingModal, +} = vi.hoisted(() => ({ + mockPush: vi.fn(), + mockLogout: vi.fn(), + mockResetUser: vi.fn(), + mockSetShowAccountSettingModal: vi.fn(), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, version?: string }) => { + if (options?.version) + return `${options.ns}.${key}:${options.version}` + return options?.ns ? `${options.ns}.${key}` : key + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { + name: 'Ada Lovelace', + email: 'ada@example.com', + avatar_url: '', + }, + langGeniusVersionInfo: { + current_version: '1.0.0', + latest_version: '1.1.0', + release_notes: 'https://example.com/releases/1.1.0', + }, + isCurrentWorkspaceOwner: false, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + isEducationAccount: false, + plan: { + type: Plan.professional, + }, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector?: (state: Record) => unknown) => { + const state = { + systemFeatures: { + branding: { + enabled: false, + workspace_logo: null, + }, + }, + } + return selector ? selector(state) : state + }, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +vi.mock('@/service/use-common', () => ({ + useLogout: () => ({ + mutateAsync: mockLogout, + }), +})) + +vi.mock('@/app/components/base/amplitude/utils', () => ({ + resetUser: mockResetUser, +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string + children?: React.ReactNode + } & Record) => ( + + {children} + + ), +})) + +const renderAccountDropdown = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +describe('Header Account Dropdown Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + repo: { stars: 123456 }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + localStorage.clear() + }) + + it('opens account actions, fetches github stars, and opens the settings and about flows', async () => { + renderAccountDropdown() + + fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) + + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.getByText('ada@example.com')).toBeInTheDocument() + expect(await screen.findByText('123,456')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.userProfile.settings')) + + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.MEMBERS, + }) + + fireEvent.click(screen.getByText('common.userProfile.about')) + + await waitFor(() => { + expect(screen.getByText(/Version/)).toBeInTheDocument() + expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument() + }) + }) + + it('logs out, resets cached user markers, and redirects to signin', async () => { + localStorage.setItem('setup_status', 'done') + localStorage.setItem('education-reverify-prev-expire-at', '1') + localStorage.setItem('education-reverify-has-noticed', '1') + localStorage.setItem('education-expired-has-noticed', '1') + + renderAccountDropdown() + + fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) + fireEvent.click(screen.getByText('common.userProfile.logout')) + + await waitFor(() => { + expect(mockLogout).toHaveBeenCalledTimes(1) + expect(mockResetUser).toHaveBeenCalledTimes(1) + expect(mockPush).toHaveBeenCalledWith('/signin') + }) + + expect(localStorage.getItem('setup_status')).toBeNull() + expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull() + expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull() + expect(localStorage.getItem('education-expired-has-noticed')).toBeNull() + }) +}) diff --git a/web/__tests__/header/nav-flow.test.tsx b/web/__tests__/header/nav-flow.test.tsx new file mode 100644 index 0000000000..05955a6c83 --- /dev/null +++ b/web/__tests__/header/nav-flow.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Nav from '@/app/components/header/nav' +import { AppModeEnum } from '@/types/app' + +const mockPush = vi.fn() +const mockSetAppDetail = vi.fn() +const mockOnCreate = vi.fn() +const mockOnLoadMore = vi.fn() + +let mockSelectedSegment = 'app' +let mockIsCurrentWorkspaceEditor = true + +vi.mock('@headlessui/react', () => { + type MenuContextValue = { + open: boolean + setOpen: React.Dispatch> + } + const MenuContext = React.createContext(null) + + const Menu = ({ + children, + }: { + children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) + }) => { + const [open, setOpen] = React.useState(false) + const value = React.useMemo(() => ({ open, setOpen }), [open]) + + return ( + + {typeof children === 'function' ? children({ open }) : children} + + ) + } + + const MenuButton = ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes) => { + const context = React.useContext(MenuContext) + + return ( + + ) + } + + const MenuItems = ({ + as: Component = 'div', + children, + ...props + }: { + as?: React.ElementType + children: React.ReactNode + } & Record) => { + const context = React.useContext(MenuContext) + if (!context?.open) + return null + + return {children} + } + + const MenuItem = ({ + as: Component = 'div', + children, + ...props + }: { + as?: React.ElementType + children: React.ReactNode + } & Record) => {children} + + return { + Menu, + MenuButton, + MenuItems, + MenuItem, + Transition: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useSelectedLayoutSegment: () => mockSelectedSegment, + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + }: { + href: string + children?: React.ReactNode + }) => {children}, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: () => mockSetAppDetail, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + }), +})) + +const navigationItems = [ + { + id: 'app-1', + name: 'Alpha', + link: '/app/app-1/configuration', + icon_type: 'emoji' as const, + icon: '🤖', + icon_background: '#FFEAD5', + icon_url: null, + mode: AppModeEnum.CHAT, + }, + { + id: 'app-2', + name: 'Bravo', + link: '/app/app-2/workflow', + icon_type: 'emoji' as const, + icon: '⚙️', + icon_background: '#E0F2FE', + icon_url: null, + mode: AppModeEnum.WORKFLOW, + }, +] + +const curNav = { + id: 'app-1', + name: 'Alpha', + icon_type: 'emoji' as const, + icon: '🤖', + icon_background: '#FFEAD5', + icon_url: null, + mode: AppModeEnum.CHAT, +} + +const renderNav = (nav = curNav) => { + return render( +
} + marketplace={
marketplace view
} + />, + { searchParams }, + ) +} + +describe('Plugin Page Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchManifestFromMarketPlace.mockResolvedValue({ + data: { + plugin: { + org: 'langgenius', + name: 'plugin-demo', + }, + version: { + version: '1.0.0', + }, + }, + }) + }) + + it('switches from installed plugins to marketplace and syncs the active tab into the URL', async () => { + const { onUrlUpdate } = renderPluginPage() + + expect(screen.getByTestId('plugins-view')).toBeInTheDocument() + expect(screen.queryByTestId('marketplace-view')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('tab-item-discover')) + + await waitFor(() => { + expect(screen.getByTestId('marketplace-view')).toBeInTheDocument() + }) + + const tabUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(tabUpdate.searchParams.get('tab')).toBe('discover') + }) + + it('hydrates marketplace installation from query params and clears the install state when closed', async () => { + const { onUrlUpdate } = renderPluginPage('?package-ids=%5B%22langgenius%2Fplugin-demo%22%5D') + + await waitFor(() => { + expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith('langgenius%2Fplugin-demo') + expect(screen.getByTestId('install-from-marketplace-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'close-install-modal' })) + + await waitFor(() => { + const clearUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(clearUpdate.searchParams.has('package-ids')).toBe(false) + }) + }) +}) diff --git a/web/__tests__/share/text-generation-mode-flow.test.tsx b/web/__tests__/share/text-generation-mode-flow.test.tsx new file mode 100644 index 0000000000..0d4307bca0 --- /dev/null +++ b/web/__tests__/share/text-generation-mode-flow.test.tsx @@ -0,0 +1,155 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TextGeneration from '@/app/components/share/text-generation' + +const useSearchParamsMock = vi.fn(() => new URLSearchParams()) +const mockUseTextGenerationAppState = vi.fn() +const mockUseTextGenerationBatch = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => useSearchParamsMock(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => 'pc', + MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, +})) + +vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-app-state', () => ({ + useTextGenerationAppState: (...args: unknown[]) => mockUseTextGenerationAppState(...args), +})) + +vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-batch', () => ({ + useTextGenerationBatch: (...args: unknown[]) => mockUseTextGenerationBatch(...args), +})) + +vi.mock('@/app/components/share/text-generation/text-generation-sidebar', () => ({ + default: ({ + currentTab, + onTabChange, + }: { + currentTab: string + onTabChange: (tab: string) => void + }) => ( +
+ {currentTab} + + +
+ ), +})) + +vi.mock('@/app/components/share/text-generation/text-generation-result-panel', () => ({ + default: ({ + isCallBatchAPI, + resultExisted, + }: { + isCallBatchAPI: boolean + resultExisted: boolean + }) => ( +
+ ), +})) + +const createReadyAppState = () => ({ + accessMode: 'public', + appId: 'app-123', + appSourceType: 'published', + customConfig: { + remove_webapp_brand: false, + replace_webapp_logo: '', + }, + handleRemoveSavedMessage: vi.fn(), + handleSaveMessage: vi.fn(), + moreLikeThisConfig: { + enabled: true, + }, + promptConfig: { + user_input_form: [], + }, + savedMessages: [], + siteInfo: { + title: 'Text Generation', + }, + systemFeatures: { + branding: { + enabled: false, + workspace_logo: null, + }, + }, + textToSpeechConfig: { + enabled: true, + }, + visionConfig: null, +}) + +const createBatchState = () => ({ + allFailedTaskList: [], + allSuccessTaskList: [], + allTaskList: [], + allTasksRun: false, + controlRetry: 0, + exportRes: vi.fn(), + handleCompleted: vi.fn(), + handleRetryAllFailedTask: vi.fn(), + handleRunBatch: vi.fn(), + isCallBatchAPI: false, + noPendingTask: true, + resetBatchExecution: vi.fn(), + setIsCallBatchAPI: vi.fn(), + showTaskList: false, +}) + +describe('Text Generation Mode Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + useSearchParamsMock.mockReturnValue(new URLSearchParams()) + mockUseTextGenerationAppState.mockReturnValue(createReadyAppState()) + mockUseTextGenerationBatch.mockReturnValue(createBatchState()) + }) + + it('shows the loading state before app metadata is ready', () => { + mockUseTextGenerationAppState.mockReturnValue({ + ...createReadyAppState(), + appId: '', + promptConfig: null, + siteInfo: null, + }) + + render() + + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + }) + + it('hydrates the initial tab from the mode query parameter and lets the sidebar switch it', () => { + useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=batch')) + + render() + + expect(screen.getByTestId('current-tab')).toHaveTextContent('batch') + + fireEvent.click(screen.getByRole('button', { name: 'switch-to-create' })) + + expect(screen.getByTestId('current-tab')).toHaveTextContent('create') + }) + + it('falls back to create mode for unsupported query values', () => { + useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=unsupported')) + + render() + + expect(screen.getByTestId('current-tab')).toHaveTextContent('create') + expect(screen.getByTestId('text-generation-result-panel')).toHaveAttribute('data-batch', 'false') + }) +}) diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx new file mode 100644 index 0000000000..5b6ba8a64b --- /dev/null +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -0,0 +1,205 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ProviderList from '@/app/components/tools/provider-list' +import { CollectionType } from '@/app/components/tools/types' +import { renderWithNuqs } from '@/test/nuqs-testing' + +const mockInvalidateInstalledPluginList = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ + systemFeatures: { + enable_marketplace: true, + }, + }), +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + getTagLabel: (name: string) => name, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ + data: [ + { + id: 'builtin-plugin', + name: 'plugin-tool', + author: 'Dify', + description: { en_US: 'Plugin Tool' }, + icon: 'icon-plugin', + label: { en_US: 'Plugin Tool' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['search'], + plugin_id: 'langgenius/plugin-tool', + }, + { + id: 'builtin-basic', + name: 'basic-tool', + author: 'Dify', + description: { en_US: 'Basic Tool' }, + icon: 'icon-basic', + label: { en_US: 'Basic Tool' }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: ['utility'], + }, + ], + refetch: vi.fn(), + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({ + data: enabled + ? { + plugins: [{ + plugin_id: 'langgenius/plugin-tool', + declaration: { + category: 'tool', + }, + }], + } + : null, + }), + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +vi.mock('@/app/components/tools/labels/filter', () => ({ + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ payload, className }: { payload: { name: string }, className?: string }) => ( +
+ {payload.name} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/card-more-info', () => ({ + default: ({ tags }: { tags: string[] }) =>
{tags.join(',')}
, +})) + +vi.mock('@/app/components/tools/provider/detail', () => ({ + default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => ( +
+ {collection.name} + +
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ + default: ({ + detail, + onHide, + onUpdate, + }: { + detail?: { plugin_id: string } + onHide: () => void + onUpdate: () => void + }) => detail + ? ( +
+ {detail.plugin_id} + + +
+ ) + : null, +})) + +vi.mock('@/app/components/tools/provider/empty', () => ({ + default: () =>
workflow empty
, +})) + +vi.mock('@/app/components/plugins/marketplace/empty', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('@/app/components/tools/marketplace', () => ({ + default: ({ + isMarketplaceArrowVisible, + showMarketplacePanel, + }: { + isMarketplaceArrowVisible: boolean + showMarketplacePanel: () => void + }) => ( + + ), +})) + +vi.mock('@/app/components/tools/marketplace/hooks', () => ({ + useMarketplace: () => ({ + handleScroll: vi.fn(), + }), +})) + +vi.mock('@/app/components/tools/mcp', () => ({ + default: ({ searchText }: { searchText: string }) =>
{searchText}
, +})) + +const renderProviderList = (searchParams = '') => { + return renderWithNuqs(, { searchParams }) +} + +describe('Tool Provider List Shell Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + Element.prototype.scrollTo = vi.fn() + }) + + it('opens a plugin-backed provider detail panel and invalidates installed plugins on update', async () => { + renderProviderList('?category=builtin') + + fireEvent.click(screen.getByTestId('tool-card-plugin-tool')) + + await waitFor(() => { + expect(screen.getByTestId('tool-plugin-detail-panel')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'update-plugin-detail' })) + expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'close-plugin-detail' })) + + await waitFor(() => { + expect(screen.queryByTestId('tool-plugin-detail-panel')).not.toBeInTheDocument() + }) + }) + + it('scrolls to the marketplace section and syncs workflow tab selection into the URL', async () => { + const { onUrlUpdate } = renderProviderList('?category=builtin') + + fireEvent.click(screen.getByTestId('marketplace-arrow')) + expect(Element.prototype.scrollTo).toHaveBeenCalled() + + fireEvent.click(screen.getByTestId('tab-item-workflow')) + + await waitFor(() => { + expect(screen.getByTestId('workflow-empty')).toBeInTheDocument() + }) + + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.get('category')).toBe('workflow') + }) +}) diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx index ccd2dd53cc..d462ca6449 100644 --- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx +++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx @@ -2,7 +2,7 @@ import type { Area } from 'react-easy-crop' import type { OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput' -import type { AvatarProps } from '@/app/components/base/avatar' +import type { AvatarProps } from '@/app/components/base/ui/avatar' import type { ImageFile } from '@/types/app' import { RiDeleteBin5Line, RiPencilLine } from '@remixicon/react' import * as React from 'react' @@ -10,10 +10,10 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ImageInput from '@/app/components/base/app-icon-picker/ImageInput' import getCroppedImg from '@/app/components/base/app-icon-picker/utils' -import { Avatar } from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks' +import { Avatar } from '@/app/components/base/ui/avatar' import { Dialog, DialogContent } from '@/app/components/base/ui/dialog' import { toast } from '@/app/components/base/ui/toast' import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 36a510cf63..b81c96df74 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -6,9 +6,9 @@ import { import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' -import { Avatar } from '@/app/components/base/avatar' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import PremiumBadge from '@/app/components/base/premium-badge' +import { Avatar } from '@/app/components/base/ui/avatar' import { useProviderContext } from '@/context/provider-context' import { useRouter } from '@/next/navigation' import { useLogout, useUserProfile } from '@/service/use-common' diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 2c849fd542..b0b7f557a4 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -10,9 +10,9 @@ import { import * as React from 'react' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Avatar } from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' +import { Avatar } from '@/app/components/base/ui/avatar' import { toast } from '@/app/components/base/ui/toast' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index 1df6fa79b7..dcc9f9c98e 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -18,6 +18,7 @@ const mockInvalidDatasetDetail = vi.fn() const mockExportPipeline = vi.fn() const mockCheckIsUsedInApp = vi.fn() const mockDeleteDataset = vi.fn() +const mockToast = vi.fn() const createDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', @@ -111,6 +112,10 @@ vi.mock('@/service/datasets', () => ({ deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), })) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + vi.mock('@/app/components/datasets/rename-modal', () => ({ default: ({ show, @@ -225,4 +230,49 @@ describe('Dropdown callback coverage', () => { expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() }) }) + + it('should show the used-by-app confirmation copy when the dataset is referenced by apps', async () => { + const user = userEvent.setup() + mockCheckIsUsedInApp.mockResolvedValueOnce({ is_using: true }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(screen.getByText('dataset.datasetUsedByApp')).toBeInTheDocument() + }) + }) + + it('should surface an export failure toast when pipeline export fails', async () => { + const user = userEvent.setup() + mockExportPipeline.mockRejectedValueOnce(new Error('export failed')) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith('app.exportFailed', { type: 'error' }) + }) + }) + + it('should surface the backend message when checking app usage fails', async () => { + const user = userEvent.setup() + mockCheckIsUsedInApp.mockRejectedValueOnce({ + json: vi.fn().mockResolvedValue({ message: 'check failed' }), + }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith('check failed', { type: 'error' }) + }) + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index a1e275d731..bb85e00c14 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { DataSet } from '@/models/datasets' import { RiEditLine } from '@remixicon/react' -import { render, screen, waitFor } from '@testing-library/react' +import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { @@ -218,6 +218,31 @@ describe('MenuItem', () => { // Assert expect(handleClick).toHaveBeenCalledTimes(1) }) + + it('should stop propagation before invoking the handler', () => { + const parentClick = vi.fn() + const handleClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByText('Edit')) + + expect(handleClick).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + + it('should not crash when no click handler is provided', () => { + render() + + const event = createEvent.click(screen.getByText('Edit')) + fireEvent(screen.getByText('Edit'), event) + + expect(event.defaultPrevented).toBe(true) + }) }) }) @@ -265,6 +290,47 @@ describe('Menu', () => { expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() }) }) + + describe('Interactions', () => { + it('should invoke the rename callback when edit is clicked', async () => { + const user = userEvent.setup() + const openRenameModal = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('common.operation.edit')) + + expect(openRenameModal).toHaveBeenCalledTimes(1) + }) + + it('should invoke export and delete callbacks from their menu items', async () => { + const user = userEvent.setup() + const handleExportPipeline = vi.fn() + const detectIsUsedByApp = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + await user.click(screen.getByText('common.operation.delete')) + + expect(handleExportPipeline).toHaveBeenCalledTimes(1) + expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) + }) + }) }) describe('Dropdown', () => { diff --git a/web/app/components/app/annotation/view-annotation-modal/__tests__/hit-history-no-data.spec.tsx b/web/app/components/app/annotation/view-annotation-modal/__tests__/hit-history-no-data.spec.tsx new file mode 100644 index 0000000000..33a38106d0 --- /dev/null +++ b/web/app/components/app/annotation/view-annotation-modal/__tests__/hit-history-no-data.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react' +import HitHistoryNoData from '../hit-history-no-data' + +describe('HitHistoryNoData', () => { + it('should render the empty history message', () => { + render() + + expect(screen.getByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx new file mode 100644 index 0000000000..5c7d2f2dc0 --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx @@ -0,0 +1,32 @@ +/* eslint-disable ts/no-explicit-any */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import AccessControlDialog from '../access-control-dialog' + +describe('AccessControlDialog', () => { + it('should render dialog content when visible', () => { + render( + +
Dialog Content
+
, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Dialog Content')).toBeInTheDocument() + }) + + it('should trigger onClose when clicking the close control', async () => { + const onClose = vi.fn() + render( + +
Dialog Content
+
, + ) + + const closeButton = document.body.querySelector('div.absolute.right-5.top-5') as HTMLElement + fireEvent.click(closeButton) + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx new file mode 100644 index 0000000000..b1a862a13c --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import useAccessControlStore from '@/context/access-control-store' +import { AccessMode } from '@/models/access-control' +import AccessControlItem from '../access-control-item' + +describe('AccessControlItem', () => { + beforeEach(() => { + vi.clearAllMocks() + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.PUBLIC, + selectedGroupsForBreadcrumb: [], + }) + }) + + it('should update current menu when selecting a different access type', () => { + render( + + Organization Only + , + ) + + const option = screen.getByText('Organization Only').parentElement as HTMLElement + fireEvent.click(option) + + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + }) + + it('should keep the selected state for the active access type', () => { + useAccessControlStore.setState({ + currentMenu: AccessMode.ORGANIZATION, + }) + + render( + + Organization Only + , + ) + + const option = screen.getByText('Organization Only').parentElement as HTMLElement + expect(option).toHaveClass('border-components-option-card-option-selected-border') + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx new file mode 100644 index 0000000000..725b121d30 --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -0,0 +1,130 @@ +import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import useAccessControlStore from '@/context/access-control-store' +import { SubjectType } from '@/models/access-control' +import AddMemberOrGroupDialog from '../add-member-or-group-pop' + +const mockUseSearchForWhiteListCandidates = vi.fn() +const intersectionObserverMocks = vi.hoisted(() => ({ + callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (value: { userProfile: { email: string } }) => T) => selector({ + userProfile: { + email: 'member@example.com', + }, + }), +})) + +vi.mock('@/service/access-control', () => ({ + useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), +})) + +const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, +} as AccessControlGroup) + +const createMember = (overrides: Partial = {}): AccessControlAccount => ({ + id: 'member-1', + name: 'Member One', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + ...overrides, +} as AccessControlAccount) + +describe('AddMemberOrGroupDialog', () => { + const baseGroup = createGroup() + const baseMember = createMember() + const groupSubject: Subject = { + subjectId: baseGroup.id, + subjectType: SubjectType.GROUP, + groupData: baseGroup, + } as Subject + const memberSubject: Subject = { + subjectId: baseMember.id, + subjectType: SubjectType.ACCOUNT, + accountData: baseMember, + } as Subject + + beforeAll(() => { + class MockIntersectionObserver { + constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) { + intersectionObserverMocks.callback = callback + } + + observe = vi.fn(() => undefined) + disconnect = vi.fn(() => undefined) + unobserve = vi.fn(() => undefined) + } + + // @ts-expect-error test DOM typings do not guarantee IntersectionObserver here + globalThis.IntersectionObserver = MockIntersectionObserver + }) + + beforeEach(() => { + vi.clearAllMocks() + useAccessControlStore.setState({ + appId: 'app-1', + specificGroups: [], + specificMembers: [], + currentMenu: SubjectType.GROUP as never, + selectedGroupsForBreadcrumb: [], + }) + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { + pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }], + }, + }) + }) + + it('should open the search popover and display candidates', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + + expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument() + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByText(baseMember.name)).toBeInTheDocument() + }) + + it('should allow expanding groups and selecting members', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) + + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + + const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement + fireEvent.click(memberCheckbox) + + expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) + }) + + it('should show the empty state when no candidates are returned', async () => { + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [] }, + }) + + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + + expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f2fa09f98a --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -0,0 +1,121 @@ +/* eslint-disable ts/no-explicit-any */ +import type { App } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { toast } from '@/app/components/base/ui/toast' +import useAccessControlStore from '@/context/access-control-store' +import { AccessMode } from '@/models/access-control' +import AccessControl from '../index' + +const mockMutateAsync = vi.fn() +const mockUseUpdateAccessMode = vi.fn(() => ({ + isPending: false, + mutateAsync: mockMutateAsync, +})) +const mockUseAppWhiteListSubjects = vi.fn() +const mockUseSearchForWhiteListCandidates = vi.fn() +let mockWebappAuth = { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, +} + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({ + systemFeatures: { + webapp_auth: mockWebappAuth, + }, + }), +})) + +vi.mock('@/service/access-control', () => ({ + useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), + useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useUpdateAccessMode: () => mockUseUpdateAccessMode(), +})) + +describe('AccessControl', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWebappAuth = { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, + } + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [], + }) + mockMutateAsync.mockResolvedValue(undefined) + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: false, + data: { + groups: [], + members: [], + }, + }) + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [] }, + }) + }) + + it('should initialize menu from the app and update access mode on confirm', async () => { + const onClose = vi.fn() + const onConfirm = vi.fn() + const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') + const app = { + id: 'app-id-1', + access_mode: AccessMode.PUBLIC, + } as App + + render( + , + ) + + await waitFor(() => { + expect(useAccessControlStore.getState().appId).toBe(app.id) + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC) + }) + + fireEvent.click(screen.getByText('common.operation.confirm')) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + appId: app.id, + accessMode: AccessMode.PUBLIC, + }) + expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + }) + + it('should show the external-members option when SSO tip is visible', () => { + mockWebappAuth = { + enabled: false, + allow_sso: false, + allow_email_password_login: false, + allow_email_code_login: false, + } + + render( + , + ) + + expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() + expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx new file mode 100644 index 0000000000..7b198c4e66 --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -0,0 +1,97 @@ +import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import useAccessControlStore from '@/context/access-control-store' +import { AccessMode } from '@/models/access-control' +import SpecificGroupsOrMembers from '../specific-groups-or-members' + +const mockUseAppWhiteListSubjects = vi.fn() + +vi.mock('@/service/access-control', () => ({ + useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), +})) + +vi.mock('../add-member-or-group-pop', () => ({ + default: () =>
, +})) + +const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, +} as AccessControlGroup) + +const createMember = (overrides: Partial = {}): AccessControlAccount => ({ + id: 'member-1', + name: 'Member One', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + ...overrides, +} as AccessControlAccount) + +describe('SpecificGroupsOrMembers', () => { + const baseGroup = createGroup() + const baseMember = createMember() + + beforeEach(() => { + vi.clearAllMocks() + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [], + }) + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: false, + data: { + groups: [baseGroup], + members: [baseMember], + }, + }) + }) + + it('should render the collapsed row when not in specific mode', () => { + useAccessControlStore.setState({ + currentMenu: AccessMode.ORGANIZATION, + }) + + render() + + expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() + expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() + }) + + it('should show loading while whitelist subjects are pending', async () => { + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: true, + data: undefined, + }) + + const { container } = render() + + await waitFor(() => { + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + }) + + it('should render fetched groups and members and support removal', async () => { + useAccessControlStore.setState({ appId: 'app-1' }) + + render() + + await waitFor(() => { + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByText(baseMember.name)).toBeInTheDocument() + }) + + const groupRemove = screen.getByText(baseGroup.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + fireEvent.click(groupRemove) + expect(useAccessControlStore.getState().specificGroups).toEqual([]) + + const memberRemove = screen.getByText(baseMember.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + fireEvent.click(memberRemove) + expect(useAccessControlStore.getState().specificMembers).toEqual([]) + }) +}) diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index d7e48f2d1f..b25fb94191 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -5,12 +5,12 @@ import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixi import { useDebounce } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Avatar } from '@/app/components/base/ui/avatar' import { useSelector } from '@/context/app-context' import { SubjectType } from '@/models/access-control' import { useSearchForWhiteListCandidates } from '@/service/access-control' import { cn } from '@/utils/classnames' import useAccessControlStore from '../../../../context/access-control-store' -import { Avatar } from '../../base/avatar' import Button from '../../base/button' import Checkbox from '../../base/checkbox' import Input from '../../base/input' diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx index 2c0e4b2694..8f4e71c8d2 100644 --- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -3,10 +3,10 @@ import type { AccessControlAccount, AccessControlGroup } from '@/models/access-c import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { Avatar } from '@/app/components/base/ui/avatar' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects } from '@/service/access-control' import useAccessControlStore from '../../../../context/access-control-store' -import { Avatar } from '../../base/avatar' import Loading from '../../base/loading' import Tooltip from '../../base/tooltip' import AddMemberOrGroupDialog from './add-member-or-group-pop' diff --git a/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx new file mode 100644 index 0000000000..0b492a06ed --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import InputTypeIcon from '../input-type-icon' + +const mockInputVarTypeIcon = vi.fn(({ type, className }: { type: InputVarType, className?: string }) => ( +
+)) + +vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({ + default: (props: { type: InputVarType, className?: string }) => mockInputVarTypeIcon(props), +})) + +describe('InputTypeIcon', () => { + it('should map string variables to the workflow text-input icon', () => { + render() + + expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.textInput) + expect(screen.getByTestId('input-var-type-icon')).toHaveClass('marker') + }) + + it('should map select variables to the workflow select icon', () => { + render() + + expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.select) + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx new file mode 100644 index 0000000000..e84189ddff --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx @@ -0,0 +1,19 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import ModalFoot from '../modal-foot' + +describe('ModalFoot', () => { + it('should trigger cancel and confirm callbacks', () => { + const onCancel = vi.fn() + const onConfirm = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx new file mode 100644 index 0000000000..611aaa1c8a --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx @@ -0,0 +1,16 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import SelectVarType from '../select-var-type' + +describe('SelectVarType', () => { + it('should open the menu and return the selected variable type', () => { + const onChange = vi.fn() + + render() + + fireEvent.click(screen.getByText('common.operation.add')) + fireEvent.click(screen.getByText('appDebug.variableConfig.checkbox')) + + expect(onChange).toHaveBeenCalledWith('checkbox') + expect(screen.queryByText('appDebug.variableConfig.checkbox')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx new file mode 100644 index 0000000000..aae00bb2b7 --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import VarItem from '../var-item' + +describe('VarItem', () => { + it('should render variable metadata and allow editing', () => { + const onEdit = vi.fn() + const onRemove = vi.fn() + const { container } = render( + , + ) + + expect(screen.getByTitle('api_key · API Key')).toBeInTheDocument() + expect(screen.getByText('required')).toBeInTheDocument() + + const editButton = container.querySelector('.mr-1.flex.h-6.w-6') as HTMLElement + fireEvent.click(editButton) + + expect(onEdit).toHaveBeenCalledTimes(1) + }) + + it('should call remove when clicking the delete action', () => { + const onRemove = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('var-item-delete-btn')) + + expect(onRemove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/config.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/config.spec.ts new file mode 100644 index 0000000000..efa2f793ae --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/config.spec.ts @@ -0,0 +1,23 @@ +import { jsonConfigPlaceHolder } from '../config' + +describe('config modal placeholder config', () => { + it('should contain a valid object schema example', () => { + const parsed = JSON.parse(jsonConfigPlaceHolder) as { + type: string + properties: { + foo: { type: string } + bar: { + type: string + properties: { + sub: { type: string } + } + } + } + } + + expect(parsed.type).toBe('object') + expect(parsed.properties.foo.type).toBe('string') + expect(parsed.properties.bar.type).toBe('object') + expect(parsed.properties.bar.properties.sub.type).toBe('number') + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx new file mode 100644 index 0000000000..454e5dd444 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react' +import Field from '../field' + +describe('ConfigModal Field', () => { + it('should render the title and children', () => { + render( + + + , + ) + + expect(screen.getByText('Field title')).toBeInTheDocument() + expect(screen.getByLabelText('field-input')).toBeInTheDocument() + }) + + it('should render the optional hint when requested', () => { + render( + + + , + ) + + expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx b/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx new file mode 100644 index 0000000000..2cb919b6db --- /dev/null +++ b/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx @@ -0,0 +1,74 @@ +import type { FeatureStoreState } from '@/app/components/base/features/store' +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Resolution, TransferMethod } from '@/types/app' +import ParamConfigContent from '../param-config-content' + +const mockUseFeatures = vi.fn() +const mockUseFeaturesStore = vi.fn() +const mockSetFeatures = vi.fn() + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector), + useFeaturesStore: () => mockUseFeaturesStore(), +})) + +const setupFeatureStore = (fileOverrides: Partial = {}) => { + const file: FileUpload = { + enabled: true, + allowed_file_types: [], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + image: { + enabled: true, + detail: Resolution.low, + number_limits: 3, + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }, + ...fileOverrides, + } + const featureStoreState = { + features: { file }, + setFeatures: mockSetFeatures, + showFeaturesModal: false, + setShowFeaturesModal: vi.fn(), + } as unknown as FeatureStoreState + + mockUseFeatures.mockImplementation(selector => selector(featureStoreState)) + mockUseFeaturesStore.mockReturnValue({ + getState: () => featureStoreState, + }) +} + +const getUpdatedFile = () => { + expect(mockSetFeatures).toHaveBeenCalled() + return mockSetFeatures.mock.calls.at(-1)?.[0].file as FileUpload +} + +describe('ParamConfigContent', () => { + beforeEach(() => { + vi.clearAllMocks() + setupFeatureStore() + }) + + it('should update the image resolution', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('appDebug.vision.visionSettings.high')) + + expect(getUpdatedFile().image?.detail).toBe(Resolution.high) + }) + + it('should update upload methods and upload limit', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('appDebug.vision.visionSettings.localUpload')) + expect(getUpdatedFile().allowed_file_upload_methods).toEqual([TransferMethod.local_file]) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '5' } }) + expect(getUpdatedFile().number_limits).toBe(5) + }) +}) diff --git a/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx b/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx new file mode 100644 index 0000000000..617f14629e --- /dev/null +++ b/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx @@ -0,0 +1,58 @@ +import type { FeatureStoreState } from '@/app/components/base/features/store' +import type { FileUpload } from '@/app/components/base/features/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Resolution, TransferMethod } from '@/types/app' +import ParamConfig from '../param-config' + +const mockUseFeatures = vi.fn() +const mockUseFeaturesStore = vi.fn() + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector), + useFeaturesStore: () => mockUseFeaturesStore(), +})) + +const setupFeatureStore = (fileOverrides: Partial = {}) => { + const file: FileUpload = { + enabled: true, + allowed_file_types: [], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + image: { + enabled: true, + detail: Resolution.low, + number_limits: 3, + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }, + ...fileOverrides, + } + const featureStoreState = { + features: { file }, + setFeatures: vi.fn(), + showFeaturesModal: false, + setShowFeaturesModal: vi.fn(), + } as unknown as FeatureStoreState + mockUseFeatures.mockImplementation(selector => selector(featureStoreState)) + mockUseFeaturesStore.mockReturnValue({ + getState: () => featureStoreState, + }) +} + +describe('ParamConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + setupFeatureStore() + }) + + it('should toggle the settings panel when clicking the trigger', async () => { + const user = userEvent.setup() + render() + + expect(screen.queryByText('appDebug.vision.visionSettings.title')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'appDebug.voice.settings' })) + + expect(await screen.findByText('appDebug.vision.visionSettings.title')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx new file mode 100644 index 0000000000..bc380d35d0 --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx @@ -0,0 +1,22 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import PromptToast from '../prompt-toast' + +describe('PromptToast', () => { + it('should render the note title and markdown message', () => { + render() + + expect(screen.getByText('appDebug.generate.optimizationNote')).toBeInTheDocument() + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) + + it('should collapse and expand the markdown content', () => { + const { container } = render() + + const toggle = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(toggle) + expect(screen.queryByTestId('markdown-body')).not.toBeInTheDocument() + + fireEvent.click(toggle) + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx new file mode 100644 index 0000000000..cbdbda8480 --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react' +import ResPlaceholder from '../res-placeholder' + +describe('ResPlaceholder', () => { + it('should render the placeholder copy', () => { + render() + + expect(screen.getByText('appDebug.generate.newNoDataLine1')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts b/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts new file mode 100644 index 0000000000..374a75cd7b --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts @@ -0,0 +1,39 @@ +import type { GenRes } from '@/service/debug' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import useGenData from '../use-gen-data' + +describe('useGenData', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('should start with an empty version list', () => { + const { result } = renderHook(() => useGenData({ storageKey: 'prompt' })) + + expect(result.current.versions).toEqual([]) + expect(result.current.currentVersionIndex).toBe(0) + expect(result.current.current).toBeUndefined() + }) + + it('should append versions and keep the latest one selected', () => { + const versionOne = { modified: 'first version' } as GenRes + const versionTwo = { modified: 'second version' } as GenRes + const { result } = renderHook(() => useGenData({ storageKey: 'prompt' })) + + act(() => { + result.current.addVersion(versionOne) + }) + + expect(result.current.versions).toEqual([versionOne]) + expect(result.current.current).toEqual(versionOne) + + act(() => { + result.current.addVersion(versionTwo) + }) + + expect(result.current.versions).toEqual([versionOne, versionTwo]) + expect(result.current.currentVersionIndex).toBe(1) + expect(result.current.current).toEqual(versionTwo) + }) +}) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx index 80bb26a052..61eb8f2ae8 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx @@ -90,7 +90,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({ }, })) -vi.mock('@/app/components/base/avatar', () => ({ +vi.mock('@/app/components/base/ui/avatar', () => ({ Avatar: ({ name }: { name: string }) =>
{name}
, })) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx new file mode 100644 index 0000000000..5608f4c5a2 --- /dev/null +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react' +import { useDebugWithMultipleModelContext } from '../context' +import { DebugWithMultipleModelContextProvider } from '../context-provider' + +const ContextConsumer = () => { + const value = useDebugWithMultipleModelContext() + return ( +
+
{value.multipleModelConfigs.length}
+ + +
{String(value.checkCanSend?.())}
+
+ ) +} + +describe('DebugWithMultipleModelContextProvider', () => { + it('should expose the provided context value to descendants', () => { + const onMultipleModelConfigsChange = vi.fn() + const onDebugWithMultipleModelChange = vi.fn() + const checkCanSend = vi.fn(() => true) + const multipleModelConfigs = [{ model: 'gpt-4o' }] as unknown as [] + + render( + + + , + ) + + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('true')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx index e957fc24c4..56345890ff 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx @@ -7,11 +7,11 @@ import { useCallback, useMemo, } from 'react' -import { Avatar } from '@/app/components/base/avatar' import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { getLastAnswer } from '@/app/components/base/chat/utils' import { useFeatures } from '@/app/components/base/features/hooks' +import { Avatar } from '@/app/components/base/ui/avatar' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useAppContext } from '@/context/app-context' import { useDebugConfigurationContext } from '@/context/debug-configuration' diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index 84ff8b5ede..a9f9f1116b 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -3,11 +3,11 @@ import type { ChatConfig, ChatItem, OnSend } from '@/app/components/base/chat/ty import type { FileEntity } from '@/app/components/base/file-uploader/types' import { memo, useCallback, useImperativeHandle, useMemo } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' -import { Avatar } from '@/app/components/base/avatar' import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' import { useFeatures } from '@/app/components/base/features/hooks' +import { Avatar } from '@/app/components/base/ui/avatar' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useAppContext } from '@/context/app-context' import { useDebugConfigurationContext } from '@/context/debug-configuration' diff --git a/web/app/components/app/configuration/hooks/use-configuration-utils.ts b/web/app/components/app/configuration/hooks/use-configuration-utils.ts index bbb4bde82e..6911fdcd01 100644 --- a/web/app/components/app/configuration/hooks/use-configuration-utils.ts +++ b/web/app/components/app/configuration/hooks/use-configuration-utils.ts @@ -359,7 +359,7 @@ export const loadConfigurationState = async ({ }, visionConfig: backendModelConfig.file_upload?.image, citationConfig: backendModelConfig.retriever_resource || { enabled: false }, - chatPromptConfig: backendModelConfig.chat_prompt_config && backendModelConfig.chat_prompt_config.prompt.length > 0 + chatPromptConfig: backendModelConfig.chat_prompt_config && backendModelConfig.chat_prompt_config.prompt?.length > 0 ? backendModelConfig.chat_prompt_config : clone(DEFAULT_CHAT_PROMPT_CONFIG), introduction: backendModelConfig.opening_statement, diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index a5d801f13f..25512ed689 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -1,14 +1,13 @@ /* eslint-disable ts/no-explicit-any */ import type { ReactNode } from 'react' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import ConversationList from '../list' const mockFetchChatMessages = vi.fn() const mockUpdateLogMessageFeedbacks = vi.fn() const mockUpdateLogMessageAnnotations = vi.fn() -const mockPush = vi.fn() -const mockReplace = vi.fn() const mockOnRefresh = vi.fn() const mockSetCurrentLogItem = vi.fn() const mockSetShowPromptLogModal = vi.fn() @@ -17,7 +16,6 @@ const mockSetShowMessageLogModal = vi.fn() const mockCompletionRefetch = vi.fn() const mockDelAnnotation = vi.fn() -let mockSearchParams = new URLSearchParams() let mockChatConversationDetail: Record | undefined let mockCompletionConversationDetail: Record | undefined let mockShowMessageLogModal = false @@ -53,18 +51,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({ }, })) -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - replace: mockReplace, - }), - usePathname: () => '/apps/app-1/logs', - useSearchParams: () => ({ - get: (key: string) => mockSearchParams.get(key), - toString: () => mockSearchParams.toString(), - }), -})) - vi.mock('@/service/use-log', () => ({ useChatConversationDetail: () => ({ data: mockChatConversationDetail, @@ -256,10 +242,28 @@ const createChatMessage = (id: string, overrides: Record = {}) ...overrides, }) +const renderConversationList = ({ + appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } as any, + logs = createLogs() as any, + searchParams = '?page=2', +}: { + appDetail?: any + logs?: any + searchParams?: string +} = {}) => { + return renderWithNuqs( + , + { searchParams }, + ) +} + describe('ConversationList', () => { beforeEach(() => { vi.clearAllMocks() - mockSearchParams = new URLSearchParams('page=2') mockChatConversationDetail = undefined mockCompletionConversationDetail = undefined mockShowMessageLogModal = false @@ -273,34 +277,29 @@ describe('ConversationList', () => { }) }) - it('should render chat rows and push the conversation id into the url when a row is clicked', () => { - render( - , - ) + it('should render chat rows and push the conversation id into the url when a row is clicked', async () => { + const { onUrlUpdate } = renderConversationList() expect(screen.getByText('hello world')).toBeInTheDocument() expect(screen.getAllByText('formatted-1710000000')).toHaveLength(2) fireEvent.click(screen.getByText('hello world')) - expect(mockPush).toHaveBeenCalledWith('/apps/app-1/logs?page=2&conversation_id=conversation-1', { scroll: false }) - expect(screen.getByTestId('drawer')).toBeInTheDocument() + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalled() + expect(screen.getByTestId('drawer')).toBeInTheDocument() + }) + + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(update.searchParams.get('page')).toBe('2') + expect(update.searchParams.get('conversation_id')).toBe('conversation-1') + expect(update.options.history).toBe('push') }) - it('should close the drawer, refresh, and clear modal flags', () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') - - render( - , - ) + it('should close the drawer, refresh, and clear modal flags', async () => { + const { onUrlUpdate } = renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) fireEvent.click(screen.getByText('close-drawer')) @@ -308,11 +307,18 @@ describe('ConversationList', () => { expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false) expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) - expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false }) + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalled() + }) + + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(update.searchParams.get('page')).toBe('2') + expect(update.searchParams.has('conversation_id')).toBe(false) + expect(update.options.history).toBe('replace') }) it('should render chat conversation details and submit feedback from the chat panel', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -355,13 +361,9 @@ describe('ConversationList', () => { mockShowMessageLogModal = true mockCurrentLogItem = { id: 'log-1' } - render( - , - ) + renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(mockFetchChatMessages).toHaveBeenCalledWith({ @@ -396,7 +398,6 @@ describe('ConversationList', () => { }) it('should render completion details and refetch after feedback updates', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockCompletionConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -423,13 +424,11 @@ describe('ConversationList', () => { mockShowPromptLogModal = true mockCurrentLogItem = { id: 'log-2' } - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any, + logs: createCompletionLogs() as any, + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(screen.getByTestId('text-generation')).toBeInTheDocument() @@ -454,64 +453,61 @@ describe('ConversationList', () => { }) it('should render chatflow status cells and feedback counters for advanced chat logs', () => { - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT } as any, + logs: { + data: [ + { + id: 'conversation-pending', + name: 'Pending row', + from_account_name: 'user-a', + read_at: 1710000001, + message_count: 3, + status_count: { paused: 1, success: 0, failed: 0, partial_success: 0 }, + user_feedback_stats: { like: 2, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 1 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-success', + name: 'Success row', + from_account_name: 'user-b', + read_at: 1710000001, + message_count: 4, + status_count: { paused: 0, success: 4, failed: 0, partial_success: 0 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-partial', + name: 'Partial row', + from_account_name: 'user-c', + read_at: 1710000001, + message_count: 5, + status_count: { paused: 0, success: 3, failed: 0, partial_success: 1 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-failure', + name: 'Failure row', + from_account_name: 'user-d', + read_at: 1710000001, + message_count: 1, + status_count: { paused: 0, success: 0, failed: 2, partial_success: 0 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + ], + } as any, + }) expect(screen.getByText('Pending')).toBeInTheDocument() expect(screen.getByText('Success')).toBeInTheDocument() @@ -522,7 +518,6 @@ describe('ConversationList', () => { }) it('should support annotation changes, modal closing, and paginated scroll loading in the detail drawer', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -568,13 +563,9 @@ describe('ConversationList', () => { has_more: false, }) - render( - , - ) + renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(screen.getByTestId('chat-panel')).toBeInTheDocument() @@ -609,7 +600,6 @@ describe('ConversationList', () => { }) it('should close the prompt log modal from completion detail drawers', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockCompletionConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -636,13 +626,11 @@ describe('ConversationList', () => { mockShowPromptLogModal = true mockCurrentLogItem = { id: 'log-2' } - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any, + logs: createCompletionLogs() as any, + searchParams: '?page=2&conversation_id=conversation-1', + }) expect(await screen.findByTestId('prompt-log-modal')).toBeInTheDocument() diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 79323d34ab..01621e0d2a 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -13,6 +13,7 @@ import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import { noop } from 'es-toolkit/function' +import { parseAsString, useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -33,7 +34,6 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' -import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' import { AppSourceType } from '@/service/share' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' @@ -46,7 +46,6 @@ import { applyAnnotationEdited, applyAnnotationRemoved, buildChatThreadState, - buildConversationUrl, getCompletionMessageFiles, getConversationRowValues, getDetailVarList, @@ -674,10 +673,7 @@ const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string } const ConversationList: FC = ({ logs, appDetail, onRefresh }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined + const [conversationIdInUrl, setConversationIdInUrl] = useQueryState('conversation_id', parseAsString) const media = useBreakpoints() const isMobile = media === MediaType.mobile @@ -697,8 +693,6 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id - const buildUrlWithConversation = useCallback((conversationId?: string) => buildConversationUrl(pathname, searchParams.toString(), conversationId), [pathname, searchParams]) - const handleRowClick = useCallback((log: ConversationListItem) => { if (conversationIdInUrl === log.id) { if (!showDrawer) @@ -717,8 +711,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) if (currentConversation?.id !== log.id) setCurrentConversation(undefined) - router.push(buildUrlWithConversation(log.id), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, currentConversation, router, showDrawer]) + void setConversationIdInUrl(log.id, { history: 'push' }) + }, [conversationIdInUrl, currentConversation, setConversationIdInUrl, showDrawer]) const currentConversationId = currentConversation?.id @@ -755,7 +749,7 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation) pendingConversationCacheRef.current = undefined - }, [conversationIdInUrl, currentConversation, isChatMode, logs?.data, showDrawer]) + }, [conversationIdInUrl, currentConversation, currentConversationId, logs?.data, showDrawer]) const onCloseDrawer = useCallback(() => { onRefresh() @@ -769,8 +763,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) closingConversationIdRef.current = conversationIdInUrl ?? null if (conversationIdInUrl) - router.replace(buildUrlWithConversation(), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, onRefresh, router, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) + void setConversationIdInUrl(null, { history: 'replace' }) + }, [conversationIdInUrl, onRefresh, setConversationIdInUrl, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) // Annotated data needs to be highlighted const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => { diff --git a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx new file mode 100644 index 0000000000..9a818e0fd7 --- /dev/null +++ b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx @@ -0,0 +1,103 @@ +import type { AppDetailResponse } from '@/models/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { AppCardAccessControlSection, AppCardOperations, createAppCardOperations } from '../app-card-sections' + +describe('app-card-sections', () => { + const t = (key: string) => key + + it('should build operations with the expected disabled state', () => { + const onLaunch = vi.fn() + const operations = createAppCardOperations({ + operationKeys: ['launch', 'settings'], + t: t as never, + runningStatus: false, + triggerModeDisabled: false, + onLaunch, + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + expect(operations[0]).toMatchObject({ + key: 'launch', + disabled: true, + label: 'overview.appInfo.launch', + }) + expect(operations[1]).toMatchObject({ + key: 'settings', + disabled: false, + label: 'overview.appInfo.settings.entry', + }) + }) + + it('should render the access-control section and call onClick', () => { + const onClick = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('publishApp.notSet')) + + expect(screen.getByText('accessControlDialog.accessItems.specific')).toBeInTheDocument() + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should render operation buttons and execute enabled actions', () => { + const onLaunch = vi.fn() + const operations = createAppCardOperations({ + operationKeys: ['launch', 'embedded'], + t: t as never, + runningStatus: true, + triggerModeDisabled: false, + onLaunch, + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) + + expect(onLaunch).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument() + }) + + it('should keep customize available for web app cards that are not completion or workflow apps', () => { + const operations = createAppCardOperations({ + operationKeys: ['customize'], + t: t as never, + runningStatus: true, + triggerModeDisabled: false, + onLaunch: vi.fn(), + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + render( + , + ) + + expect(screen.getByText('overview.appInfo.customize.entry')).toBeInTheDocument() + expect(AppModeEnum.CHAT).toBe('chat') + }) +}) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts new file mode 100644 index 0000000000..fbfcdaf955 --- /dev/null +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -0,0 +1,107 @@ +import type { AppDetailResponse } from '@/models/app' +import { BlockEnum } from '@/app/components/workflow/types' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { basePath } from '@/utils/var' +import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils' + +describe('app-card-utils', () => { + const baseAppInfo = { + id: 'app-1', + mode: AppModeEnum.CHAT, + enable_site: true, + enable_api: false, + access_mode: AccessMode.PUBLIC, + api_base_url: 'https://api.example.com', + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } as AppDetailResponse + + it('should detect whether the workflow includes a start node', () => { + expect(hasWorkflowStartNode({ + graph: { + nodes: [{ data: { type: BlockEnum.Start } }], + }, + })).toBe(true) + + expect(hasWorkflowStartNode({ + graph: { + nodes: [{ data: { type: BlockEnum.Answer } }], + }, + })).toBe(false) + }) + + it('should build the display state for a published web app', () => { + const state = getAppCardDisplayState({ + appInfo: baseAppInfo, + cardType: 'webapp', + currentWorkflow: null, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceManager: true, + }) + + expect(state.isApp).toBe(true) + expect(state.appMode).toBe(AppModeEnum.CHAT) + expect(state.runningStatus).toBe(true) + expect(state.accessibleUrl).toBe(`https://example.com${basePath}/chat/token-1`) + }) + + it('should disable workflow cards without a graph or start node', () => { + const unpublishedState = getAppCardDisplayState({ + appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW }, + cardType: 'webapp', + currentWorkflow: null, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceManager: true, + }) + expect(unpublishedState.appUnpublished).toBe(true) + expect(unpublishedState.toggleDisabled).toBe(true) + + const missingStartState = getAppCardDisplayState({ + appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW }, + cardType: 'webapp', + currentWorkflow: { + graph: { + nodes: [{ data: { type: BlockEnum.Answer } }], + }, + }, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceManager: true, + }) + expect(missingStartState.missingStartNode).toBe(true) + expect(missingStartState.runningStatus).toBe(false) + }) + + it('should require specific access subjects only for the specific access mode', () => { + expect(isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.PUBLIC }, + { groups: [], members: [] }, + )).toBe(true) + + expect(isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + { groups: [], members: [] }, + )).toBe(false) + + expect(isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + { groups: [{ id: 'group-1' }], members: [] }, + )).toBe(true) + }) + + it('should derive operation keys for api and webapp cards', () => { + expect(getAppCardOperationKeys({ + cardType: 'api', + appMode: AppModeEnum.COMPLETION, + isCurrentWorkspaceEditor: true, + })).toEqual(['develop']) + + expect(getAppCardOperationKeys({ + cardType: 'webapp', + appMode: AppModeEnum.CHAT, + isCurrentWorkspaceEditor: false, + })).toEqual(['launch', 'embedded', 'customize']) + }) +}) diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index b66787a87d..1cbc9c69c6 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -75,12 +75,12 @@ const ACCESS_MODE_LABEL_MAP: Record = { const MaybeTooltip = ({ children, content, - popupClassName, + tooltipClassName, show = true, }: { children: ReactNode content?: ReactNode - popupClassName?: string + tooltipClassName?: string show?: boolean }) => { if (!show || !content) @@ -89,7 +89,7 @@ const MaybeTooltip = ({ return ( {children}
} /> - + {content} @@ -269,7 +269,7 @@ export const AppCardOperations = ({ >
diff --git a/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx new file mode 100644 index 0000000000..f43db2f5f9 --- /dev/null +++ b/web/app/components/apps/__tests__/app-card-skeleton.spec.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react' +import { AppCardSkeleton } from '../app-card-skeleton' + +describe('AppCardSkeleton', () => { + it('should render six skeleton cards by default', () => { + const { container } = render() + + expect(container.childElementCount).toBe(6) + expect(AppCardSkeleton.displayName).toBe('AppCardSkeleton') + }) + + it('should respect the custom skeleton count and card classes', () => { + const { container } = render() + + expect(container.childElementCount).toBe(2) + expect(container.firstElementChild).toHaveClass( + 'h-[160px]', + 'rounded-xl', + 'border-[0.5px]', + 'bg-components-card-bg', + 'p-4', + ) + }) +}) diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 720355d09f..9c3a7cc8f7 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -11,6 +11,7 @@ import AppIcon from '@/app/components/base/app-icon' import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form' import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions' import { Markdown } from '@/app/components/base/markdown' +import { Avatar } from '@/app/components/base/ui/avatar' import { InputVarType } from '@/app/components/workflow/types' import { AppSourceType, @@ -23,7 +24,6 @@ import { submitHumanInputForm as submitHumanInputFormService } from '@/service/w import { TransferMethod } from '@/types/app' import { cn } from '@/utils/classnames' import { formatBooleanInputs } from '@/utils/model-config' -import { Avatar } from '../../avatar' import Chat from '../chat' import { useChat } from '../chat/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '../utils' diff --git a/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx new file mode 100644 index 0000000000..36e7cec67d --- /dev/null +++ b/web/app/components/base/chat/chat/__tests__/chat-log-modals.spec.tsx @@ -0,0 +1,144 @@ +import type { IChatItem } from '../type' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' +import { fetchAgentLogDetail } from '@/service/log' +import ChatLogModals from '../chat-log-modals' + +vi.mock('@/service/log', () => ({ + fetchAgentLogDetail: vi.fn(), +})) + +describe('ChatLogModals', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ appDetail: { id: 'app-1' } as ReturnType['appDetail'] }) + }) + + // Modal visibility should follow the two booleans unless log modals are globally hidden. + describe('Rendering', () => { + it('should render real prompt and agent log modals when enabled', async () => { + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + render( + , + ) + + expect(screen.getByText('PROMPT LOG')).toBeInTheDocument() + expect(screen.getByText('Prompt body')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument() + }) + }) + + it('should render nothing when hideLogModal is true', () => { + render( + , + ) + + expect(screen.queryByText('PROMPT LOG')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).not.toBeInTheDocument() + }) + }) + + // Cancel actions should clear the current item and close only the targeted modal. + describe('User Interactions', () => { + it('should close the prompt log modal through the real close action', async () => { + const user = userEvent.setup() + const setCurrentLogItem = vi.fn() + const setShowPromptLogModal = vi.fn() + const setShowAgentLogModal = vi.fn() + + render( + , + ) + + await user.click(screen.getByTestId('close-btn-container')) + + expect(setCurrentLogItem).toHaveBeenCalled() + expect(setShowPromptLogModal).toHaveBeenCalledWith(false) + expect(setShowAgentLogModal).not.toHaveBeenCalled() + }) + + it('should close the agent log modal through the real close action', async () => { + const user = userEvent.setup() + const setCurrentLogItem = vi.fn() + const setShowPromptLogModal = vi.fn() + const setShowAgentLogModal = vi.fn() + vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + + render( + , + ) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument() + }) + await user.click(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling as HTMLElement) + + expect(setCurrentLogItem).toHaveBeenCalled() + expect(setShowAgentLogModal).toHaveBeenCalledWith(false) + expect(setShowPromptLogModal).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx b/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx new file mode 100644 index 0000000000..15da63e4d0 --- /dev/null +++ b/web/app/components/base/chat/chat/__tests__/use-chat-layout.spec.tsx @@ -0,0 +1,293 @@ +import type { ChatItem } from '../../types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' +import { useChatLayout } from '../use-chat-layout' + +type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void + +let capturedResizeCallbacks: ResizeCallback[] = [] +let disconnectSpy: ReturnType +let rafCallbacks: FrameRequestCallback[] = [] + +const makeChatItem = (overrides: Partial = {}): ChatItem => ({ + id: `item-${Math.random().toString(36).slice(2)}`, + content: 'Test content', + isAnswer: false, + ...overrides, +}) + +const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverEntry => ({ + borderBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + contentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + contentRect: new DOMRect(0, 0, inlineSize, blockSize), + devicePixelContentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize], + target: document.createElement('div'), +}) + +const assignMetric = (node: HTMLElement, key: 'clientWidth' | 'clientHeight' | 'scrollHeight', value: number) => { + Object.defineProperty(node, key, { + configurable: true, + value, + }) +} + +const LayoutHarness = ({ + chatList, + sidebarCollapseState, + attachRefs = true, +}: { + chatList: ChatItem[] + sidebarCollapseState?: boolean + attachRefs?: boolean +}) => { + const { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } = useChatLayout({ chatList, sidebarCollapseState }) + + return ( + <> +
{ + chatContainerRef.current = attachRefs ? node : null + if (node && attachRefs) { + assignMetric(node, 'clientWidth', 400) + assignMetric(node, 'clientHeight', 240) + assignMetric(node, 'scrollHeight', 640) + if (!node.dataset.metricsReady) { + node.scrollTop = 0 + node.dataset.metricsReady = 'true' + } + } + }} + > +
{ + chatContainerInnerRef.current = attachRefs ? node : null + if (node && attachRefs) + assignMetric(node, 'clientWidth', 360) + }} + /> +
+
{ + chatFooterRef.current = attachRefs ? node : null + }} + > +
{ + chatFooterInnerRef.current = attachRefs ? node : null + }} + /> +
+ {width} + + ) +} + +const flushAnimationFrames = () => { + const queuedCallbacks = [...rafCallbacks] + rafCallbacks = [] + queuedCallbacks.forEach(callback => callback(0)) +} + +describe('useChatLayout', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + capturedResizeCallbacks = [] + disconnectSpy = vi.fn() + rafCallbacks = [] + + Object.defineProperty(document.body, 'clientWidth', { + configurable: true, + value: 1024, + }) + + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + + vi.stubGlobal('ResizeObserver', class { + constructor(cb: ResizeCallback) { + capturedResizeCallbacks.push(cb) + } + + observe() { } + unobserve() { } + disconnect = disconnectSpy + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + // The hook should compute shell dimensions and auto-scroll when enough chat items exist. + describe('Layout Calculation', () => { + it('should auto-scroll and compute the chat shell widths on mount', () => { + const addSpy = vi.spyOn(window, 'addEventListener') + + render( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(screen.getByTestId('layout-width')).toHaveTextContent('600') + expect(screen.getByTestId('chat-footer').style.width).toBe('400px') + expect(screen.getByTestId('chat-footer-inner').style.width).toBe('360px') + expect((screen.getByTestId('chat-container') as HTMLDivElement).scrollTop).toBe(640) + expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + }) + }) + + // Resize observers should keep padding and widths in sync, then fully clean up on unmount. + describe('Resize Observers', () => { + it('should react to observer updates and disconnect both observers on unmount', () => { + const removeSpy = vi.spyOn(window, 'removeEventListener') + const { unmount } = render( + , + ) + + act(() => { + capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver) + }) + expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px') + + act(() => { + capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver) + }) + expect(screen.getByTestId('chat-footer').style.width).toBe('560px') + + unmount() + + expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + expect(disconnectSpy).toHaveBeenCalledTimes(2) + }) + + it('should respect manual scrolling until a new first message arrives and safely ignore missing refs', () => { + const { rerender } = render( + , + ) + + const container = screen.getByTestId('chat-container') as HTMLDivElement + + act(() => { + fireEvent.scroll(container) + flushAnimationFrames() + }) + + act(() => { + container.scrollTop = 10 + fireEvent.scroll(container) + }) + + rerender( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + act(() => { + container.scrollTop = 420 + fireEvent.scroll(container) + }) + + rerender( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(container.scrollTop).toBe(640) + + rerender( + , + ) + + act(() => { + fireEvent.scroll(container) + flushAnimationFrames() + }) + }) + + it('should keep the hook stable when the DOM refs are not attached', () => { + render( + , + ) + + act(() => { + flushAnimationFrames() + vi.runAllTimers() + }) + + expect(screen.getByTestId('layout-width')).toHaveTextContent('0') + expect(capturedResizeCallbacks).toHaveLength(0) + expect(screen.getByTestId('chat-footer').style.width).toBe('') + expect(screen.getByTestId('chat-footer-inner').style.width).toBe('') + }) + }) +}) diff --git a/web/app/components/base/chat/chat/chat-log-modals.tsx b/web/app/components/base/chat/chat/chat-log-modals.tsx new file mode 100644 index 0000000000..d1bf43b81c --- /dev/null +++ b/web/app/components/base/chat/chat/chat-log-modals.tsx @@ -0,0 +1,56 @@ +import type { FC } from 'react' +import type { IChatItem } from './type' +import AgentLogModal from '@/app/components/base/agent-log-modal' +import PromptLogModal from '@/app/components/base/prompt-log-modal' + +type ChatLogModalsProps = { + width: number + currentLogItem?: IChatItem + showPromptLogModal: boolean + showAgentLogModal: boolean + hideLogModal?: boolean + setCurrentLogItem: (item?: IChatItem) => void + setShowPromptLogModal: (showPromptLogModal: boolean) => void + setShowAgentLogModal: (showAgentLogModal: boolean) => void +} + +const ChatLogModals: FC = ({ + width, + currentLogItem, + showPromptLogModal, + showAgentLogModal, + hideLogModal, + setCurrentLogItem, + setShowPromptLogModal, + setShowAgentLogModal, +}) => { + if (hideLogModal) + return null + + return ( + <> + {showPromptLogModal && ( + { + setCurrentLogItem() + setShowPromptLogModal(false) + }} + /> + )} + {showAgentLogModal && ( + { + setCurrentLogItem() + setShowAgentLogModal(false) + }} + /> + )} + + ) +} + +export default ChatLogModals diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index ed44c8719d..f04169327f 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -13,26 +13,19 @@ import type { import type { InputForm } from './type' import type { Emoji } from '@/app/components/tools/types' import type { AppData } from '@/models/share' -import { debounce } from 'es-toolkit/compat' -import { - memo, - useCallback, - useEffect, - useRef, - useState, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' -import AgentLogModal from '@/app/components/base/agent-log-modal' import Button from '@/app/components/base/button' -import PromptLogModal from '@/app/components/base/prompt-log-modal' import { cn } from '@/utils/classnames' import Answer from './answer' import ChatInputArea from './chat-input-area' +import ChatLogModals from './chat-log-modals' import { ChatContextProvider } from './context-provider' import Question from './question' import TryToAsk from './try-to-ask' +import { useChatLayout } from './use-chat-layout' export type ChatProps = { isTryApp?: boolean @@ -133,128 +126,17 @@ const Chat: FC = ({ showAgentLogModal: state.showAgentLogModal, setShowAgentLogModal: state.setShowAgentLogModal, }))) - const [width, setWidth] = useState(0) - const chatContainerRef = useRef(null) - const chatContainerInnerRef = useRef(null) - const chatFooterRef = useRef(null) - const chatFooterInnerRef = useRef(null) - const userScrolledRef = useRef(false) - const isAutoScrollingRef = useRef(false) - - const handleScrollToBottom = useCallback(() => { - if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { - isAutoScrollingRef.current = true - chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight - - requestAnimationFrame(() => { - isAutoScrollingRef.current = false - }) - } - }, [chatList.length]) - - const handleWindowResize = useCallback(() => { - if (chatContainerRef.current) - setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8) - - if (chatContainerRef.current && chatFooterRef.current) - chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` - - if (chatContainerInnerRef.current && chatFooterInnerRef.current) - chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` - }, []) - - useEffect(() => { - handleScrollToBottom() - handleWindowResize() - }, [handleScrollToBottom, handleWindowResize]) - - useEffect(() => { - /* v8 ignore next - @preserve */ - if (chatContainerRef.current) { - requestAnimationFrame(() => { - handleScrollToBottom() - handleWindowResize() - }) - } + const { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } = useChatLayout({ + chatList, + sidebarCollapseState, }) - useEffect(() => { - const debouncedHandler = debounce(handleWindowResize, 200) - window.addEventListener('resize', debouncedHandler) - - return () => { - window.removeEventListener('resize', debouncedHandler) - debouncedHandler.cancel() - } - }, [handleWindowResize]) - - useEffect(() => { - /* v8 ignore next - @preserve */ - if (chatFooterRef.current && chatContainerRef.current) { - const resizeContainerObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { blockSize } = entry.borderBoxSize[0] - chatContainerRef.current!.style.paddingBottom = `${blockSize}px` - handleScrollToBottom() - } - }) - resizeContainerObserver.observe(chatFooterRef.current) - - const resizeFooterObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { inlineSize } = entry.borderBoxSize[0] - chatFooterRef.current!.style.width = `${inlineSize}px` - } - }) - resizeFooterObserver.observe(chatContainerRef.current) - - return () => { - resizeContainerObserver.disconnect() - resizeFooterObserver.disconnect() - } - } - }, [handleScrollToBottom]) - - useEffect(() => { - const setUserScrolled = () => { - const container = chatContainerRef.current - /* v8 ignore next 2 - @preserve */ - if (!container) - return - /* v8 ignore next 2 - @preserve */ - if (isAutoScrollingRef.current) - return - - const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop - const SCROLL_UP_THRESHOLD = 100 - - userScrolledRef.current = distanceToBottom > SCROLL_UP_THRESHOLD - } - - const container = chatContainerRef.current - /* v8 ignore next 2 - @preserve */ - if (!container) - return - - container.addEventListener('scroll', setUserScrolled) - return () => container.removeEventListener('scroll', setUserScrolled) - }, []) - - const prevFirstMessageIdRef = useRef(undefined) - useEffect(() => { - const firstMessageId = chatList[0]?.id - if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId)) - userScrolledRef.current = false - prevFirstMessageIdRef.current = firstMessageId - }, [chatList]) - - useEffect(() => { - if (!sidebarCollapseState) { - const timer = setTimeout(handleWindowResize, 200) - return () => clearTimeout(timer) - } - }, [handleWindowResize, sidebarCollapseState]) - const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend return ( @@ -279,7 +161,7 @@ const Chat: FC = ({
{chatNode}
= ({ !noStopResponding && isResponding && (
@@ -375,26 +257,16 @@ const Chat: FC = ({ }
- {showPromptLogModal && !hideLogModal && ( - { - setCurrentLogItem() - setShowPromptLogModal(false) - }} - /> - )} - {showAgentLogModal && !hideLogModal && ( - { - setCurrentLogItem() - setShowAgentLogModal(false) - }} - /> - )} +
) diff --git a/web/app/components/base/chat/chat/use-chat-layout.ts b/web/app/components/base/chat/chat/use-chat-layout.ts new file mode 100644 index 0000000000..41f622c523 --- /dev/null +++ b/web/app/components/base/chat/chat/use-chat-layout.ts @@ -0,0 +1,144 @@ +import type { ChatItem } from '../types' +import { debounce } from 'es-toolkit/compat' +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +type UseChatLayoutOptions = { + chatList: ChatItem[] + sidebarCollapseState?: boolean +} + +export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => { + const [width, setWidth] = useState(0) + const chatContainerRef = useRef(null) + const chatContainerInnerRef = useRef(null) + const chatFooterRef = useRef(null) + const chatFooterInnerRef = useRef(null) + const userScrolledRef = useRef(false) + const isAutoScrollingRef = useRef(false) + const prevFirstMessageIdRef = useRef(undefined) + + const handleScrollToBottom = useCallback(() => { + if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { + isAutoScrollingRef.current = true + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight + + requestAnimationFrame(() => { + isAutoScrollingRef.current = false + }) + } + }, [chatList.length]) + + const handleWindowResize = useCallback(() => { + if (chatContainerRef.current) + setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8) + + if (chatContainerRef.current && chatFooterRef.current) + chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` + + if (chatContainerInnerRef.current && chatFooterInnerRef.current) + chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` + }, []) + + useEffect(() => { + handleScrollToBottom() + const animationFrame = requestAnimationFrame(handleWindowResize) + + return () => { + cancelAnimationFrame(animationFrame) + } + }, [handleScrollToBottom, handleWindowResize]) + + useEffect(() => { + if (chatContainerRef.current) { + requestAnimationFrame(() => { + handleScrollToBottom() + handleWindowResize() + }) + } + }) + + useEffect(() => { + const debouncedHandler = debounce(handleWindowResize, 200) + window.addEventListener('resize', debouncedHandler) + + return () => { + window.removeEventListener('resize', debouncedHandler) + debouncedHandler.cancel() + } + }, [handleWindowResize]) + + useEffect(() => { + if (chatFooterRef.current && chatContainerRef.current) { + const resizeContainerObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { blockSize } = entry.borderBoxSize[0] + chatContainerRef.current!.style.paddingBottom = `${blockSize}px` + handleScrollToBottom() + } + }) + resizeContainerObserver.observe(chatFooterRef.current) + + const resizeFooterObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { inlineSize } = entry.borderBoxSize[0] + chatFooterRef.current!.style.width = `${inlineSize}px` + } + }) + resizeFooterObserver.observe(chatContainerRef.current) + + return () => { + resizeContainerObserver.disconnect() + resizeFooterObserver.disconnect() + } + } + }, [handleScrollToBottom]) + + useEffect(() => { + const setUserScrolled = () => { + const container = chatContainerRef.current + if (!container) + return + if (isAutoScrollingRef.current) + return + + const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop + const scrollUpThreshold = 100 + + userScrolledRef.current = distanceToBottom > scrollUpThreshold + } + + const container = chatContainerRef.current + if (!container) + return + + container.addEventListener('scroll', setUserScrolled) + return () => container.removeEventListener('scroll', setUserScrolled) + }, []) + + useEffect(() => { + const firstMessageId = chatList[0]?.id + if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId)) + userScrolledRef.current = false + prevFirstMessageIdRef.current = firstMessageId + }, [chatList]) + + useEffect(() => { + if (!sidebarCollapseState) { + const timer = setTimeout(handleWindowResize, 200) + return () => clearTimeout(timer) + } + }, [handleWindowResize, sidebarCollapseState]) + + return { + width, + chatContainerRef, + chatContainerInnerRef, + chatFooterRef, + chatFooterInnerRef, + } +} diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx index c518a9d078..451f566505 100644 --- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx +++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx @@ -12,6 +12,7 @@ import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested import InputsForm from '@/app/components/base/chat/embedded-chatbot/inputs-form' import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar' import { Markdown } from '@/app/components/base/markdown' +import { Avatar } from '@/app/components/base/ui/avatar' import { InputVarType } from '@/app/components/workflow/types' import { AppSourceType, @@ -23,7 +24,6 @@ import { import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { cn } from '@/utils/classnames' -import { Avatar } from '../../avatar' import Chat from '../chat' import { useChat } from '../chat/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '../utils' diff --git a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx index 322a9970af..8cc22693b6 100644 --- a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ const mockCopy = vi.fn() const mockReset = vi.fn() let mockCopied = false -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy: mockCopy, reset: mockReset, diff --git a/web/app/components/base/copy-feedback/index.tsx b/web/app/components/base/copy-feedback/index.tsx index 80b35eb3a8..5210066670 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -3,11 +3,11 @@ import { RiClipboardFill, RiClipboardLine, } from '@remixicon/react' -import { useClipboard } from 'foxact/use-clipboard' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Tooltip from '@/app/components/base/tooltip' +import { useClipboard } from '@/hooks/use-clipboard' import copyStyle from './style.module.css' type Props = { diff --git a/web/app/components/base/copy-icon/__tests__/index.spec.tsx b/web/app/components/base/copy-icon/__tests__/index.spec.tsx index 3db76ef606..1ce9e6dbf5 100644 --- a/web/app/components/base/copy-icon/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-icon/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ const copy = vi.fn() const reset = vi.fn() let copied = false -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy, reset, diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx index 78c0fcb8c3..15332592d0 100644 --- a/web/app/components/base/copy-icon/index.tsx +++ b/web/app/components/base/copy-icon/index.tsx @@ -1,7 +1,7 @@ 'use client' -import { useClipboard } from 'foxact/use-clipboard' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useClipboard } from '@/hooks/use-clipboard' import Tooltip from '../tooltip' type Props = { diff --git a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx index eb5b419d78..714c280008 100644 --- a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx @@ -27,7 +27,7 @@ describe('NumberInputField', () => { it('should update value when users click increment', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'common.operation.increment' })) + fireEvent.click(screen.getByRole('button', { name: 'Increment value' })) expect(mockField.handleChange).toHaveBeenCalledWith(3) }) diff --git a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx index 201c419444..33ebec5cbc 100644 --- a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx +++ b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ const mockCopy = vi.fn() let mockCopied = false const mockReset = vi.fn() -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy: mockCopy, copied: mockCopied, diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx index e85a7bd6f4..33db47baaa 100644 --- a/web/app/components/base/input-with-copy/index.tsx +++ b/web/app/components/base/input-with-copy/index.tsx @@ -1,8 +1,8 @@ 'use client' import type { InputProps } from '../input' -import { useClipboard } from 'foxact/use-clipboard' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useClipboard } from '@/hooks/use-clipboard' import { cn } from '@/utils/classnames' import ActionButton from '../action-button' import Tooltip from '../tooltip' diff --git a/web/app/components/base/markdown/streamdown-wrapper.tsx b/web/app/components/base/markdown/streamdown-wrapper.tsx index 46db301adb..e20898135b 100644 --- a/web/app/components/base/markdown/streamdown-wrapper.tsx +++ b/web/app/components/base/markdown/streamdown-wrapper.tsx @@ -16,7 +16,7 @@ import { ThinkBlock, VideoBlock, } from '@/app/components/base/markdown-blocks' -import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config' +import { ALLOW_INLINE_STYLES, ENABLE_SINGLE_DOLLAR_LATEX } from '@/config' import dynamic from '@/next/dynamic' import { customUrlTransform } from './markdown-utils' import 'katex/dist/katex.min.css' @@ -118,6 +118,11 @@ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList { // component validates names with `isSafeName()`, so remove it. const clobber = (defaultSanitizeSchema.clobber ?? []).filter(k => k !== 'name') + if (ALLOW_INLINE_STYLES) { + const globalAttrs = mergedAttributes['*'] ?? [] + mergedAttributes['*'] = [...globalAttrs, 'style'] + } + const customSchema: SanitizeSchema = { ...defaultSanitizeSchema, tagNames: [...tagNamesSet], diff --git a/web/app/components/base/modal/__tests__/index.spec.tsx b/web/app/components/base/modal/__tests__/index.spec.tsx index caf2b58053..4705d0defd 100644 --- a/web/app/components/base/modal/__tests__/index.spec.tsx +++ b/web/app/components/base/modal/__tests__/index.spec.tsx @@ -135,19 +135,6 @@ describe('Modal', () => { expect(container).toBeInTheDocument() }) - it('should apply highPriority z-index when highPriority is true', async () => { - await act(async () => { - render( - -
Content
-
, - ) - }) - - const dialog = document.querySelector('.z-1100') - expect(dialog).toBeInTheDocument() - }) - it('should apply overlayOpacity background when overlayOpacity is true', async () => { await act(async () => { render( diff --git a/web/app/components/base/modal/index.stories.tsx b/web/app/components/base/modal/index.stories.tsx index 91bb851f20..33d0366324 100644 --- a/web/app/components/base/modal/index.stories.tsx +++ b/web/app/components/base/modal/index.stories.tsx @@ -9,7 +9,7 @@ const meta = { layout: 'fullscreen', docs: { description: { - component: 'Lightweight modal wrapper with optional header/description, close icon, and high-priority stacking for dropdown overlays.', + component: 'Lightweight modal wrapper with optional header/description and close icon.', }, }, }, @@ -43,10 +43,6 @@ const meta = { control: 'boolean', description: 'Allows content to overflow the modal panel.', }, - highPriority: { - control: 'boolean', - description: 'Lifts the modal above other high z-index elements like dropdowns.', - }, onClose: { control: false, description: 'Callback invoked when the modal requests to close.', @@ -115,18 +111,17 @@ export const Default: Story = { render: args => , } -export const HighPriorityOverflow: Story = { +export const OverflowVisible: Story = { render: args => , args: { - highPriority: true, overflowVisible: true, - description: 'Demonstrates the modal configured to sit above dropdowns while letting the body content overflow.', + description: 'Demonstrates the modal configured to let the body content overflow.', className: 'max-w-[540px]', }, parameters: { docs: { description: { - story: 'Shows the modal with `highPriority` and `overflowVisible` enabled, useful when nested within complex surfaces.', + story: 'Shows the modal with `overflowVisible` enabled for content that needs to escape the panel bounds.', }, }, }, diff --git a/web/app/components/base/modal/index.tsx b/web/app/components/base/modal/index.tsx index 92a38268e4..8107718b29 100644 --- a/web/app/components/base/modal/index.tsx +++ b/web/app/components/base/modal/index.tsx @@ -20,7 +20,6 @@ type IModal = { children?: React.ReactNode closable?: boolean overflowVisible?: boolean - highPriority?: boolean // For modals that need to appear above dropdowns overlayOpacity?: boolean // For semi-transparent overlay instead of default clickOutsideNotClose?: boolean // Prevent closing when clicking outside modal } @@ -36,13 +35,12 @@ export default function Modal({ children, closable = false, overflowVisible = false, - highPriority = false, overlayOpacity = false, clickOutsideNotClose = false, }: IModal) { return ( - +
@@ -59,19 +57,19 @@ export default function Modal({ {!!title && ( {title} )} {!!description && ( -
+
{description}
)} {closable && ( -
+
= {}): NotionPageRow => ({ + page: { + page_id: 'page-1', + page_name: 'Page 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + parentExists: false, + depth: 0, + expand: false, + hasChild: false, + ancestors: [], + ...overrides, +}) + +const renderPageRow = (overrides: Partial> = {}) => { + const props: ComponentProps = { + checked: false, + disabled: false, + isPreviewed: false, + onPreview: vi.fn(), + onSelect: vi.fn(), + onToggle: vi.fn(), + row: buildRow(), + searchValue: '', + selectionMode: 'multiple', + showPreview: true, + style: { height: 28 }, + ...overrides, + } + + return { + ...render(), + props, + } +} + +describe('PageRow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call onSelect with the page id when the checkbox is clicked', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + renderPageRow({ onSelect }) + + await user.click(screen.getByTestId('checkbox-notion-page-checkbox-page-1')) + + expect(onSelect).toHaveBeenCalledWith('page-1') + }) + + it('should call onToggle when the row has children and the toggle is clicked', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + + renderPageRow({ + onToggle, + row: buildRow({ + hasChild: true, + expand: true, + }), + }) + + await user.click(screen.getByTestId('notion-page-toggle-page-1')) + + expect(onToggle).toHaveBeenCalledWith('page-1') + }) + + it('should render breadcrumbs and hide the toggle while searching', () => { + renderPageRow({ + searchValue: 'Page', + row: buildRow({ + parentExists: true, + ancestors: ['Workspace', 'Section'], + }), + }) + + expect(screen.queryByTestId('notion-page-toggle-page-1')).not.toBeInTheDocument() + expect(screen.getByText('Workspace / Section / Page 1')).toBeInTheDocument() + }) + + it('should render preview state and call onPreview when the preview button is clicked', async () => { + const user = userEvent.setup() + const onPreview = vi.fn() + + renderPageRow({ + isPreviewed: true, + onPreview, + }) + + expect(screen.getByTestId('notion-page-row-page-1')).toHaveClass('bg-state-base-hover') + + await user.click(screen.getByTestId('notion-page-preview-page-1')) + + expect(onPreview).toHaveBeenCalledWith('page-1') + }) + + it('should hide the preview button when showPreview is false', () => { + renderPageRow({ showPreview: false }) + + expect(screen.queryByTestId('notion-page-preview-page-1')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts b/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts new file mode 100644 index 0000000000..d90c50308d --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/use-page-selector-model.spec.ts @@ -0,0 +1,127 @@ +import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' +import { act, renderHook, waitFor } from '@testing-library/react' +import { usePageSelectorModel } from '../use-page-selector-model' + +const buildPage = (overrides: Partial): DataSourceNotionPage => ({ + page_id: 'page-id', + page_name: 'Page name', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + ...overrides, +}) + +const list: DataSourceNotionPage[] = [ + buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }), + buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }), + buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }), + buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }), +] + +const pagesMap: DataSourceNotionPageMap = { + 'root-1': { ...list[0], workspace_id: 'workspace-1' }, + 'child-1': { ...list[1], workspace_id: 'workspace-1' }, + 'grandchild-1': { ...list[2], workspace_id: 'workspace-1' }, + 'child-2': { ...list[3], workspace_id: 'workspace-1' }, +} + +const createProps = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + checkedIds: new Set(), + searchValue: '', + pagesMap, + list, + onSelect: vi.fn(), + previewPageId: undefined, + onPreview: vi.fn(), + selectionMode: 'multiple', + ...overrides, +}) + +describe('usePageSelectorModel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build visible rows from the expanded tree state', async () => { + const { result } = renderHook(() => usePageSelectorModel(createProps())) + + expect(result.current.rows.map(row => row.page.page_id)).toEqual(['root-1']) + + act(() => { + result.current.handleToggle('root-1') + }) + + await waitFor(() => { + expect(result.current.rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'child-2', + ]) + }) + + act(() => { + result.current.handleToggle('child-1') + }) + + await waitFor(() => { + expect(result.current.rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ]) + }) + }) + + it('should select descendants when selecting a parent in multiple mode', () => { + const onSelect = vi.fn() + const { result } = renderHook(() => usePageSelectorModel(createProps({ onSelect }))) + + act(() => { + result.current.handleSelect('root-1') + }) + + expect(onSelect).toHaveBeenCalledWith(new Set([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ])) + }) + + it('should update local preview and respect the controlled previewPageId when provided', () => { + const onPreview = vi.fn() + const { result, rerender } = renderHook( + props => usePageSelectorModel(props), + { initialProps: createProps({ onPreview }) }, + ) + + act(() => { + result.current.handlePreview('child-1') + }) + + expect(onPreview).toHaveBeenCalledWith('child-1') + expect(result.current.currentPreviewPageId).toBe('child-1') + + rerender(createProps({ onPreview, previewPageId: 'grandchild-1' })) + + expect(result.current.currentPreviewPageId).toBe('grandchild-1') + }) + + it('should expose filtered rows when the deferred search value changes', async () => { + const { result, rerender } = renderHook( + props => usePageSelectorModel(props), + { initialProps: createProps() }, + ) + + rerender(createProps({ searchValue: 'Grandchild' })) + + await waitFor(() => { + expect(result.current.effectiveSearchValue).toBe('Grandchild') + expect(result.current.rows.map(row => row.page.page_id)).toEqual(['grandchild-1']) + }) + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts b/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts new file mode 100644 index 0000000000..2e6005c573 --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/utils.spec.ts @@ -0,0 +1,118 @@ +import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' +import { + buildNotionPageTree, + getNextSelectedPageIds, + getRootPageIds, + getVisiblePageRows, +} from '../utils' + +const buildPage = (overrides: Partial): DataSourceNotionPage => ({ + page_id: 'page-id', + page_name: 'Page name', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + ...overrides, +}) + +const list: DataSourceNotionPage[] = [ + buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }), + buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }), + buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }), + buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }), + buildPage({ page_id: 'orphan-1', page_name: 'Orphan 1', parent_id: 'missing-parent' }), +] + +const pagesMap: DataSourceNotionPageMap = { + 'root-1': { ...list[0], workspace_id: 'workspace-1' }, + 'child-1': { ...list[1], workspace_id: 'workspace-1' }, + 'grandchild-1': { ...list[2], workspace_id: 'workspace-1' }, + 'child-2': { ...list[3], workspace_id: 'workspace-1' }, + 'orphan-1': { ...list[4], workspace_id: 'workspace-1' }, +} + +describe('page-selector utils', () => { + it('should build a tree with descendants, depth, and ancestors', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + expect(treeMap['root-1'].children).toEqual(new Set(['child-1', 'child-2'])) + expect(treeMap['root-1'].descendants).toEqual(new Set(['child-1', 'grandchild-1', 'child-2'])) + expect(treeMap['grandchild-1'].depth).toBe(2) + expect(treeMap['grandchild-1'].ancestors).toEqual(['Root 1', 'Child 1']) + }) + + it('should return root page ids for true roots and pages with missing parents', () => { + expect(getRootPageIds(list, pagesMap)).toEqual(['root-1', 'orphan-1']) + }) + + it('should return expanded tree rows in depth-first order when not searching', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + const rows = getVisiblePageRows({ + list, + pagesMap, + searchValue: '', + treeMap, + rootPageIds: ['root-1'], + expandedIds: new Set(['root-1', 'child-1']), + }) + + expect(rows.map(row => row.page.page_id)).toEqual([ + 'root-1', + 'child-1', + 'grandchild-1', + 'child-2', + ]) + }) + + it('should return filtered search rows with ancestry metadata when searching', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + const rows = getVisiblePageRows({ + list, + pagesMap, + searchValue: 'Grandchild', + treeMap, + rootPageIds: ['root-1'], + expandedIds: new Set(), + }) + + expect(rows).toEqual([ + expect.objectContaining({ + page: expect.objectContaining({ page_id: 'grandchild-1' }), + ancestors: ['Root 1', 'Child 1'], + hasChild: false, + parentExists: true, + }), + ]) + }) + + it('should toggle selected ids correctly in single and multiple mode', () => { + const treeMap = buildNotionPageTree(list, pagesMap) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(['root-1']), + pageId: 'child-1', + searchValue: '', + selectionMode: 'single', + treeMap, + })).toEqual(new Set(['child-1'])) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(), + pageId: 'root-1', + searchValue: '', + selectionMode: 'multiple', + treeMap, + })).toEqual(new Set(['root-1', 'child-1', 'grandchild-1', 'child-2'])) + + expect(getNextSelectedPageIds({ + checkedIds: new Set(['child-1']), + pageId: 'child-1', + searchValue: 'Child', + selectionMode: 'multiple', + treeMap, + })).toEqual(new Set()) + }) +}) diff --git a/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx b/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx new file mode 100644 index 0000000000..7ad4f29d3e --- /dev/null +++ b/web/app/components/base/notion-page-selector/page-selector/__tests__/virtual-page-list.spec.tsx @@ -0,0 +1,144 @@ +import type { ComponentProps } from 'react' +import type { NotionPageRow } from '../types' +import { render, screen } from '@testing-library/react' +import VirtualPageList from '../virtual-page-list' + +vi.mock('@tanstack/react-virtual') + +const pageRowPropsSpy = vi.fn() +type MockPageRowProps = ComponentProps + +vi.mock('../page-row', () => ({ + default: ({ + checked, + disabled, + isPreviewed, + onPreview, + onSelect, + onToggle, + row, + searchValue, + selectionMode, + showPreview, + style, + }: MockPageRowProps) => { + pageRowPropsSpy({ + checked, + disabled, + isPreviewed, + onPreview, + onSelect, + onToggle, + row, + searchValue, + selectionMode, + showPreview, + style, + }) + return
+ }, +})) + +const buildRow = (overrides: Partial = {}): NotionPageRow => ({ + page: { + page_id: 'page-1', + page_name: 'Page 1', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + parentExists: false, + depth: 0, + expand: false, + hasChild: false, + ancestors: [], + ...overrides, +}) + +describe('VirtualPageList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render virtual rows and pass row state to PageRow', () => { + const rows = [ + buildRow(), + buildRow({ + page: { + page_id: 'page-2', + page_name: 'Page 2', + parent_id: 'root', + page_icon: null, + type: 'page', + is_bound: false, + }, + }), + ] + + render( + , + ) + + expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + expect(screen.getByTestId('page-row-page-1')).toBeInTheDocument() + expect(screen.getByTestId('page-row-page-2')).toBeInTheDocument() + expect(pageRowPropsSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ + checked: true, + disabled: false, + isPreviewed: false, + searchValue: '', + selectionMode: 'multiple', + showPreview: true, + row: rows[0], + style: expect.objectContaining({ + height: '28px', + width: 'calc(100% - 16px)', + }), + })) + expect(pageRowPropsSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ + checked: false, + disabled: true, + isPreviewed: true, + row: rows[1], + })) + }) + + it('should size the virtual container using the row estimate', () => { + const rows = [buildRow(), buildRow()] + + render( + ()} + disabledValue={new Set()} + onPreview={vi.fn()} + onSelect={vi.fn()} + onToggle={vi.fn()} + previewPageId="" + rows={rows} + searchValue="" + selectionMode="multiple" + showPreview={false} + />, + ) + + const list = screen.getByTestId('virtual-list') + const innerContainer = list.firstElementChild as HTMLElement + + expect(innerContainer).toHaveStyle({ + height: '56px', + position: 'relative', + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx new file mode 100644 index 0000000000..02de482073 --- /dev/null +++ b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx @@ -0,0 +1,295 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { LexicalEditor } from 'lexical' +import type { ComponentProps } from 'react' +import type { EventEmitterValue } from '@/context/event-emitter' +import { CodeNode } from '@lexical/code' +import { LexicalComposer } from '@lexical/react/LexicalComposer' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + BLUR_COMMAND, + COMMAND_PRIORITY_EDITOR, + createCommand, + FOCUS_COMMAND, + TextNode, +} from 'lexical' +import { useEffect } from 'react' +import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' +import { ContextBlockNode } from '../plugins/context-block' +import { CurrentBlockNode } from '../plugins/current-block' +import { CustomTextNode } from '../plugins/custom-text/node' +import { ErrorMessageBlockNode } from '../plugins/error-message-block' +import { HistoryBlockNode } from '../plugins/history-block' +import { HITLInputNode } from '../plugins/hitl-input-block' +import { LastRunBlockNode } from '../plugins/last-run-block' +import { QueryBlockNode } from '../plugins/query-block' +import { RequestURLBlockNode } from '../plugins/request-url-block' +import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '../plugins/update-block' +import { VariableValueBlockNode } from '../plugins/variable-value-block/node' +import { WorkflowVariableBlockNode } from '../plugins/workflow-variable-block' +import PromptEditorContent from '../prompt-editor-content' +import { textToEditorState } from '../utils' + +type Captures = { + editor: LexicalEditor | null + eventEmitter: EventEmitter | null +} + +const mockDOMRect = { + x: 100, + y: 100, + width: 100, + height: 20, + top: 100, + right: 200, + bottom: 120, + left: 100, + toJSON: () => ({}), +} + +const originalRangeGetClientRects = Range.prototype.getClientRects +const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect + +const setSelectionOnEditable = (editable: HTMLElement) => { + const lexicalTextNode = editable.querySelector('[data-lexical-text="true"]')?.firstChild + const range = document.createRange() + + if (lexicalTextNode) { + range.setStart(lexicalTextNode, 0) + range.setEnd(lexicalTextNode, 1) + } + else { + range.selectNodeContents(editable) + } + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) +} + +const CaptureEditorAndEmitter = ({ captures }: { captures: Captures }) => { + const { eventEmitter } = useEventEmitterContextContext() + const [editor] = useLexicalComposerContext() + + useEffect(() => { + captures.editor = editor + }, [captures, editor]) + + useEffect(() => { + captures.eventEmitter = eventEmitter + }, [captures, eventEmitter]) + + return null +} + +const PromptEditorContentHarness = ({ + captures, + initialText = '', + ...props +}: ComponentProps & { captures: Captures, initialText?: string }) => ( + + new CustomTextNode(node.__text), + withKlass: CustomTextNode, + }, + ContextBlockNode, + HistoryBlockNode, + QueryBlockNode, + RequestURLBlockNode, + WorkflowVariableBlockNode, + VariableValueBlockNode, + HITLInputNode, + CurrentBlockNode, + ErrorMessageBlockNode, + LastRunBlockNode, + ], + editorState: textToEditorState(initialText), + onError: (error: Error) => { + throw error + }, + }} + > + + + + +) + +describe('PromptEditorContent', () => { + beforeAll(() => { + Range.prototype.getClientRects = vi.fn(() => { + const rectList = [mockDOMRect] as unknown as DOMRectList + Object.defineProperty(rectList, 'length', { value: 1 }) + Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null }) + return rectList + }) + Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect) + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterAll(() => { + Range.prototype.getClientRects = originalRangeGetClientRects + Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect + }) + + // The extracted content shell should run with the real Lexical stack and forward editor commands through its composed plugins. + describe('Rendering', () => { + it('should render with real dependencies and forward update/focus/blur events', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const onEditorChange = vi.fn() + const onFocus = vi.fn() + const onBlur = vi.fn() + const anchorElem = document.createElement('div') + + const { container } = render( + , + ) + + expect(screen.getByText('Type prompt')).toBeInTheDocument() + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + expect(editable.className).toContain('text-[13px]') + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + expect(captures.eventEmitter).not.toBeNull() + }) + + act(() => { + captures.eventEmitter?.emit({ + type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER, + instanceId: 'content-editor', + payload: 'updated prompt', + }) + }) + + await waitFor(() => { + expect(onEditorChange).toHaveBeenCalled() + }) + + act(() => { + captures.editor?.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus')) + captures.editor?.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: null })) + }) + + expect(onFocus).toHaveBeenCalledTimes(1) + expect(onBlur).toHaveBeenCalledTimes(1) + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should render optional blocks and open shortcut popups with the real editor runtime', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const onEditorChange = vi.fn() + const insertCommand = createCommand('prompt-editor-shortcut-insert') + const insertSpy = vi.fn() + const Popup = ({ onClose, onInsert }: { onClose: () => void, onInsert: (command: typeof insertCommand, params: string[]) => void }) => ( + <> + + + + ) + + const { container } = render( + , + ) + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + }) + + const unregister = captures.editor?.registerCommand( + insertCommand, + (payload) => { + insertSpy(payload) + return true + }, + COMMAND_PRIORITY_EDITOR, + ) + + const editable = container.querySelector('[contenteditable="true"]') as HTMLElement + editable.focus() + setSelectionOnEditable(editable) + + fireEvent.keyDown(document, { key: '/', ctrlKey: true }) + + const insertButton = await screen.findByRole('button', { name: 'Insert shortcut' }) + fireEvent.click(insertButton) + + expect(insertSpy).toHaveBeenCalledWith(['from-shortcut']) + expect(onEditorChange).toHaveBeenCalled() + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Insert shortcut' })).not.toBeInTheDocument() + }) + + unregister?.() + }) + + it('should keep the shell stable without optional anchor or placeholder overrides', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + render( + , + ) + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + }) + + expect(screen.queryByTestId('draggable-target-line')).not.toBeInTheDocument() + expect(screen.getByText('common.promptEditor.placeholder')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 772d15e4cf..6f6da6901b 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -22,11 +22,6 @@ import type { } from './types' import { CodeNode } from '@lexical/code' import { LexicalComposer } from '@lexical/react/LexicalComposer' -import { ContentEditable } from '@lexical/react/LexicalContentEditable' -import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' -import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' import { $getRoot, TextNode, @@ -39,63 +34,37 @@ import { UPDATE_DATASETS_EVENT_EMITTER, UPDATE_HISTORY_EVENT_EMITTER, } from './constants' -import ComponentPickerBlock from './plugins/component-picker-block' import { - ContextBlock, ContextBlockNode, - ContextBlockReplacementBlock, } from './plugins/context-block' import { - CurrentBlock, CurrentBlockNode, - CurrentBlockReplacementBlock, } from './plugins/current-block' import { CustomTextNode } from './plugins/custom-text/node' -import DraggableBlockPlugin from './plugins/draggable-plugin' import { - ErrorMessageBlock, ErrorMessageBlockNode, - ErrorMessageBlockReplacementBlock, } from './plugins/error-message-block' import { - HistoryBlock, HistoryBlockNode, - HistoryBlockReplacementBlock, } from './plugins/history-block' import { - HITLInputBlock, - HITLInputBlockReplacementBlock, HITLInputNode, } from './plugins/hitl-input-block' import { - LastRunBlock, LastRunBlockNode, - LastRunReplacementBlock, } from './plugins/last-run-block' -import OnBlurBlock from './plugins/on-blur-or-focus-block' -// import TreeView from './plugins/tree-view' -import Placeholder from './plugins/placeholder' import { - QueryBlock, QueryBlockNode, - QueryBlockReplacementBlock, } from './plugins/query-block' import { - RequestURLBlock, RequestURLBlockNode, - RequestURLBlockReplacementBlock, } from './plugins/request-url-block' -import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin' -import UpdateBlock from './plugins/update-block' -import VariableBlock from './plugins/variable-block' -import VariableValueBlock from './plugins/variable-value-block' import { VariableValueBlockNode } from './plugins/variable-value-block/node' import { - WorkflowVariableBlock, WorkflowVariableBlockNode, - WorkflowVariableBlockReplacementBlock, } from './plugins/workflow-variable-block' +import PromptEditorContent from './prompt-editor-content' import { textToEditorState } from './utils' export type PromptEditorProps = { @@ -214,152 +183,31 @@ const PromptEditor: FC = ({ return (
- - )} - placeholder={( - - )} - ErrorBoundary={LexicalErrorBoundary} - /> - {shortcutPopups?.map(({ hotkey, Popup }, idx) => ( - - {(closePortal, onInsert) => } - - ))} - - - { - contextBlock?.show && ( - <> - - - - ) - } - { - queryBlock?.show && ( - <> - - - - ) - } - { - historyBlock?.show && ( - <> - - - - ) - } - { - (variableBlock?.show || externalToolBlock?.show) && ( - <> - - - - ) - } - { - workflowVariableBlock?.show && ( - <> - - - - ) - } - { - hitlInputBlock?.show && ( - <> - - - - ) - } - { - currentBlock?.show && ( - <> - - - - ) - } - { - requestURLBlock?.show && ( - <> - - - - ) - } - { - errorMessageBlock?.show && ( - <> - - - - ) - } - { - lastRunBlock?.show && ( - <> - - - - ) - } - { - isSupportFileVar && ( - - ) - } - - - - - {floatingAnchorElem && ( - - )} - {/* */}
) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx index b5f38cdd1b..62b867d155 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/index.spec.tsx @@ -120,7 +120,10 @@ describe('HITLInputBlock', () => { }) await waitFor(() => { - expect(onWorkflowMapUpdate).toHaveBeenCalledWith(workflowNodesMap) + expect(onWorkflowMapUpdate).toHaveBeenCalledWith({ + workflowNodesMap, + availableVariables: [], + }) }) }) }) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx index c848d08c5c..db3e474b60 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx @@ -148,7 +148,10 @@ describe('HITLInputVariableBlockComponent', () => { editor!.update(() => { $getRoot().selectEnd() }) - handled = editor!.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, createWorkflowNodesMap()) + handled = editor!.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, { + workflowNodesMap: createWorkflowNodesMap(), + availableVariables: [], + }) }) expect(handled).toBe(true) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx index cd1515c57d..0da99b9155 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx @@ -22,7 +22,7 @@ const HITLInputReplacementBlock = ({ onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, - workflowNodesMap, + workflowNodesMap = {}, getVarType, variables, readonly, diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx index 2c10fdbd5a..1b2af39ebe 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx @@ -14,6 +14,7 @@ import { useEffect, } from 'react' import { CustomTextNode } from '../custom-text/node' +import { UPDATE_WORKFLOW_NODES_MAP as WORKFLOW_UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block' import { $createHITLInputNode, HITLInputNode, @@ -21,11 +22,13 @@ import { export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND') export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND') -export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP') +export const UPDATE_WORKFLOW_NODES_MAP = WORKFLOW_UPDATE_WORKFLOW_NODES_MAP + const HITLInputBlock = memo(({ onInsert, onDelete, - workflowNodesMap, + workflowNodesMap = {}, + variables: workflowAvailableVariables, getVarType, readonly, }: HITLInputBlockType) => { @@ -33,9 +36,12 @@ const HITLInputBlock = memo(({ useEffect(() => { editor.update(() => { - editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap) + editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, { + workflowNodesMap: workflowNodesMap || {}, + availableVariables: workflowAvailableVariables || [], + }) }) - }, [editor, workflowNodesMap]) + }, [editor, workflowNodesMap, workflowAvailableVariables]) useEffect(() => { if (!editor.hasNodes([HITLInputNode])) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx index b1374b994f..a466d64eff 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx @@ -1,3 +1,4 @@ +import type { UpdateWorkflowNodesMapPayload } from '../workflow-variable-block' import type { WorkflowNodesMap } from '../workflow-variable-block/node' import type { ValueSelector, Var } from '@/app/components/workflow/types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' @@ -98,9 +99,8 @@ const HITLInputVariableBlockComponent = ({ return mergeRegister( editor.registerCommand( UPDATE_WORKFLOW_NODES_MAP, - (workflowNodesMap: WorkflowNodesMap) => { - setLocalWorkflowNodesMap(workflowNodesMap) - + (payload: UpdateWorkflowNodesMapPayload) => { + setLocalWorkflowNodesMap(payload.workflowNodesMap) return true }, COMMAND_PRIORITY_EDITOR, diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx index ff064f2a99..a6cb70ddb6 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx @@ -1,4 +1,5 @@ import type { LexicalEditor } from 'lexical' +import type { UpdateWorkflowNodesMapPayload } from '../index' import type { ValueSelector, Var } from '@/app/components/workflow/types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' @@ -216,7 +217,7 @@ describe('WorkflowVariableBlockComponent', () => { }) }) - it('should mark env variable invalid when not found in environmentVariables', () => { + it('should treat env variable as valid regardless of environmentVariables contents', () => { const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }] render( @@ -229,7 +230,7 @@ describe('WorkflowVariableBlockComponent', () => { ) expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: expect.any(String), + errorMsg: undefined, })) }) @@ -281,7 +282,7 @@ describe('WorkflowVariableBlockComponent', () => { })) }) - it('should evaluate env fallback selector tokens when classifier is forced', () => { + it('should mark forced env branch invalid when selector prefix is missing', () => { mockForcedVariableKind.value = 'env' const environmentVariables: Var[] = [{ variable: '.', type: VarType.string }] @@ -295,7 +296,7 @@ describe('WorkflowVariableBlockComponent', () => { ) expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, + errorMsg: expect.any(String), })) }) @@ -330,7 +331,7 @@ describe('WorkflowVariableBlockComponent', () => { })) }) - it('should mark conversation variable invalid when not found in conversationVariables', () => { + it('should treat conversation variable as valid regardless of conversationVariables contents', () => { const conversationVariables: Var[] = [{ variable: 'conversation.other', type: VarType.string }] render( @@ -343,7 +344,7 @@ describe('WorkflowVariableBlockComponent', () => { ) expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: expect.any(String), + errorMsg: undefined, })) }) @@ -364,7 +365,7 @@ describe('WorkflowVariableBlockComponent', () => { })) }) - it('should evaluate conversation fallback selector tokens when classifier is forced', () => { + it('should mark forced conversation branch invalid when selector prefix is missing', () => { mockForcedVariableKind.value = 'conversation' const conversationVariables: Var[] = [{ variable: '.', type: VarType.string }] @@ -378,7 +379,7 @@ describe('WorkflowVariableBlockComponent', () => { ) expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, + errorMsg: expect.any(String), })) }) @@ -427,7 +428,7 @@ describe('WorkflowVariableBlockComponent', () => { })) }) - it('should mark rag variable invalid when not found in ragVariables', () => { + it('should treat rag variable as valid regardless of ragVariables contents', () => { const ragVariables: Var[] = [{ variable: 'rag.shared.other', type: VarType.string }] render( @@ -440,7 +441,7 @@ describe('WorkflowVariableBlockComponent', () => { ) expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: expect.any(String), + errorMsg: undefined, })) }) @@ -461,7 +462,7 @@ describe('WorkflowVariableBlockComponent', () => { })) }) - it('should evaluate rag fallback selector tokens when classifier is forced', () => { + it('should mark forced rag branch invalid when selector prefix is missing', () => { mockForcedVariableKind.value = 'rag' const ragVariables: Var[] = [{ variable: '..', type: VarType.string }] @@ -475,7 +476,7 @@ describe('WorkflowVariableBlockComponent', () => { ) expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ - errorMsg: undefined, + errorMsg: expect.any(String), })) }) @@ -488,20 +489,81 @@ describe('WorkflowVariableBlockComponent', () => { />, ) - const updateHandler = mockRegisterCommand.mock.calls[0][1] as (map: Record) => boolean + const updateHandler = mockRegisterCommand.mock.calls[0][1] as (payload: UpdateWorkflowNodesMapPayload) => boolean let result = false act(() => { result = updateHandler({ - 'node-1': { - title: 'Updated', - type: BlockEnum.LLM, - width: 100, - height: 50, - position: { x: 0, y: 0 }, + workflowNodesMap: { + 'node-1': { + title: 'Updated', + type: BlockEnum.LLM, + width: 100, + height: 50, + position: { x: 0, y: 0 }, + }, }, + availableVariables: [], }) }) expect(result).toBe(true) }) + + it('should mark non-special variable invalid when source key is missing in availableVariables', () => { + render( + , + ) + + expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ + errorMsg: expect.any(String), + })) + }) + + it('should keep non-special variable valid when source key exists in availableVariables', () => { + render( + , + ) + + expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({ + errorMsg: undefined, + })) + }) }) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/index.spec.tsx index 1591dc44f9..00b5b66660 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/index.spec.tsx @@ -105,7 +105,10 @@ describe('WorkflowVariableBlock', () => { ) expect(mockUpdate).toHaveBeenCalled() - expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap) + expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, { + workflowNodesMap, + availableVariables: [], + }) }) it('should throw when WorkflowVariableBlockNode is not registered', () => { @@ -137,6 +140,7 @@ describe('WorkflowVariableBlock', () => { ['node-1', 'answer'], workflowNodesMap, getVarType, + [], ) expect($insertNodes).toHaveBeenCalledWith([{ id: 'workflow-node' }]) expect(onInsert).toHaveBeenCalledTimes(1) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/node.spec.tsx index 8d7a1cc33d..4154cd2fd9 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/node.spec.tsx @@ -1,5 +1,5 @@ import type { Klass, LexicalEditor, LexicalNode } from 'lexical' -import type { Var } from '@/app/components/workflow/types' +import type { NodeOutPutVar } from '@/app/components/workflow/types' import { createEditor } from 'lexical' import { Type } from '@/app/components/workflow/nodes/llm/types' import { BlockEnum, VarType } from '@/app/components/workflow/types' @@ -57,45 +57,43 @@ describe('WorkflowVariableBlockNode', () => { it('should decorate with component props from node state', () => { runInEditor(() => { const getVarType = vi.fn(() => Type.number) - const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }] - const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }] - const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }] + const availableVariables: NodeOutPutVar[] = [{ + nodeId: 'node-1', + title: 'Node A', + vars: [{ variable: 'answer', type: VarType.string }], + }] const node = new WorkflowVariableBlockNode( ['node-1', 'answer'], { 'node-1': { title: 'A', type: BlockEnum.LLM } }, getVarType, 'decorator-key', - environmentVariables, - conversationVariables, - ragVariables, + availableVariables, ) const decorated = node.decorate() expect(decorated.props.nodeKey).toBe('decorator-key') expect(decorated.props.variables).toEqual(['node-1', 'answer']) expect(decorated.props.workflowNodesMap).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } }) - expect(decorated.props.environmentVariables).toEqual(environmentVariables) - expect(decorated.props.conversationVariables).toEqual(conversationVariables) - expect(decorated.props.ragVariables).toEqual(ragVariables) + expect(decorated.props.availableVariables).toEqual(availableVariables) }) }) - it('should export and import json with full payload', () => { + it('should export and import json with available variables payload', () => { runInEditor(() => { const getVarType = vi.fn(() => Type.string) - const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }] - const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }] - const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }] + const availableVariables: NodeOutPutVar[] = [{ + nodeId: 'node-1', + title: 'Node A', + vars: [{ variable: 'answer', type: VarType.string }], + }] const node = new WorkflowVariableBlockNode( ['node-1', 'answer'], { 'node-1': { title: 'A', type: BlockEnum.LLM } }, getVarType, undefined, - environmentVariables, - conversationVariables, - ragVariables, + availableVariables, ) expect(node.exportJSON()).toEqual({ @@ -104,9 +102,7 @@ describe('WorkflowVariableBlockNode', () => { variables: ['node-1', 'answer'], workflowNodesMap: { 'node-1': { title: 'A', type: BlockEnum.LLM } }, getVarType, - environmentVariables, - conversationVariables, - ragVariables, + availableVariables, }) const imported = WorkflowVariableBlockNode.importJSON({ @@ -115,48 +111,51 @@ describe('WorkflowVariableBlockNode', () => { variables: ['node-2', 'result'], workflowNodesMap: { 'node-2': { title: 'B', type: BlockEnum.Tool } }, getVarType, - environmentVariables, - conversationVariables, - ragVariables, + availableVariables, }) expect(imported).toBeInstanceOf(WorkflowVariableBlockNode) expect(imported.getVariables()).toEqual(['node-2', 'result']) expect(imported.getWorkflowNodesMap()).toEqual({ 'node-2': { title: 'B', type: BlockEnum.Tool } }) + expect(imported.getAvailableVariables()).toEqual(availableVariables) }) }) it('should return getters and text content in expected format', () => { runInEditor(() => { const getVarType = vi.fn(() => Type.string) - const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }] - const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }] - const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }] + const availableVariables: NodeOutPutVar[] = [{ + nodeId: 'node-1', + title: 'Node A', + vars: [{ variable: 'answer', type: VarType.string }], + }] const node = new WorkflowVariableBlockNode( ['node-1', 'answer'], { 'node-1': { title: 'A', type: BlockEnum.LLM } }, getVarType, undefined, - environmentVariables, - conversationVariables, - ragVariables, + availableVariables, ) expect(node.getVariables()).toEqual(['node-1', 'answer']) expect(node.getWorkflowNodesMap()).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } }) expect(node.getVarType()).toBe(getVarType) - expect(node.getEnvironmentVariables()).toEqual(environmentVariables) - expect(node.getConversationVariables()).toEqual(conversationVariables) - expect(node.getRagVariables()).toEqual(ragVariables) + expect(node.getAvailableVariables()).toEqual(availableVariables) expect(node.getTextContent()).toBe('{{#node-1.answer#}}') }) }) it('should create node helper and type guard checks', () => { runInEditor(() => { - const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined) + const availableVariables: NodeOutPutVar[] = [{ + nodeId: 'node-1', + title: 'Node A', + vars: [{ variable: 'answer', type: VarType.string }], + }] + const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined, availableVariables) expect(node).toBeInstanceOf(WorkflowVariableBlockNode) + expect(node.getAvailableVariables()).toEqual(availableVariables) expect($isWorkflowVariableBlockNode(node)).toBe(true) expect($isWorkflowVariableBlockNode(null)).toBe(false) expect($isWorkflowVariableBlockNode(undefined)).toBe(false) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/workflow-variable-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/workflow-variable-block-replacement-block.spec.tsx index b9cb1faa37..9dcc37ec35 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/workflow-variable-block-replacement-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/workflow-variable-block-replacement-block.spec.tsx @@ -183,12 +183,7 @@ describe('WorkflowVariableBlockReplacementBlock', () => { ['node-1', 'output'], workflowNodesMap, getVarType, - variables[0].vars, - variables[1].vars, - [ - { variable: 'ragVarA', type: VarType.string, isRagVariable: true }, - { variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true }, - ], + variables, ) expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'workflow-node' }) expect(created).toEqual({ type: 'workflow-node' }) @@ -214,8 +209,6 @@ describe('WorkflowVariableBlockReplacementBlock', () => { workflowNodesMap, undefined, [], - [], - undefined, ) }) }) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx index 2b46d1a378..bf91d25834 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx @@ -1,5 +1,8 @@ +import type { + UpdateWorkflowNodesMapPayload, +} from './index' import type { WorkflowNodesMap } from './node' -import type { ValueSelector, Var } from '@/app/components/workflow/types' +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' import { @@ -15,7 +18,7 @@ import { import { useTranslation } from 'react-i18next' import { useReactFlow, useStoreApi } from 'reactflow' import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' -import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { isRagVariableVar, isSpecialVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel' import { VariableLabelInEditor, @@ -34,6 +37,7 @@ type WorkflowVariableBlockComponentProps = { nodeKey: string variables: string[] workflowNodesMap: WorkflowNodesMap + availableVariables?: NodeOutPutVar[] environmentVariables?: Var[] conversationVariables?: Var[] ragVariables?: Var[] @@ -47,10 +51,8 @@ const WorkflowVariableBlockComponent = ({ nodeKey, variables, workflowNodesMap = {}, + availableVariables, getVarType, - environmentVariables, - conversationVariables, - ragVariables, }: WorkflowVariableBlockComponentProps) => { const { t } = useTranslation() const [editor] = useLexicalComposerContext() @@ -66,36 +68,25 @@ const WorkflowVariableBlockComponent = ({ } )() const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState(workflowNodesMap) + const [localAvailableVariables, setLocalAvailableVariables] = useState(availableVariables || []) const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]] const isException = isExceptionVariable(varName, node?.type) const sourceNodeId = variables[isRagVar ? 1 : 0] const isLlmModelInstalled = useLlmModelPluginInstalled(sourceNodeId, localWorkflowNodesMap) const variableValid = useMemo(() => { - let variableValid = true - const isEnv = isENV(variables) - const isChatVar = isConversationVar(variables) - const isGlobal = isGlobalVar(variables) - if (isGlobal) + if (isSpecialVar(variables[0] ?? '')) return true - if (isEnv) { - if (environmentVariables) - variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) - } - else if (isChatVar) { - if (conversationVariables) - variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) - } - else if (isRagVar) { - if (ragVariables) - variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`) - } - else { - variableValid = !!node - } - return variableValid - }, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables]) + if (!variables[1]) + return false + + const sourceNode = localAvailableVariables.find(v => v.nodeId === variables[0]) + if (!sourceNode) + return false + + return sourceNode.vars.some(v => v.variable === variables[1]) + }, [localAvailableVariables, variables]) const reactflow = useReactFlow() const store = useStoreApi() @@ -107,9 +98,9 @@ const WorkflowVariableBlockComponent = ({ return mergeRegister( editor.registerCommand( UPDATE_WORKFLOW_NODES_MAP, - (workflowNodesMap: WorkflowNodesMap) => { - setLocalWorkflowNodesMap(workflowNodesMap) - + (payload: UpdateWorkflowNodesMapPayload) => { + setLocalWorkflowNodesMap(payload.workflowNodesMap) + setLocalAvailableVariables(payload.availableVariables) return true }, COMMAND_PRIORITY_EDITOR, diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx index dfbd238dbf..ab79630f80 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx @@ -17,9 +17,14 @@ import { export const INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND') export const DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND') -export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP') +export type UpdateWorkflowNodesMapPayload = { + workflowNodesMap: NonNullable + availableVariables: NonNullable +} +export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP') const WorkflowVariableBlock = memo(({ - workflowNodesMap, + workflowNodesMap = {}, + variables: workflowAvailableVariables, onInsert, onDelete, getVarType, @@ -28,9 +33,12 @@ const WorkflowVariableBlock = memo(({ useEffect(() => { editor.update(() => { - editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap) + editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, { + workflowNodesMap: workflowNodesMap || {}, + availableVariables: workflowAvailableVariables || [], + }) }) - }, [editor, workflowNodesMap]) + }, [editor, workflowNodesMap, workflowAvailableVariables]) useEffect(() => { if (!editor.hasNodes([WorkflowVariableBlockNode])) @@ -40,7 +48,12 @@ const WorkflowVariableBlock = memo(({ editor.registerCommand( INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, (variables: string[]) => { - const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType) + const workflowVariableBlockNode = $createWorkflowVariableBlockNode( + variables, + workflowNodesMap, + getVarType, + workflowAvailableVariables || [], + ) $insertNodes([workflowVariableBlockNode]) if (onInsert) @@ -61,7 +74,7 @@ const WorkflowVariableBlock = memo(({ COMMAND_PRIORITY_EDITOR, ), ) - }, [editor, onInsert, onDelete, workflowNodesMap, getVarType]) + }, [editor, onInsert, onDelete, workflowNodesMap, getVarType, workflowAvailableVariables]) return null }) diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx index 743937d8a6..2d13627b20 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx @@ -1,49 +1,55 @@ import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical' import type { GetVarType, WorkflowVariableBlockType } from '../../types' -import type { Var } from '@/app/components/workflow/types' +import type { NodeOutPutVar } from '@/app/components/workflow/types' import { DecoratorNode } from 'lexical' import WorkflowVariableBlockComponent from './component' -export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap'] +export type WorkflowNodesMap = NonNullable type SerializedNode = SerializedLexicalNode & { variables: string[] workflowNodesMap: WorkflowNodesMap getVarType?: GetVarType - environmentVariables?: Var[] - conversationVariables?: Var[] - ragVariables?: Var[] + availableVariables?: NodeOutPutVar[] } export class WorkflowVariableBlockNode extends DecoratorNode { __variables: string[] __workflowNodesMap: WorkflowNodesMap __getVarType?: GetVarType - __environmentVariables?: Var[] - __conversationVariables?: Var[] - __ragVariables?: Var[] + __availableVariables?: NodeOutPutVar[] static getType(): string { return 'workflow-variable-block' } static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode { - return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__getVarType, node.__key, node.__environmentVariables, node.__conversationVariables, node.__ragVariables) + return new WorkflowVariableBlockNode( + node.__variables, + node.__workflowNodesMap, + node.__getVarType, + node.__key, + node.__availableVariables, + ) } isInline(): boolean { return true } - constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType: any, key?: NodeKey, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]) { + constructor( + variables: string[], + workflowNodesMap: WorkflowNodesMap, + getVarType: any, + key?: NodeKey, + availableVariables?: NodeOutPutVar[], + ) { super(key) this.__variables = variables this.__workflowNodesMap = workflowNodesMap this.__getVarType = getVarType - this.__environmentVariables = environmentVariables - this.__conversationVariables = conversationVariables - this.__ragVariables = ragVariables + this.__availableVariables = availableVariables } createDOM(): HTMLElement { @@ -63,30 +69,34 @@ export class WorkflowVariableBlockNode extends DecoratorNode variables={this.__variables} workflowNodesMap={this.__workflowNodesMap} getVarType={this.__getVarType!} - environmentVariables={this.__environmentVariables} - conversationVariables={this.__conversationVariables} - ragVariables={this.__ragVariables} + availableVariables={this.__availableVariables} /> ) } static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode { - const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType, serializedNode.environmentVariables, serializedNode.conversationVariables, serializedNode.ragVariables) + const node = $createWorkflowVariableBlockNode( + serializedNode.variables, + serializedNode.workflowNodesMap, + serializedNode.getVarType, + serializedNode.availableVariables, + ) return node } exportJSON(): SerializedNode { - return { + const json: SerializedNode = { type: 'workflow-variable-block', version: 1, variables: this.getVariables(), workflowNodesMap: this.getWorkflowNodesMap(), getVarType: this.getVarType(), - environmentVariables: this.getEnvironmentVariables(), - conversationVariables: this.getConversationVariables(), - ragVariables: this.getRagVariables(), } + if (this.getAvailableVariables()) + json.availableVariables = this.getAvailableVariables() + + return json } getVariables(): string[] { @@ -104,27 +114,28 @@ export class WorkflowVariableBlockNode extends DecoratorNode return self.__getVarType } - getEnvironmentVariables(): any { + getAvailableVariables(): NodeOutPutVar[] | undefined { const self = this.getLatest() - return self.__environmentVariables - } - - getConversationVariables(): any { - const self = this.getLatest() - return self.__conversationVariables - } - - getRagVariables(): any { - const self = this.getLatest() - return self.__ragVariables + return self.__availableVariables } getTextContent(): string { return `{{#${this.getVariables().join('.')}#}}` } } -export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]): WorkflowVariableBlockNode { - return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType, undefined, environmentVariables, conversationVariables, ragVariables) +export function $createWorkflowVariableBlockNode( + variables: string[], + workflowNodesMap: WorkflowNodesMap, + getVarType?: GetVarType, + availableVariables?: NodeOutPutVar[], +): WorkflowVariableBlockNode { + return new WorkflowVariableBlockNode( + variables, + workflowNodesMap, + getVarType, + undefined, + availableVariables, + ) } export function $isWorkflowVariableBlockNode( diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx index 573c97f465..e3c947d786 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx @@ -15,19 +15,12 @@ import { WorkflowVariableBlockNode } from './index' import { $createWorkflowVariableBlockNode } from './node' const WorkflowVariableBlockReplacementBlock = ({ - workflowNodesMap, + workflowNodesMap = {}, getVarType, onInsert, variables, }: WorkflowVariableBlockType) => { const [editor] = useLexicalComposerContext() - const ragVariables = variables?.reduce((acc, curr) => { - if (curr.nodeId === 'rag') - acc.push(...curr.vars) - else - acc.push(...curr.vars.filter(v => v.isRagVariable)) - return acc - }, []) useEffect(() => { if (!editor.hasNodes([WorkflowVariableBlockNode])) @@ -39,8 +32,13 @@ const WorkflowVariableBlockReplacementBlock = ({ onInsert() const nodePathString = textNode.getTextContent().slice(3, -3) - return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables)) - }, [onInsert, workflowNodesMap, getVarType, variables, ragVariables]) + return $applyNodeReplacement($createWorkflowVariableBlockNode( + nodePathString.split('.'), + workflowNodesMap, + getVarType, + variables || [], + )) + }, [onInsert, workflowNodesMap, getVarType, variables]) const getMatch = useCallback((text: string) => { const matchArr = REGEX.exec(text) diff --git a/web/app/components/base/prompt-editor/prompt-editor-content.tsx b/web/app/components/base/prompt-editor/prompt-editor-content.tsx new file mode 100644 index 0000000000..07db69cfc8 --- /dev/null +++ b/web/app/components/base/prompt-editor/prompt-editor-content.tsx @@ -0,0 +1,257 @@ +import type { + EditorState, + LexicalCommand, +} from 'lexical' +import type { FC } from 'react' +import type { Hotkey } from './plugins/shortcuts-popup-plugin' +import type { + ContextBlockType, + CurrentBlockType, + ErrorMessageBlockType, + ExternalToolBlockType, + HistoryBlockType, + HITLInputBlockType, + LastRunBlockType, + QueryBlockType, + RequestURLBlockType, + VariableBlockType, + WorkflowVariableBlockType, +} from './types' +import { ContentEditable } from '@lexical/react/LexicalContentEditable' +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' +import * as React from 'react' +import { cn } from '@/utils/classnames' +import ComponentPickerBlock from './plugins/component-picker-block' +import { + ContextBlock, + ContextBlockReplacementBlock, +} from './plugins/context-block' +import { + CurrentBlock, + CurrentBlockReplacementBlock, +} from './plugins/current-block' +import DraggableBlockPlugin from './plugins/draggable-plugin' +import { + ErrorMessageBlock, + ErrorMessageBlockReplacementBlock, +} from './plugins/error-message-block' +import { + HistoryBlock, + HistoryBlockReplacementBlock, +} from './plugins/history-block' +import { + HITLInputBlock, + HITLInputBlockReplacementBlock, +} from './plugins/hitl-input-block' +import { + LastRunBlock, + LastRunReplacementBlock, +} from './plugins/last-run-block' +import OnBlurBlock from './plugins/on-blur-or-focus-block' +import Placeholder from './plugins/placeholder' +import { + QueryBlock, + QueryBlockReplacementBlock, +} from './plugins/query-block' +import { + RequestURLBlock, + RequestURLBlockReplacementBlock, +} from './plugins/request-url-block' +import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin' +import UpdateBlock from './plugins/update-block' +import VariableBlock from './plugins/variable-block' +import VariableValueBlock from './plugins/variable-value-block' +import { + WorkflowVariableBlock, + WorkflowVariableBlockReplacementBlock, +} from './plugins/workflow-variable-block' + +type ShortcutPopup = { + hotkey: Hotkey + Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand, params: unknown[]) => void }> +} + +type PromptEditorContentProps = { + compact?: boolean + className?: string + placeholder?: string | React.ReactNode + placeholderClassName?: string + style?: React.CSSProperties + shortcutPopups: ShortcutPopup[] + contextBlock?: ContextBlockType + queryBlock?: QueryBlockType + requestURLBlock?: RequestURLBlockType + historyBlock?: HistoryBlockType + variableBlock?: VariableBlockType + externalToolBlock?: ExternalToolBlockType + workflowVariableBlock?: WorkflowVariableBlockType + hitlInputBlock?: HITLInputBlockType + currentBlock?: CurrentBlockType + errorMessageBlock?: ErrorMessageBlockType + lastRunBlock?: LastRunBlockType + isSupportFileVar?: boolean + onBlur?: () => void + onFocus?: () => void + instanceId?: string + floatingAnchorElem: HTMLDivElement | null + onEditorChange: (editorState: EditorState) => void +} + +const PromptEditorContent: FC = ({ + compact, + className, + placeholder, + placeholderClassName, + style, + shortcutPopups, + contextBlock, + queryBlock, + requestURLBlock, + historyBlock, + variableBlock, + externalToolBlock, + workflowVariableBlock, + hitlInputBlock, + currentBlock, + errorMessageBlock, + lastRunBlock, + isSupportFileVar, + onBlur, + onFocus, + instanceId, + floatingAnchorElem, + onEditorChange, +}) => { + return ( + <> + + )} + placeholder={( + + )} + ErrorBoundary={LexicalErrorBoundary} + /> + {shortcutPopups.map(({ hotkey, Popup }, idx) => ( + + {(closePortal, onInsert) => } + + ))} + + + {contextBlock?.show && ( + <> + + + + )} + {queryBlock?.show && ( + <> + + + + )} + {historyBlock?.show && ( + <> + + + + )} + {(variableBlock?.show || externalToolBlock?.show) && ( + <> + + + + )} + {workflowVariableBlock?.show && ( + <> + + + + )} + {hitlInputBlock?.show && ( + <> + + + + )} + {currentBlock?.show && ( + <> + + + + )} + {requestURLBlock?.show && ( + <> + + + + )} + {errorMessageBlock?.show && ( + <> + + + + )} + {lastRunBlock?.show && ( + <> + + + + )} + {isSupportFileVar && ( + + )} + + + + + {floatingAnchorElem && ( + + )} + + ) +} + +export default PromptEditorContent diff --git a/web/app/components/base/avatar/__tests__/index.spec.tsx b/web/app/components/base/ui/avatar/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/base/avatar/__tests__/index.spec.tsx rename to web/app/components/base/ui/avatar/__tests__/index.spec.tsx index 69c56ac993..8be3f8bf0f 100644 --- a/web/app/components/base/avatar/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/avatar/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { Avatar } from '../index' +import { Avatar } from '..' describe('Avatar', () => { describe('Rendering', () => { diff --git a/web/app/components/base/avatar/index.stories.tsx b/web/app/components/base/ui/avatar/index.stories.tsx similarity index 100% rename from web/app/components/base/avatar/index.stories.tsx rename to web/app/components/base/ui/avatar/index.stories.tsx diff --git a/web/app/components/base/avatar/index.tsx b/web/app/components/base/ui/avatar/index.tsx similarity index 92% rename from web/app/components/base/avatar/index.tsx rename to web/app/components/base/ui/avatar/index.tsx index 885022dded..0842a1734d 100644 --- a/web/app/components/base/avatar/index.tsx +++ b/web/app/components/base/ui/avatar/index.tsx @@ -36,7 +36,7 @@ function AvatarRoot({ return ( ) diff --git a/web/app/components/base/ui/dialog/index.tsx b/web/app/components/base/ui/dialog/index.tsx index 7b58be4cc5..5dcebfcac2 100644 --- a/web/app/components/base/ui/dialog/index.tsx +++ b/web/app/components/base/ui/dialog/index.tsx @@ -2,13 +2,10 @@ // z-index strategy (relies on root `isolation: isolate` in layout.tsx): // All base/ui/* overlay primitives — z-1002 +// Toast stays one layer above overlays at z-1003. // Overlays share the same z-index; DOM order handles stacking when multiple are open. // This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render // above the dialog backdrop instead of being clipped by it. -// During migration, z-1002 is chosen to sit above all legacy overlays -// (Modal z-[60], PortalToFollowElem callers up to z-[1001]). -// Once all legacy overlays are migrated, this can be reduced back to z-50. -// Toast uses z-1101 during migration so it stays above legacy highPriority modals. import { Dialog as BaseDialog } from '@base-ui/react/dialog' import * as React from 'react' @@ -60,7 +57,7 @@ export function DialogContent({ { // Increment and decrement buttons should preserve accessible naming, icon fallbacks, and spacing variants. describe('Control buttons', () => { - it('should provide localized aria labels and default icons when labels are not provided', () => { + it('should provide english fallback aria labels and default icons when labels are not provided', () => { renderNumberField({ controlsProps: {}, }) - const increment = screen.getByRole('button', { name: 'common.operation.increment' }) - const decrement = screen.getByRole('button', { name: 'common.operation.decrement' }) + const increment = screen.getByRole('button', { name: 'Increment value' }) + const decrement = screen.getByRole('button', { name: 'Decrement value' }) expect(increment.querySelector('.i-ri-arrow-up-s-line')).toBeInTheDocument() expect(decrement.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() @@ -217,11 +217,11 @@ describe('NumberField wrapper', () => { }, }) - expect(screen.getByRole('button', { name: 'common.operation.increment' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.decrement' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Increment value' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Decrement value' })).toBeInTheDocument() }) - it('should rely on aria-labelledby when provided instead of injecting a translated aria-label', () => { + it('should rely on aria-labelledby when provided instead of injecting a fallback aria-label', () => { render( <> Increment from label diff --git a/web/app/components/base/ui/number-field/index.tsx b/web/app/components/base/ui/number-field/index.tsx index 97f1cc7d31..7d4c43b815 100644 --- a/web/app/components/base/ui/number-field/index.tsx +++ b/web/app/components/base/ui/number-field/index.tsx @@ -4,7 +4,6 @@ import type { VariantProps } from 'class-variance-authority' import { NumberField as BaseNumberField } from '@base-ui/react/number-field' import { cva } from 'class-variance-authority' import * as React from 'react' -import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' export const NumberField = BaseNumberField.Root @@ -188,18 +187,19 @@ type NumberFieldButtonVariantProps = Omit< export type NumberFieldButtonProps = React.ComponentPropsWithoutRef & NumberFieldButtonVariantProps +const incrementAriaLabel = 'Increment value' +const decrementAriaLabel = 'Decrement value' + export function NumberFieldIncrement({ className, children, size = 'regular', ...props }: NumberFieldButtonProps) { - const { t } = useTranslation() - return ( {children ??
() return (
{ expect(popup).toHaveAttribute('data-track-id', 'tooltip-track') expect(onMouseEnter).toHaveBeenCalledTimes(1) }) + + it('should apply className to the popup and positionerClassName to the positioner', () => { + render( + + Trigger + + Tooltip body + + , + ) + + const popup = screen.getByRole('tooltip', { name: 'styled tooltip' }) + expect(popup).toHaveClass('popup-class') + expect(popup.parentElement).toHaveClass('positioner-class') + }) }) }) diff --git a/web/app/components/base/ui/tooltip/index.tsx b/web/app/components/base/ui/tooltip/index.tsx index 030c30bf78..0887506863 100644 --- a/web/app/components/base/ui/tooltip/index.tsx +++ b/web/app/components/base/ui/tooltip/index.tsx @@ -13,8 +13,8 @@ type TooltipContentProps = { placement?: Placement sideOffset?: number alignOffset?: number + positionerClassName?: string className?: string - popupClassName?: string variant?: TooltipContentVariant } & Omit, 'children' | 'className'> @@ -23,8 +23,8 @@ export function TooltipContent({ placement = 'top', sideOffset = 8, alignOffset = 0, + positionerClassName, className, - popupClassName, variant = 'default', ...props }: TooltipContentProps) { @@ -37,13 +37,13 @@ export function TooltipContent({ align={align} sideOffset={sideOffset} alignOffset={alignOffset} - className={cn('z-1002 outline-hidden', className)} + className={cn('z-1002 outline-hidden', positionerClassName)} > diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx index 28c640cdbe..2c0480e508 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx @@ -33,6 +33,22 @@ describe('DelimiterInput', () => { // Tooltip triggers render; component mounts without error expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument() }) + + it('should suppress onChange during IME composition', () => { + const onChange = vi.fn() + const finalValue = 'wu' + render() + const input = screen.getByPlaceholderText(`${ns}.stepTwo.separatorPlaceholder`) + + fireEvent.compositionStart(input) + fireEvent.change(input, { target: { value: 'w' } }) + fireEvent.change(input, { target: { value: finalValue } }) + expect(onChange).not.toHaveBeenCalled() + + fireEvent.compositionEnd(input) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange.mock.calls[0][0].target.value).toBe(finalValue) + }) }) describe('MaxLengthInput', () => { diff --git a/web/app/components/datasets/create/step-two/components/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx index 9d40f511f9..4733852d19 100644 --- a/web/app/components/datasets/create/step-two/components/inputs.tsx +++ b/web/app/components/datasets/create/step-two/components/inputs.tsx @@ -1,6 +1,7 @@ import type { FC, PropsWithChildren, ReactNode } from 'react' import type { InputProps } from '@/app/components/base/input' import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '@/app/components/base/ui/number-field' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Tooltip from '@/app/components/base/tooltip' @@ -16,7 +17,7 @@ import { import { env } from '@/env' const TextLabel: FC = (props) => { - return + return } const FormField: FC> = (props) => { @@ -28,8 +29,11 @@ const FormField: FC> = (props) => { ) } -export const DelimiterInput: FC = (props) => { +export const DelimiterInput: FC = ({ tooltip, onChange, value, ...rest }) => { const { t } = useTranslation() + const isComposing = useRef(false) + const [compositionValue, setCompositionValue] = useState('') + return ( @@ -37,7 +41,7 @@ export const DelimiterInput: FC = (props) => - {props.tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })} + {tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })}
)} /> @@ -48,7 +52,24 @@ export const DelimiterInput: FC = (props) => type="text" className="h-9" placeholder={t('stepTwo.separatorPlaceholder', { ns: 'datasetCreation' })!} - {...props} + value={isComposing.current ? compositionValue : value} + onChange={(e) => { + if (isComposing.current) + setCompositionValue(e.target.value) + else + onChange?.(e) + }} + onCompositionStart={() => { + isComposing.current = true + setCompositionValue(String(value ?? '')) + }} + onCompositionEnd={(e) => { + const committed = e.currentTarget.value + isComposing.current = false + setCompositionValue('') + onChange?.({ ...e, target: { ...e.target, value: committed } } as unknown as React.ChangeEvent) + }} + {...rest} /> ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx new file mode 100644 index 0000000000..ada7aa7b7f --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/loading.spec.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import Loading from '../loading' + +vi.mock('@/app/components/base/skeleton', () => ({ + SkeletonContainer: ({ children, className }: { children?: ReactNode, className?: string }) => ( +
{children}
+ ), + SkeletonRectangle: ({ className }: { className?: string }) => ( +
+ ), +})) + +describe('CreateFromPipelinePreviewLoading', () => { + it('should render the preview loading shell and all skeleton blocks', () => { + const { container } = render() + + expect(container.firstElementChild).toHaveClass( + 'flex', + 'h-full', + 'w-full', + 'flex-col', + 'overflow-hidden', + 'px-6', + 'py-5', + ) + expect(screen.getAllByTestId('skeleton-container')).toHaveLength(6) + expect(screen.getAllByTestId('skeleton-rectangle')).toHaveLength(29) + }) +}) diff --git a/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx new file mode 100644 index 0000000000..9524998290 --- /dev/null +++ b/web/app/components/datasets/documents/detail/__tests__/context.spec.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { DocumentContext, useDocumentContext } from '../context' + +describe('DocumentContext', () => { + it('should return the default empty context value when no provider is present', () => { + const { result } = renderHook(() => useDocumentContext(value => value)) + + expect(result.current).toEqual({}) + }) + + it('should select values from the nearest provider', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook( + () => useDocumentContext(value => `${value.datasetId}:${value.documentId}`), + { wrapper }, + ) + + expect(result.current).toBe('dataset-1:document-1') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx new file mode 100644 index 0000000000..f3fa0d0929 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list-context.spec.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { SegmentListContext, useSegmentListContext } from '../segment-list-context' + +describe('SegmentListContext', () => { + it('should expose the default collapsed state', () => { + const { result } = renderHook(() => useSegmentListContext(value => value)) + + expect(result.current).toEqual({ + isCollapsed: true, + fullScreen: false, + toggleFullScreen: expect.any(Function), + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, + }) + }) + + it('should select provider values from the current segment list context', () => { + const toggleFullScreen = vi.fn() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook( + () => useSegmentListContext(value => ({ + fullScreen: value.fullScreen, + segmentOpen: value.currSegment.showModal, + childOpen: value.currChildChunk.showModal, + })), + { wrapper }, + ) + + expect(result.current).toEqual({ + fullScreen: true, + segmentOpen: true, + childOpen: true, + }) + }) +}) diff --git a/web/app/components/datasets/settings/permission-selector/index.tsx b/web/app/components/datasets/settings/permission-selector/index.tsx index a83beffbb4..cdf13e4b32 100644 --- a/web/app/components/datasets/settings/permission-selector/index.tsx +++ b/web/app/components/datasets/settings/permission-selector/index.tsx @@ -4,13 +4,13 @@ import { useDebounceFn } from 'ahooks' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Avatar } from '@/app/components/base/avatar' import Input from '@/app/components/base/input' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' +import { Avatar } from '@/app/components/base/ui/avatar' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { DatasetPermission } from '@/models/datasets' import { cn } from '@/utils/classnames' diff --git a/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx b/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx new file mode 100644 index 0000000000..b153f24179 --- /dev/null +++ b/web/app/components/develop/hooks/__tests__/use-doc-toc.spec.tsx @@ -0,0 +1,125 @@ +import type { TocItem } from '../use-doc-toc' +import { act, renderHook } from '@testing-library/react' +import { useDocToc } from '../use-doc-toc' + +const mockMatchMedia = (matches: boolean) => { + vi.stubGlobal('matchMedia', vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }))) +} + +const setupDocument = () => { + document.body.innerHTML = ` +
+ + ` + + const scrollContainer = document.querySelector('.overflow-auto') as HTMLDivElement + scrollContainer.scrollTo = vi.fn() + + const intro = document.getElementById('intro') as HTMLElement + const details = document.getElementById('details') as HTMLElement + + Object.defineProperty(intro, 'offsetTop', { configurable: true, value: 140 }) + Object.defineProperty(details, 'offsetTop', { configurable: true, value: 320 }) + + return { + scrollContainer, + intro, + details, + } +} + +describe('useDocToc', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + document.body.innerHTML = '' + mockMatchMedia(false) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('should extract headings and expand the TOC on wide screens', async () => { + setupDocument() + mockMatchMedia(true) + + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + expect(result.current.toc).toEqual([ + { href: '#intro', text: 'Intro' }, + { href: '#details', text: 'Details' }, + ]) + expect(result.current.activeSection).toBe('intro') + expect(result.current.isTocExpanded).toBe(true) + }) + + it('should update the active section when the scroll container scrolls', async () => { + const { scrollContainer, intro, details } = setupDocument() + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }) + + intro.getBoundingClientRect = vi.fn(() => ({ top: 500 } as DOMRect)) + details.getBoundingClientRect = vi.fn(() => ({ top: 300 } as DOMRect)) + + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + act(() => { + scrollContainer.dispatchEvent(new Event('scroll')) + }) + + expect(result.current.activeSection).toBe('details') + }) + + it('should scroll the container to the clicked heading offset', async () => { + const { scrollContainer } = setupDocument() + const { result } = renderHook(() => useDocToc({ + appDetail: { id: 'app-1' }, + locale: 'en', + })) + + act(() => { + vi.runAllTimers() + }) + + const preventDefault = vi.fn() + act(() => { + result.current.handleTocClick( + { preventDefault } as unknown as React.MouseEvent, + { href: '#details', text: 'Details' }, + ) + }) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(scrollContainer.scrollTo).toHaveBeenCalledWith({ + top: 240, + behavior: 'smooth', + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/__tests__/app.spec.ts b/web/app/components/goto-anything/actions/__tests__/app.spec.ts index 922be7675b..55939b2da8 100644 --- a/web/app/components/goto-anything/actions/__tests__/app.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/app.spec.ts @@ -41,12 +41,59 @@ describe('appAction', () => { url: 'apps', params: { page: 1, name: 'test' }, }) - expect(results).toHaveLength(1) + expect(results).toHaveLength(5) expect(results[0]).toMatchObject({ id: 'app-1', title: 'My App', type: 'app', }) + expect(results.slice(1).map(r => r.id)).toEqual([ + 'app-1:configuration', + 'app-1:overview', + 'app-1:logs', + 'app-1:develop', + ]) + }) + + it('returns workflow sub-sections for workflow-mode apps', async () => { + const { fetchAppList } = await import('@/service/apps') + vi.mocked(fetchAppList).mockResolvedValue({ + data: [ + { id: 'wf-1', name: 'Flow', description: '', mode: 'workflow', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + ], + has_more: false, + limit: 10, + page: 1, + total: 1, + }) + + const results = await appAction.search('@app', '', 'en') + + expect(results).toHaveLength(4) + expect(results.slice(1).map(r => r.id)).toEqual([ + 'wf-1:workflow', + 'wf-1:overview', + 'wf-1:logs', + ]) + }) + + it('returns apps without sub-sections for unscoped queries', async () => { + const { fetchAppList } = await import('@/service/apps') + vi.mocked(fetchAppList).mockResolvedValue({ + data: [ + { id: 'app-1', name: 'My App', description: '', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + { id: 'app-2', name: 'Other', description: '', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + ], + has_more: false, + limit: 10, + page: 1, + total: 2, + }) + + const results = await appAction.search('my app', 'my app', 'en') + + expect(results).toHaveLength(2) + expect(results.map(r => r.id)).toEqual(['app-1', 'app-2']) }) it('returns empty array when response has no data', async () => { diff --git a/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts b/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts new file mode 100644 index 0000000000..1594662691 --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts @@ -0,0 +1,69 @@ +import type { SearchResult } from '../types' +import { ragPipelineNodesAction } from '../rag-pipeline-nodes' +import { workflowNodesAction } from '../workflow-nodes' + +describe('workflowNodesAction', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowNodesAction.searchFn = undefined + }) + + it('should return an empty result when no workflow search function is registered', async () => { + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([]) + }) + + it('should delegate to the injected workflow search function', async () => { + const results: SearchResult[] = [ + { id: 'workflow-node-1', title: 'LLM', type: 'workflow-node', data: {} as never }, + ] + workflowNodesAction.searchFn = vi.fn().mockReturnValue(results) + + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual(results) + expect(workflowNodesAction.searchFn).toHaveBeenCalledWith('llm') + }) + + it('should warn and return an empty list when workflow node search throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + workflowNodesAction.searchFn = vi.fn(() => { + throw new Error('failed') + }) + + await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('Workflow nodes search failed:', expect.any(Error)) + + warnSpy.mockRestore() + }) +}) + +describe('ragPipelineNodesAction', () => { + beforeEach(() => { + vi.clearAllMocks() + ragPipelineNodesAction.searchFn = undefined + }) + + it('should return an empty result when no rag pipeline search function is registered', async () => { + await expect(ragPipelineNodesAction.search('@node embed', 'embed', 'en')).resolves.toEqual([]) + }) + + it('should delegate to the injected rag pipeline search function', async () => { + const results: SearchResult[] = [ + { id: 'rag-node-1', title: 'Retriever', type: 'workflow-node', data: {} as never }, + ] + ragPipelineNodesAction.searchFn = vi.fn().mockReturnValue(results) + + await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual(results) + expect(ragPipelineNodesAction.searchFn).toHaveBeenCalledWith('retrieve') + }) + + it('should warn and return an empty list when rag pipeline node search throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ragPipelineNodesAction.searchFn = vi.fn(() => { + throw new Error('failed') + }) + + await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual([]) + expect(warnSpy).toHaveBeenCalledWith('RAG pipeline nodes search failed:', expect.any(Error)) + + warnSpy.mockRestore() + }) +}) diff --git a/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts b/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts new file mode 100644 index 0000000000..d2fb346286 --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts @@ -0,0 +1,78 @@ +import { addRecentItem, getRecentItems } from '../recent-store' + +describe('recent-store', () => { + beforeEach(() => { + localStorage.clear() + }) + + describe('getRecentItems', () => { + it('returns an empty array when nothing is stored', () => { + expect(getRecentItems()).toEqual([]) + }) + + it('parses stored items from localStorage', () => { + const items = [ + { id: 'app-1', title: 'App 1', path: '/app/1', originalType: 'app' as const }, + ] + localStorage.setItem('goto-anything:recent', JSON.stringify(items)) + + expect(getRecentItems()).toEqual(items) + }) + + it('returns an empty array when stored JSON is invalid', () => { + localStorage.setItem('goto-anything:recent', 'not-json') + + expect(getRecentItems()).toEqual([]) + }) + + it('returns an empty array when localStorage throws', () => { + const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('boom') + }) + + expect(getRecentItems()).toEqual([]) + spy.mockRestore() + }) + }) + + describe('addRecentItem', () => { + it('prepends a new item to the stored list', () => { + addRecentItem({ id: 'a', title: 'A', path: '/a', originalType: 'app' }) + addRecentItem({ id: 'b', title: 'B', path: '/b', originalType: 'knowledge' }) + + const stored = getRecentItems() + expect(stored.map(i => i.id)).toEqual(['b', 'a']) + }) + + it('deduplicates by id, moving the existing entry to the front', () => { + addRecentItem({ id: 'a', title: 'A', path: '/a', originalType: 'app' }) + addRecentItem({ id: 'b', title: 'B', path: '/b', originalType: 'app' }) + addRecentItem({ id: 'a', title: 'A updated', path: '/a', originalType: 'app' }) + + const stored = getRecentItems() + expect(stored.map(i => i.id)).toEqual(['a', 'b']) + expect(stored[0].title).toBe('A updated') + }) + + it('caps the list at 8 items, evicting the oldest', () => { + for (let i = 0; i < 10; i++) + addRecentItem({ id: `item-${i}`, title: `Item ${i}`, path: `/i/${i}`, originalType: 'app' }) + + const stored = getRecentItems() + expect(stored).toHaveLength(8) + expect(stored[0].id).toBe('item-9') + expect(stored[7].id).toBe('item-2') + }) + + it('silently swallows storage errors', () => { + const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota') + }) + + expect(() => + addRecentItem({ id: 'x', title: 'X', path: '/x', originalType: 'app' }), + ).not.toThrow() + spy.mockRestore() + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/app.tsx b/web/app/components/goto-anything/actions/app.tsx index 9440d14578..ce384f4019 100644 --- a/web/app/components/goto-anything/actions/app.tsx +++ b/web/app/components/goto-anything/actions/app.tsx @@ -1,10 +1,51 @@ -import type { ActionItem, AppSearchResult } from './types' +import type { ActionItem, AppSearchResult, SearchResult } from './types' import type { App } from '@/types/app' +import { RiFileListLine, RiLayoutLine, RiLineChartLine, RiNodeTree, RiTerminalBoxLine } from '@remixicon/react' +import * as React from 'react' import { fetchAppList } from '@/service/apps' +import { AppModeEnum } from '@/types/app' import { getRedirectionPath } from '@/utils/app-redirection' import { AppTypeIcon } from '../../app/type-selector' import AppIcon from '../../base/app-icon' +const WORKFLOW_MODES = new Set([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT]) + +type AppSection = { id: string, label: string, path: string, icon: React.ElementType } + +const getAppSections = (app: App): AppSection[] => { + const base = `/app/${app.id}` + if (WORKFLOW_MODES.has(app.mode)) { + return [ + { id: 'workflow', label: 'Workflow', path: `${base}/workflow`, icon: RiNodeTree }, + { id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine }, + { id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine }, + ] + } + return [ + { id: 'configuration', label: 'Configuration', path: `${base}/configuration`, icon: RiLayoutLine }, + { id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine }, + { id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine }, + { id: 'develop', label: 'Develop', path: `${base}/develop`, icon: RiTerminalBoxLine }, + ] +} + +const appIcon = (app: App) => ( +
+ + +
+) + const parser = (apps: App[]): AppSearchResult[] => { return apps.map(app => ({ id: app.id, @@ -15,33 +56,50 @@ const parser = (apps: App[]): AppSearchResult[] => { id: app.id, mode: app.mode, }), - icon: ( -
- - -
- ), + icon: appIcon(app), data: app, })) } +// Generate sub-section results for matched apps when in scoped @app search +const parserWithSections = (apps: App[]): SearchResult[] => { + const results: SearchResult[] = [] + for (const app of apps) { + results.push({ + id: app.id, + title: app.name, + description: app.description, + type: 'app' as const, + path: getRedirectionPath(true, { id: app.id, mode: app.mode }), + icon: appIcon(app), + data: app, + }) + for (const section of getAppSections(app)) { + results.push({ + id: `${app.id}:${section.id}`, + title: `${app.name} / ${section.label}`, + description: section.path, + type: 'app' as const, + path: section.path, + icon: ( +
+ +
+ ), + data: app, + }) + } + } + return results +} + export const appAction: ActionItem = { key: '@app', shortcut: '@app', title: 'Search Applications', description: 'Search and navigate to your applications', - // action, - search: async (_, searchTerm = '', _locale) => { + search: async (query, searchTerm = '', _locale) => { + const isScoped = query.trimStart().startsWith('@app') || query.trimStart().startsWith('@App') try { const response = await fetchAppList({ url: 'apps', @@ -51,7 +109,7 @@ export const appAction: ActionItem = { }, }) const apps = response?.data || [] - return parser(apps) + return isScoped ? parserWithSections(apps) : parser(apps) } catch (error) { console.warn('App search failed:', error) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx new file mode 100644 index 0000000000..719eddf77b --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx @@ -0,0 +1,106 @@ +import { registerCommands, unregisterCommands } from '../command-bus' +import { goCommand } from '../go' + +vi.mock('../command-bus') + +describe('goCommand', () => { + let originalHref: string + + beforeEach(() => { + vi.clearAllMocks() + originalHref = window.location.href + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true }) + }) + + it('has correct metadata', () => { + expect(goCommand.name).toBe('go') + expect(goCommand.mode).toBe('submenu') + expect(goCommand.aliases).toEqual(['navigate', 'nav']) + expect(goCommand.execute).toBeUndefined() + }) + + describe('search', () => { + it('returns all navigation items when query is empty', async () => { + const results = await goCommand.search('', 'en') + + expect(results.map(r => r.id)).toEqual([ + 'go-apps', + 'go-datasets', + 'go-plugins', + 'go-tools', + 'go-explore', + 'go-account', + ]) + }) + + it('filters by id match', async () => { + const results = await goCommand.search('plugins', 'en') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('go-plugins') + }) + + it('filters by label match (case-insensitive)', async () => { + const results = await goCommand.search('Knowledge', 'en') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('go-datasets') + expect(results[0].title).toBe('Knowledge') + }) + + it('returns command results with navigation.go data', async () => { + const results = await goCommand.search('apps', 'en') + + expect(results[0]).toMatchObject({ + type: 'command', + title: 'Apps', + description: '/apps', + data: { command: 'navigation.go', args: { path: '/apps' } }, + }) + }) + + it('returns an empty list when nothing matches', async () => { + const results = await goCommand.search('no-such-section', 'en') + + expect(results).toEqual([]) + }) + }) + + describe('register / unregister', () => { + it('registers navigation.go command', () => { + goCommand.register?.({} as Record) + + expect(registerCommands).toHaveBeenCalledWith({ 'navigation.go': expect.any(Function) }) + }) + + it('unregisters navigation.go command', () => { + goCommand.unregister?.() + + expect(unregisterCommands).toHaveBeenCalledWith(['navigation.go']) + }) + + it('registered handler navigates to the provided path', async () => { + Object.defineProperty(window, 'location', { value: { href: '' }, writable: true }) + goCommand.register?.({} as Record) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + + await handlers['navigation.go']({ path: '/datasets' }) + + expect(window.location.href).toBe('/datasets') + }) + + it('registered handler does nothing when path is missing', async () => { + Object.defineProperty(window, 'location', { value: { href: '/current' }, writable: true }) + goCommand.register?.({} as Record) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + + await handlers['navigation.go']() + await handlers['navigation.go']({}) + + expect(window.location.href).toBe('/current') + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx new file mode 100644 index 0000000000..f4825834cc --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx @@ -0,0 +1,126 @@ +import type { SearchResult } from '../../types' +import { render } from '@testing-library/react' +import { slashAction, SlashCommandProvider } from '../slash' + +const { + mockSetTheme, + mockSetLocale, + mockExecuteCommand, + mockRegister, + mockSearch, + mockUnregister, +} = vi.hoisted(() => ({ + mockSetTheme: vi.fn(), + mockSetLocale: vi.fn(), + mockExecuteCommand: vi.fn(), + mockRegister: vi.fn(), + mockSearch: vi.fn(), + mockUnregister: vi.fn(), +})) + +vi.mock('next-themes', () => ({ + useTheme: () => ({ + setTheme: mockSetTheme, + }), +})) + +vi.mock('react-i18next', () => ({ + getI18n: () => ({ + language: 'ja', + t: (key: string) => key, + }), +})) + +vi.mock('@/i18n-config', () => ({ + setLocaleOnClient: mockSetLocale, +})) + +vi.mock('../command-bus', () => ({ + executeCommand: (...args: unknown[]) => mockExecuteCommand(...args), +})) + +vi.mock('../registry', () => ({ + slashCommandRegistry: { + register: (...args: unknown[]) => mockRegister(...args), + search: (...args: unknown[]) => mockSearch(...args), + unregister: (...args: unknown[]) => mockUnregister(...args), + }, +})) + +describe('slashAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should expose translated title and description', () => { + expect(slashAction.title).toBe('gotoAnything.actions.slashTitle') + expect(slashAction.description).toBe('gotoAnything.actions.slashDesc') + }) + + it('should execute command results and ignore non-command results', () => { + slashAction.action?.({ + id: 'cmd-1', + title: 'Command', + type: 'command', + data: { + command: 'navigation.docs', + args: { path: '/docs' }, + }, + } as SearchResult) + + slashAction.action?.({ + id: 'app-1', + title: 'App', + type: 'app', + data: {} as never, + } as SearchResult) + + expect(mockExecuteCommand).toHaveBeenCalledTimes(1) + expect(mockExecuteCommand).toHaveBeenCalledWith('navigation.docs', { path: '/docs' }) + }) + + it('should delegate search to the slash command registry with the active language', async () => { + mockSearch.mockResolvedValue([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }]) + + const results = await slashAction.search('/theme dark', 'dark') + + expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja') + expect(results).toEqual([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }]) + }) +}) + +describe('SlashCommandProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should register commands on mount and unregister them on unmount', () => { + const { unmount } = render() + + expect(mockRegister.mock.calls.map(call => call[0].name)).toEqual([ + 'theme', + 'language', + 'forum', + 'docs', + 'community', + 'account', + 'zen', + 'go', + ]) + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme }) + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale }) + + unmount() + + expect(mockUnregister.mock.calls.map(call => call[0])).toEqual([ + 'theme', + 'language', + 'forum', + 'docs', + 'community', + 'account', + 'zen', + 'go', + ]) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/go.tsx b/web/app/components/goto-anything/actions/commands/go.tsx new file mode 100644 index 0000000000..54de829897 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/go.tsx @@ -0,0 +1,62 @@ +import type { SlashCommandHandler } from './types' +import { + RiApps2Line, + RiBookOpenLine, + RiCompassLine, + RiPlugLine, + RiToolsLine, + RiUserLine, +} from '@remixicon/react' +import * as React from 'react' +import { registerCommands, unregisterCommands } from './command-bus' + +const NAV_ITEMS = [ + { id: 'apps', label: 'Apps', path: '/apps', icon: RiApps2Line }, + { id: 'datasets', label: 'Knowledge', path: '/datasets', icon: RiBookOpenLine }, + { id: 'plugins', label: 'Plugins', path: '/plugins', icon: RiPlugLine }, + { id: 'tools', label: 'Tools', path: '/tools', icon: RiToolsLine }, + { id: 'explore', label: 'Explore', path: '/explore', icon: RiCompassLine }, + { id: 'account', label: 'Account', path: '/account', icon: RiUserLine }, +] + +/** + * Go command - Navigate to a top-level section of the app + */ +export const goCommand: SlashCommandHandler = { + name: 'go', + aliases: ['navigate', 'nav'], + description: 'Navigate to a section', + mode: 'submenu', + + async search(args: string, _locale: string = 'en') { + const query = args.trim().toLowerCase() + const items = NAV_ITEMS.filter( + item => !query || item.id.includes(query) || item.label.toLowerCase().includes(query), + ) + return items.map(item => ({ + id: `go-${item.id}`, + title: item.label, + description: item.path, + type: 'command' as const, + icon: ( +
+ +
+ ), + data: { command: 'navigation.go', args: { path: item.path } }, + })) + }, + + register() { + registerCommands({ + 'navigation.go': async (args) => { + if (args?.path) + window.location.href = args.path + }, + }) + }, + + unregister() { + unregisterCommands(['navigation.go']) + }, +} diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index a5db24be41..20584eef23 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -9,6 +9,7 @@ import { executeCommand } from './command-bus' import { communityCommand } from './community' import { docsCommand } from './docs' import { forumCommand } from './forum' +import { goCommand } from './go' import { languageCommand } from './language' import { slashCommandRegistry } from './registry' import { themeCommand } from './theme' @@ -48,6 +49,7 @@ const registerSlashCommands = (deps: Record) => { slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) slashCommandRegistry.register(zenCommand, {}) + slashCommandRegistry.register(goCommand, {}) } const unregisterSlashCommands = () => { @@ -59,6 +61,7 @@ const unregisterSlashCommands = () => { slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') slashCommandRegistry.unregister('zen') + slashCommandRegistry.unregister('go') } export const SlashCommandProvider = () => { diff --git a/web/app/components/goto-anything/actions/recent-store.ts b/web/app/components/goto-anything/actions/recent-store.ts new file mode 100644 index 0000000000..0946818091 --- /dev/null +++ b/web/app/components/goto-anything/actions/recent-store.ts @@ -0,0 +1,30 @@ +const RECENT_ITEMS_KEY = 'goto-anything:recent' +const MAX_RECENT_ITEMS = 8 + +export function getRecentItems() { + try { + const stored = localStorage.getItem(RECENT_ITEMS_KEY) + if (!stored) + return [] + return JSON.parse(stored) as Array<{ + id: string + title: string + description?: string + path: string + originalType: 'app' | 'knowledge' + }> + } + catch { + return [] + } +} + +export function addRecentItem(item: ReturnType[number]): void { + try { + const recent = getRecentItems() + const filtered = recent.filter(r => r.id !== item.id) + const updated = [item, ...filtered].slice(0, MAX_RECENT_ITEMS) + localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(updated)) + } + catch {} +} diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 838195ad85..7d1ddfd4e1 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -5,7 +5,7 @@ import type { CommonNodeType } from '../../workflow/types' import type { DataSet } from '@/models/datasets' import type { App } from '@/types/app' -export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' +export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | 'recent' export type BaseSearchResult = { id: string @@ -41,7 +41,12 @@ export type CommandSearchResult = { type: 'command' } & BaseSearchResult<{ command: string, args?: Record }> -export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult +export type RecentSearchResult = { + type: 'recent' + originalType: 'app' | 'knowledge' +} & BaseSearchResult<{ path: string }> + +export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult | RecentSearchResult export type ActionItem = { key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' diff --git a/web/app/components/goto-anything/components/result-list.tsx b/web/app/components/goto-anything/components/result-list.tsx index 3a380dea5f..70d2c6c61e 100644 --- a/web/app/components/goto-anything/components/result-list.tsx +++ b/web/app/components/goto-anything/components/result-list.tsx @@ -21,6 +21,7 @@ const ResultList: FC = ({ groupedResults, onSelect }) => { 'knowledge': 'gotoAnything.groups.knowledgeBases', 'workflow-node': 'gotoAnything.groups.workflowNodes', 'command': 'gotoAnything.groups.commands', + 'recent': 'gotoAnything.groups.recent', } as const return t(typeMap[type as keyof typeof typeMap] || `${type}s`, { ns: 'app' }) } diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts index c8a6a4a13c..b3dc034216 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts @@ -8,6 +8,7 @@ import { useGotoAnythingNavigation } from '../use-goto-anything-navigation' const mockRouterPush = vi.fn() const mockSelectWorkflowNode = vi.fn() +const mockAddRecentItem = vi.fn() type MockCommandResult = { mode: string @@ -32,6 +33,10 @@ vi.mock('../../actions/commands/registry', () => ({ }, })) +vi.mock('../../actions/recent-store', () => ({ + addRecentItem: (...args: unknown[]) => mockAddRecentItem(...args), +})) + const createMockActionItem = ( key: '@app' | '@knowledge' | '@plugin' | '@node' | '/', extra: Record = {}, @@ -314,6 +319,107 @@ describe('useGotoAnythingNavigation', () => { expect(mockRouterPush).toHaveBeenCalledWith('/datasets/kb-1') }) + + it('should record app navigation to recent history', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'app-1', + type: 'app' as const, + title: 'My App', + description: 'Desc', + path: '/app/app-1', + data: { id: 'app-1', name: 'My App' } as unknown as App, + }) + }) + + expect(mockAddRecentItem).toHaveBeenCalledWith({ + id: 'app-1', + title: 'My App', + description: 'Desc', + path: '/app/app-1', + originalType: 'app', + }) + }) + + it('should record knowledge navigation to recent history', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'kb-1', + type: 'knowledge' as const, + title: 'My KB', + path: '/datasets/kb-1', + data: { id: 'kb-1', name: 'My KB' } as unknown as DataSet, + }) + }) + + expect(mockAddRecentItem).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kb-1', originalType: 'knowledge' }), + ) + }) + + it('should NOT record to recent history when path is missing', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'app-1', + type: 'app' as const, + title: 'My App', + path: '', + data: { id: 'app-1', name: 'My App' } as unknown as App, + }) + }) + + expect(mockAddRecentItem).not.toHaveBeenCalled() + }) + + it('should navigate for recent type without recording again', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'recent-app-1', + type: 'recent' as const, + originalType: 'app', + title: 'My App', + path: '/app/app-1', + data: { path: '/app/app-1' }, + }) + }) + + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-1') + expect(mockAddRecentItem).not.toHaveBeenCalled() + }) + + it('should NOT call router.push for recent type when path is missing', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'recent-app-1', + type: 'recent' as const, + originalType: 'app', + title: 'My App', + data: { path: '' }, + }) + }) + + expect(mockRouterPush).not.toHaveBeenCalled() + }) }) describe('setActivePlugin', () => { diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts index faaf0bbd1e..b1b543d35a 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts @@ -29,12 +29,17 @@ vi.mock('@/context/i18n', () => ({ const mockMatchAction = vi.fn() const mockSearchAnything = vi.fn() +const mockGetRecentItems = vi.fn(() => [] as Array>) vi.mock('../../actions', () => ({ matchAction: (...args: unknown[]) => mockMatchAction(...args), searchAnything: (...args: unknown[]) => mockSearchAnything(...args), })) +vi.mock('../../actions/recent-store', () => ({ + getRecentItems: () => mockGetRecentItems(), +})) + const createMockActionItem = (key: '@app' | '@knowledge' | '@plugin' | '@node' | '/') => ({ key, shortcut: key, @@ -61,6 +66,7 @@ describe('useGotoAnythingResults', () => { capturedQueryFn = null mockMatchAction.mockReset() mockSearchAnything.mockReset() + mockGetRecentItems.mockReturnValue([]) }) describe('initialization', () => { @@ -297,6 +303,59 @@ describe('useGotoAnythingResults', () => { }) }) + describe('recent results', () => { + it('surfaces recent items when the search query is empty', () => { + mockGetRecentItems.mockReturnValue([ + { id: 'app-1', title: 'My App', description: 'Desc', path: '/app/app-1', originalType: 'app' }, + { id: 'kb-1', title: 'My KB', path: '/datasets/kb-1', originalType: 'knowledge' }, + ]) + + const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ + searchQueryDebouncedValue: '', + }))) + + expect(result.current.dedupedResults).toHaveLength(2) + expect(result.current.dedupedResults[0]).toMatchObject({ + id: 'recent-app-1', + type: 'recent', + originalType: 'app', + path: '/app/app-1', + data: { path: '/app/app-1' }, + }) + expect(result.current.groupedResults.recent).toHaveLength(2) + }) + + it('does not surface recent items when a query is active', () => { + mockGetRecentItems.mockReturnValue([ + { id: 'app-1', title: 'My App', path: '/app/app-1', originalType: 'app' }, + ]) + mockQueryResult = { + data: [{ id: 's1', type: 'app', title: 'Searched' }], + isLoading: false, + isError: false, + error: null, + } + + const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ + searchQueryDebouncedValue: 'foo', + }))) + + expect(result.current.dedupedResults.map(r => r.id)).toEqual(['s1']) + }) + + it('does not surface recent items in commands mode', () => { + mockGetRecentItems.mockReturnValue([ + { id: 'app-1', title: 'My App', path: '/app/app-1', originalType: 'app' }, + ]) + + const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ + isCommandsMode: true, + }))) + + expect(result.current.dedupedResults).toEqual([]) + }) + }) + describe('queryFn execution', () => { it('should call matchAction with lowercased query', async () => { const mockActions = { app: createMockActionItem('@app') } diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts index 1c75adbf72..a3be296c8c 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts @@ -7,6 +7,7 @@ import { useCallback, useState } from 'react' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useRouter } from '@/next/navigation' import { slashCommandRegistry } from '../actions/commands/registry' +import { addRecentItem } from '../actions/recent-store' type UseGotoAnythingNavigationReturn = { handleCommandSelect: (commandKey: string) => void @@ -80,8 +81,23 @@ export const useGotoAnythingNavigation = ( if (result.metadata?.nodeId) selectWorkflowNode(result.metadata.nodeId, true) + break + case 'recent': + if (result.path) + router.push(result.path) + break default: + // Record to recent history for app and knowledge results + if ((result.type === 'app' || result.type === 'knowledge') && result.path) { + addRecentItem({ + id: result.id, + title: result.title, + description: result.description, + path: result.path, + originalType: result.type, + }) + } if (result.path) router.push(result.path) } diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-results.ts b/web/app/components/goto-anything/hooks/use-goto-anything-results.ts index 8fac699fdc..36e8397b6f 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-results.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-results.ts @@ -1,10 +1,13 @@ 'use client' -import type { ActionItem, SearchResult } from '../actions/types' +import type { ActionItem, RecentSearchResult, SearchResult } from '../actions/types' +import { RiTimeLine } from '@remixicon/react' import { useQuery } from '@tanstack/react-query' +import * as React from 'react' import { useEffect, useMemo } from 'react' import { useGetLanguage } from '@/context/i18n' import { matchAction, searchAnything } from '../actions' +import { getRecentItems } from '../actions/recent-store' type UseGotoAnythingResultsReturn = { searchResults: SearchResult[] @@ -70,16 +73,37 @@ export const useGotoAnythingResults = ( }, ) + // Build recent items to show when search is empty + const recentResults = useMemo((): RecentSearchResult[] => { + if (searchQueryDebouncedValue || isCommandsMode) + return [] + return getRecentItems().map(item => ({ + id: `recent-${item.id}`, + title: item.title, + description: item.description, + type: 'recent' as const, + originalType: item.originalType, + path: item.path, + icon: React.createElement( + 'div', + { className: 'flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg' }, + React.createElement(RiTimeLine, { className: 'h-4 w-4 text-text-tertiary' }), + ), + data: { path: item.path }, + })) + }, [searchQueryDebouncedValue, isCommandsMode]) + const dedupedResults = useMemo(() => { + const allResults = recentResults.length ? recentResults : searchResults const seen = new Set() - return searchResults.filter((result) => { + return allResults.filter((result) => { const key = `${result.type}-${result.id}` if (seen.has(key)) return false seen.add(key) return true }) - }, [searchResults]) + }, [searchResults, recentResults]) // Group results by type const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => { diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index d0b2502408..829a95f7e7 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -4,7 +4,7 @@ import type { FC, KeyboardEvent } from 'react' import { Command } from 'cmdk' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' +import { Dialog, DialogContent } from '@/app/components/base/ui/dialog' import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' import { SlashCommandProvider } from './actions/commands' import { slashCommandRegistry } from './actions/commands/registry' @@ -131,8 +131,10 @@ const GotoAnything: FC = ({ return 'loading' if (isError) return 'error' - if (!searchQuery.trim()) - return 'default' + if (!searchQuery.trim()) { + // Show default hint only when there are no recent items to display + return dedupedResults.length === 0 ? 'default' : null + } if (dedupedResults.length === 0 && !isCommandsMode) return 'no-results' return null @@ -141,14 +143,14 @@ const GotoAnything: FC = ({ return ( <> - { + if (!open) + modalClose() + }} > -
+ = ({ hasQuery={!!searchQuery.trim()} /> -
-
+ +
{activePlugin && ( { + it('should render the icon, label, and trailing content', () => { + const { container } = render( + Soon} + />, + ) + + expect(screen.getByText('Settings')).toBeInTheDocument() + expect(screen.getByTestId('menu-trailing')).toHaveTextContent('Soon') + expect(container.querySelector('.i-ri-settings-4-line')).toBeInTheDocument() + }) +}) + +describe('ExternalLinkIndicator', () => { + it('should render the external-link icon with aria-hidden semantics', () => { + const { container } = render() + + const indicator = container.querySelector('.i-ri-arrow-right-up-line') + expect(indicator).toBeInTheDocument() + expect(indicator).toHaveAttribute('aria-hidden') + }) +}) diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index f5b0352a40..442554615b 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -4,9 +4,9 @@ import type { MouseEventHandler, ReactNode } from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' -import { Avatar } from '@/app/components/base/avatar' import PremiumBadge from '@/app/components/base/premium-badge' import ThemeSwitcher from '@/app/components/base/theme-switcher' +import { Avatar } from '@/app/components/base/ui/avatar' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { IS_CLOUD_EDITION } from '@/config' diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index 875ffba3e0..6ac9ee5d2d 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -2,7 +2,7 @@ import type { InvitationResult } from '@/models/common' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Avatar } from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/ui/avatar' import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { NUM_INFINITE } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx index a6617ac4d2..59e69b92e2 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx @@ -3,9 +3,9 @@ import type { FC } from 'react' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Avatar } from '@/app/components/base/avatar' import Input from '@/app/components/base/input' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { Avatar } from '@/app/components/base/ui/avatar' import { useMembers } from '@/service/use-common' import { cn } from '@/utils/classnames' diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts new file mode 100644 index 0000000000..7387234c67 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/index.spec.ts @@ -0,0 +1,23 @@ +import * as ModelAuth from '../index' + +vi.mock('../add-credential-in-load-balancing', () => ({ default: 'AddCredentialInLoadBalancing' })) +vi.mock('../add-custom-model', () => ({ default: 'AddCustomModel' })) +vi.mock('../authorized', () => ({ default: 'Authorized' })) +vi.mock('../config-model', () => ({ default: 'ConfigModel' })) +vi.mock('../credential-selector', () => ({ default: 'CredentialSelector' })) +vi.mock('../manage-custom-model-credentials', () => ({ default: 'ManageCustomModelCredentials' })) +vi.mock('../switch-credential-in-load-balancing', () => ({ default: 'SwitchCredentialInLoadBalancing' })) + +describe('model-auth index exports', () => { + it('should re-export the model auth entry points', () => { + expect(ModelAuth).toMatchObject({ + AddCredentialInLoadBalancing: 'AddCredentialInLoadBalancing', + AddCustomModel: 'AddCustomModel', + Authorized: 'Authorized', + ConfigModel: 'ConfigModel', + CredentialSelector: 'CredentialSelector', + ManageCustomModelCredentials: 'ManageCustomModelCredentials', + SwitchCredentialInLoadBalancing: 'SwitchCredentialInLoadBalancing', + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index b19ef2a2fc..fc2cbe0179 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -354,7 +354,7 @@ function ParameterItem({ )} /> - +
{parameterRule.help[language] || parameterRule.help.en_US}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index b68d2f09d6..5fb1135039 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -200,7 +200,7 @@ const PopupItem: FC = ({
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx new file mode 100644 index 0000000000..2d634d8673 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/credits-fallback-alert.spec.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import CreditsFallbackAlert from '../credits-fallback-alert' + +describe('CreditsFallbackAlert', () => { + it('should render the credential fallback copy and description when credentials exist', () => { + render() + + expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallback')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).toBeInTheDocument() + }) + + it('should render the no-credentials fallback copy without the description', () => { + render() + + expect(screen.getByText('common.modelProvider.card.noApiKeysFallback')).toBeInTheDocument() + expect(screen.queryByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx b/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx new file mode 100644 index 0000000000..2481a8c0b3 --- /dev/null +++ b/web/app/components/header/plugins-nav/__tests__/downloading-icon.spec.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react' +import DownloadingIcon from '../downloading-icon' + +describe('DownloadingIcon', () => { + it('should render the animated install icon wrapper and svg markup', () => { + const { container } = render() + + const wrapper = container.firstElementChild as HTMLElement + const svg = container.querySelector('svg.install-icon') + + expect(wrapper).toHaveClass('inline-flex', 'text-components-button-secondary-text') + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('viewBox', '0 0 24 24') + expect(svg?.querySelectorAll('path')).toHaveLength(3) + }) +}) diff --git a/web/app/components/plugins/__tests__/constants.spec.ts b/web/app/components/plugins/__tests__/constants.spec.ts new file mode 100644 index 0000000000..d3ec02c76c --- /dev/null +++ b/web/app/components/plugins/__tests__/constants.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { categoryKeys, tagKeys } from '../constants' +import { PluginCategoryEnum } from '../types' + +describe('plugin constants', () => { + it('exposes the expected plugin tag keys', () => { + expect(tagKeys).toEqual([ + 'agent', + 'rag', + 'search', + 'image', + 'videos', + 'weather', + 'finance', + 'design', + 'travel', + 'social', + 'news', + 'medical', + 'productivity', + 'education', + 'business', + 'entertainment', + 'utilities', + 'other', + ]) + }) + + it('exposes the expected category keys in display order', () => { + expect(categoryKeys).toEqual([ + PluginCategoryEnum.model, + PluginCategoryEnum.tool, + PluginCategoryEnum.datasource, + PluginCategoryEnum.agent, + PluginCategoryEnum.extension, + 'bundle', + PluginCategoryEnum.trigger, + ]) + }) +}) diff --git a/web/app/components/plugins/__tests__/provider-card.spec.tsx b/web/app/components/plugins/__tests__/provider-card.spec.tsx new file mode 100644 index 0000000000..71efd86bb0 --- /dev/null +++ b/web/app/components/plugins/__tests__/provider-card.spec.tsx @@ -0,0 +1,104 @@ +import type { Plugin } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { ThemeProvider } from 'next-themes' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ProviderCard from '../provider-card' +import { PluginCategoryEnum } from '../types' + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (value: Record) => value['en-US'] || value.en_US, +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/marketplace/utils', () => ({ + getPluginLinkInMarketplace: (plugin: Plugin, params: Record) => + `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, +})) + +vi.mock('../card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
{src}
, +})) + +vi.mock('../card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../card/base/download-count', () => ({ + default: ({ downloadCount }: { downloadCount: number }) =>
{downloadCount}
, +})) + +vi.mock('../card/base/title', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +const payload = { + type: 'plugin', + org: 'dify', + name: 'provider-one', + plugin_id: 'provider-one', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'pkg-1', + icon: 'icon.png', + verified: true, + label: { 'en-US': 'Provider One' }, + brief: { 'en-US': 'Provider description' }, + description: { 'en-US': 'Full description' }, + introduction: 'Intro', + repository: 'https://github.com/dify/provider-one', + category: PluginCategoryEnum.tool, + install_count: 123, + endpoint: { settings: [] }, + tags: [{ name: 'search' }, { name: 'rag' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', +} as Plugin + +describe('ProviderCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderProviderCard = () => render( + + + , + ) + + it('renders provider information, tags, and detail link', () => { + renderProviderCard() + + expect(screen.getByTestId('title')).toHaveTextContent('Provider One') + expect(screen.getByText('dify')).toBeInTheDocument() + expect(screen.getByTestId('download-count')).toHaveTextContent('123') + expect(screen.getByTestId('description')).toHaveTextContent('Provider description') + expect(screen.getByText('search')).toBeInTheDocument() + expect(screen.getByText('rag')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /plugin.detailPanel.operation.detail/i })).toHaveAttribute( + 'href', + '/marketplace/dify/provider-one?language=en-US&theme=system', + ) + }) + + it('opens and closes the install modal', () => { + renderProviderCard() + + fireEvent.click(screen.getByRole('button', { name: /plugin.detailPanel.operation.install/i })) + expect(screen.getByTestId('install-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('close-install-modal')) + expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts b/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts new file mode 100644 index 0000000000..c5364ec47f --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts @@ -0,0 +1,22 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import useGetIcon from '../use-get-icon' + +vi.mock('@/config', () => ({ + API_PREFIX: 'https://api.example.com', +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { currentWorkspace: { id: string } }) => string | { id: string }) => + selector({ currentWorkspace: { id: 'workspace-123' } }), +})) + +describe('useGetIcon', () => { + it('builds icon url with current workspace id', () => { + const { result } = renderHook(() => useGetIcon()) + + expect(result.current.getIconUrl('plugin-icon.png')).toBe( + 'https://api.example.com/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=plugin-icon.png', + ) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx new file mode 100644 index 0000000000..12cd89765a --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx @@ -0,0 +1,136 @@ +import type { GitHubItemAndMarketPlaceDependency, Plugin } from '../../../../types' +import type { VersionProps } from '@/app/components/plugins/types' +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import GithubItem from '../github-item' + +const mockUseUploadGitHub = vi.fn() +const mockPluginManifestToCardPluginProps = vi.fn() +const mockLoadedItem = vi.fn() + +vi.mock('@/service/use-plugins', () => ({ + useUploadGitHub: (params: { repo: string, version: string, package: string }) => mockUseUploadGitHub(params), +})) + +vi.mock('../../../utils', () => ({ + pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest), +})) + +vi.mock('../../../base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('../loaded-item', () => ({ + default: (props: Record) => { + mockLoadedItem(props) + return
loaded-item
+ }, +})) + +const dependency: GitHubItemAndMarketPlaceDependency = { + type: 'github', + value: { + repo: 'dify/plugin', + release: 'v1.0.0', + package: 'plugin.zip', + }, +} + +const versionInfo: VersionProps = { + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', +} + +describe('GithubItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading state before payload is ready', () => { + mockUseUploadGitHub.mockReturnValue({ data: null, error: null }) + + render( + , + ) + + expect(screen.getByTestId('loading')).toBeInTheDocument() + expect(mockUseUploadGitHub).toHaveBeenCalledWith({ + repo: 'dify/plugin', + version: 'v1.0.0', + package: 'plugin.zip', + }) + }) + + it('converts fetched manifest and renders LoadedItem', async () => { + const onFetchedPayload = vi.fn() + const payload = { + plugin_id: 'plugin-1', + name: 'Plugin One', + org: 'dify', + icon: 'icon.png', + version: '1.0.0', + } as Plugin + + mockUseUploadGitHub.mockReturnValue({ + data: { + manifest: { name: 'manifest' }, + unique_identifier: 'plugin-1', + }, + error: null, + }) + mockPluginManifestToCardPluginProps.mockReturnValue(payload) + + render( + , + ) + + await waitFor(() => { + expect(onFetchedPayload).toHaveBeenCalledWith(payload) + expect(screen.getByTestId('loaded-item')).toBeInTheDocument() + }) + + expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ + checked: true, + versionInfo, + payload: expect.objectContaining({ + ...payload, + from: 'github', + }), + })) + }) + + it('reports fetch error from upload hook', async () => { + const onFetchError = vi.fn() + mockUseUploadGitHub.mockReturnValue({ data: null, error: new Error('boom') }) + + render( + , + ) + + await waitFor(() => { + expect(onFetchError).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx new file mode 100644 index 0000000000..d19331a4e4 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx @@ -0,0 +1,160 @@ +import type { Plugin } from '../../../../types' +import type { VersionProps } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LoadedItem from '../loaded-item' + +const mockCheckbox = vi.fn() +const mockCard = vi.fn() +const mockVersion = vi.fn() +const mockUsePluginInstallLimit = vi.fn() + +vi.mock('@/config', () => ({ + API_PREFIX: 'https://api.example.com', + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: (props: { checked: boolean, disabled: boolean, onCheck: () => void }) => { + mockCheckbox(props) + return ( + + ) + }, +})) + +vi.mock('../../../../card', () => ({ + default: (props: { titleLeft?: React.ReactNode }) => { + mockCard(props) + return ( +
+ {props.titleLeft} +
+ ) + }, +})) + +vi.mock('../../../base/use-get-icon', () => ({ + default: () => ({ + getIconUrl: (icon: string) => `https://api.example.com/${icon}`, + }), +})) + +vi.mock('../../../base/version', () => ({ + default: (props: Record) => { + mockVersion(props) + return
version
+ }, +})) + +vi.mock('../../../hooks/use-install-plugin-limit', () => ({ + default: (payload: Plugin) => mockUsePluginInstallLimit(payload), +})) + +const payload = { + plugin_id: 'plugin-1', + org: 'dify', + name: 'Loaded Plugin', + icon: 'icon.png', + version: '1.0.0', +} as Plugin + +const versionInfo: VersionProps = { + hasInstalled: false, + installedVersion: '', + toInstallVersion: '0.9.0', +} + +describe('LoadedItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUsePluginInstallLimit.mockReturnValue({ canInstall: true }) + }) + + it('uses local icon url and forwards version title for non-marketplace plugins', () => { + render( + , + ) + + expect(screen.getByTestId('card')).toBeInTheDocument() + expect(mockUsePluginInstallLimit).toHaveBeenCalledWith(payload) + expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ + limitedInstall: false, + payload: expect.objectContaining({ + ...payload, + icon: 'https://api.example.com/icon.png', + }), + titleLeft: expect.anything(), + })) + expect(mockVersion).toHaveBeenCalledWith(expect.objectContaining({ + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', + })) + }) + + it('uses marketplace icon url and disables checkbox when install limit is reached', () => { + mockUsePluginInstallLimit.mockReturnValue({ canInstall: false }) + + render( + , + ) + + expect(screen.getByTestId('checkbox')).toBeDisabled() + expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ + limitedInstall: true, + payload: expect.objectContaining({ + icon: 'https://marketplace.example.com/plugins/dify/Loaded Plugin/icon', + }), + })) + }) + + it('calls onCheckedChange with payload when checkbox is toggled', () => { + const onCheckedChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('checkbox')) + + expect(onCheckedChange).toHaveBeenCalledWith(payload) + }) + + it('omits version badge when payload has no version', () => { + render( + , + ) + + expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ + titleLeft: null, + })) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx new file mode 100644 index 0000000000..b6c1763ac5 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx @@ -0,0 +1,69 @@ +import type { Plugin } from '../../../../types' +import type { VersionProps } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import MarketPlaceItem from '../marketplace-item' + +const mockLoadedItem = vi.fn() + +vi.mock('../../../base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('../loaded-item', () => ({ + default: (props: Record) => { + mockLoadedItem(props) + return
loaded-item
+ }, +})) + +const payload = { + plugin_id: 'plugin-1', + org: 'dify', + name: 'Marketplace Plugin', + icon: 'icon.png', +} as Plugin + +const versionInfo: VersionProps = { + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', +} + +describe('MarketPlaceItem', () => { + it('renders loading when payload is absent', () => { + render( + , + ) + + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('renders LoadedItem with marketplace payload and version', () => { + render( + , + ) + + expect(screen.getByTestId('loaded-item')).toBeInTheDocument() + expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ + checked: true, + isFromMarketPlace: true, + versionInfo, + payload: expect.objectContaining({ + ...payload, + version: '2.0.0', + }), + })) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx new file mode 100644 index 0000000000..e92faeb77f --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx @@ -0,0 +1,124 @@ +import type { PackageDependency } from '../../../../types' +import type { VersionProps } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../../../types' +import PackageItem from '../package-item' + +const mockPluginManifestToCardPluginProps = vi.fn() +const mockLoadedItem = vi.fn() + +vi.mock('../../../utils', () => ({ + pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest), +})) + +vi.mock('../../../base/loading-error', () => ({ + default: () =>
loading-error
, +})) + +vi.mock('../loaded-item', () => ({ + default: (props: Record) => { + mockLoadedItem(props) + return
loaded-item
+ }, +})) + +const versionInfo: VersionProps = { + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', +} + +const payload = { + type: 'package', + value: { + manifest: { + plugin_unique_identifier: 'plugin-1', + version: '1.0.0', + author: 'dify', + icon: 'icon.png', + name: 'Package Plugin', + category: PluginCategoryEnum.tool, + label: { en_US: 'Package Plugin', zh_Hans: 'Package Plugin' }, + description: { en_US: 'Description', zh_Hans: 'Description' }, + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: { + events: [], + identity: { + author: 'dify', + name: 'trigger', + description: { en_US: 'Trigger', zh_Hans: 'Trigger' }, + icon: 'icon.png', + label: { en_US: 'Trigger', zh_Hans: 'Trigger' }, + tags: [], + }, + subscription_constructor: { + credentials_schema: [], + oauth_schema: { + client_schema: [], + credentials_schema: [], + }, + parameters: [], + }, + subscription_schema: [], + }, + }, + }, +} as unknown as PackageDependency + +describe('PackageItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading error when manifest is missing', () => { + render( + , + ) + + expect(screen.getByTestId('loading-error')).toBeInTheDocument() + }) + + it('renders LoadedItem with converted plugin payload', () => { + mockPluginManifestToCardPluginProps.mockReturnValue({ + plugin_id: 'plugin-1', + name: 'Package Plugin', + org: 'dify', + icon: 'icon.png', + }) + + render( + , + ) + + expect(screen.getByTestId('loaded-item')).toBeInTheDocument() + expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ + checked: true, + isFromMarketPlace: true, + versionInfo, + payload: expect.objectContaining({ + plugin_id: 'plugin-1', + from: 'package', + }), + })) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx new file mode 100644 index 0000000000..9ae67b7d16 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx @@ -0,0 +1,114 @@ +import type { InstallStatus, Plugin } from '../../../../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Installed from '../installed' + +const mockCard = vi.fn() + +vi.mock('@/config', () => ({ + API_PREFIX: 'https://api.example.com', + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: (props: { titleLeft?: React.ReactNode }) => { + mockCard(props) + return ( +
+ {props.titleLeft} +
+ ) + }, +})) + +vi.mock('../../../base/use-get-icon', () => ({ + default: () => ({ + getIconUrl: (icon: string) => `https://api.example.com/${icon}`, + }), +})) + +const plugins = [ + { + plugin_id: 'plugin-1', + org: 'dify', + name: 'Plugin One', + icon: 'icon-1.png', + version: '1.0.0', + }, + { + plugin_id: 'plugin-2', + org: 'dify', + name: 'Plugin Two', + icon: 'icon-2.png', + version: '2.0.0', + }, +] as Plugin[] + +const installStatus: InstallStatus[] = [ + { success: true, isFromMarketPlace: true }, + { success: false, isFromMarketPlace: false }, +] + +describe('Installed', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders plugin cards with install status and marketplace icon handling', () => { + render( + , + ) + + expect(screen.getAllByTestId('card')).toHaveLength(2) + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + expect(screen.getByText('1.0.0')).toBeInTheDocument() + expect(screen.getByText('2.0.0')).toBeInTheDocument() + expect(mockCard).toHaveBeenNthCalledWith(1, expect.objectContaining({ + installed: true, + installFailed: false, + payload: expect.objectContaining({ + icon: 'https://marketplace.example.com/plugins/dify/Plugin One/icon', + }), + })) + expect(mockCard).toHaveBeenNthCalledWith(2, expect.objectContaining({ + installed: false, + installFailed: true, + payload: expect.objectContaining({ + icon: 'https://api.example.com/icon-2.png', + }), + })) + }) + + it('calls onCancel when close button is clicked', () => { + const onCancel = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('hides action button when isHideButton is true', () => { + render( + , + ) + + expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/constants.spec.ts b/web/app/components/plugins/marketplace/__tests__/constants.spec.ts new file mode 100644 index 0000000000..cb3c822993 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/constants.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { PluginCategoryEnum } from '../../types' +import { + DEFAULT_SORT, + PLUGIN_CATEGORY_WITH_COLLECTIONS, + PLUGIN_TYPE_SEARCH_MAP, + SCROLL_BOTTOM_THRESHOLD, +} from '../constants' + +describe('marketplace constants', () => { + it('defines the expected default sort', () => { + expect(DEFAULT_SORT).toEqual({ + sortBy: 'install_count', + sortOrder: 'DESC', + }) + }) + + it('defines the expected plugin search type map', () => { + expect(PLUGIN_TYPE_SEARCH_MAP).toEqual({ + all: 'all', + model: PluginCategoryEnum.model, + tool: PluginCategoryEnum.tool, + agent: PluginCategoryEnum.agent, + extension: PluginCategoryEnum.extension, + datasource: PluginCategoryEnum.datasource, + trigger: PluginCategoryEnum.trigger, + bundle: 'bundle', + }) + expect(SCROLL_BOTTOM_THRESHOLD).toBe(100) + }) + + it('tracks only collection-backed categories', () => { + expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.all)).toBe(true) + expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(true) + expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.model)).toBe(false) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts new file mode 100644 index 0000000000..c13a4528fb --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' +import { marketplaceSearchParamsParsers } from '../search-params' + +describe('marketplace search params', () => { + it('applies the expected default values', () => { + expect(marketplaceSearchParamsParsers.category.parseServerSide(undefined)).toBe(PLUGIN_TYPE_SEARCH_MAP.all) + expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('') + expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([]) + }) + + it('parses supported query values with the configured parsers', () => { + expect(marketplaceSearchParamsParsers.category.parseServerSide(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(PLUGIN_TYPE_SEARCH_MAP.tool) + expect(marketplaceSearchParamsParsers.category.parseServerSide('unsupported')).toBe(PLUGIN_TYPE_SEARCH_MAP.all) + expect(marketplaceSearchParamsParsers.q.parseServerSide('keyword')).toBe('keyword') + expect(marketplaceSearchParamsParsers.tags.parseServerSide('rag,search')).toEqual(['rag', 'search']) + }) +}) diff --git a/web/app/components/plugins/marketplace/empty/__tests__/line.spec.tsx b/web/app/components/plugins/marketplace/empty/__tests__/line.spec.tsx new file mode 100644 index 0000000000..56e7046dae --- /dev/null +++ b/web/app/components/plugins/marketplace/empty/__tests__/line.spec.tsx @@ -0,0 +1,30 @@ +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Line from '../line' + +const mockUseTheme = vi.fn() + +vi.mock('@/hooks/use-theme', () => ({ + default: () => mockUseTheme(), +})) + +describe('Line', () => { + it('renders dark mode svg variant', () => { + mockUseTheme.mockReturnValue({ theme: 'dark' }) + const { container } = render() + const svg = container.querySelector('svg') + + expect(svg).toHaveAttribute('height', '240') + expect(svg).toHaveAttribute('viewBox', '0 0 2 240') + expect(svg).toHaveClass('divider') + }) + + it('renders light mode svg variant', () => { + mockUseTheme.mockReturnValue({ theme: 'light' }) + const { container } = render() + const svg = container.querySelector('svg') + + expect(svg).toHaveAttribute('height', '241') + expect(svg).toHaveAttribute('viewBox', '0 0 2 241') + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx new file mode 100644 index 0000000000..f1e263b6f6 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx @@ -0,0 +1,115 @@ +import type { ComponentProps } from 'react' +import type { Plugin } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { ThemeProvider } from 'next-themes' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import CardWrapper from '../card-wrapper' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), + useLocale: () => 'en-US', +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + getTagLabel: (name: string) => `tag:${name}`, + }), +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( +
+ {payload.name} + {footer} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/card-more-info', () => ({ + default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( +
+ {downloadCount} + : + {tags.join('|')} +
+ ), +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('../../utils', () => ({ + getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`, + getPluginLinkInMarketplace: (plugin: Plugin, params: Record) => `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, +})) + +const plugin = { + type: 'plugin', + org: 'dify', + name: 'plugin-a', + plugin_id: 'plugin-a', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'pkg', + icon: 'icon.png', + verified: true, + label: { 'en-US': 'Plugin A' }, + brief: { 'en-US': 'Brief' }, + description: { 'en-US': 'Description' }, + introduction: 'Intro', + repository: 'https://github.com/dify/plugin-a', + category: PluginCategoryEnum.tool, + install_count: 42, + endpoint: { settings: [] }, + tags: [{ name: 'search' }, { name: 'agent' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', +} as Plugin + +describe('CardWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderCardWrapper = (props: Partial> = {}) => render( + + + , + ) + + it('renders plugin detail link when install button is hidden', () => { + renderCardWrapper() + + expect(screen.getByRole('link')).toHaveAttribute('href', '/detail/dify/plugin-a') + expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent') + }) + + it('renders install and marketplace detail actions when install button is shown', () => { + renderCardWrapper({ showInstallButton: true }) + + expect(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' })).toHaveAttribute( + 'href', + '/marketplace/dify/plugin-a?language=en-US&theme=system', + ) + }) + + it('opens and closes install modal from install action', () => { + renderCardWrapper({ showInstallButton: true }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })) + expect(screen.getByTestId('install-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('close-install-modal')) + expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx new file mode 100644 index 0000000000..cbaf7868a0 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx @@ -0,0 +1,102 @@ +import type { MarketplaceCollection } from '../../types' +import type { Plugin } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ListWithCollection from '../list-with-collection' + +const mockMoreClick = vi.fn() + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), + useLocale: () => 'en-US', +})) + +vi.mock('../../atoms', () => ({ + useMarketplaceMoreClick: () => mockMoreClick, +})) + +vi.mock('@/i18n-config/language', () => ({ + getLanguage: (locale: string) => locale, +})) + +vi.mock('../card-wrapper', () => ({ + default: ({ plugin }: { plugin: Plugin }) =>
{plugin.name}
, +})) + +const collections: MarketplaceCollection[] = [ + { + name: 'featured', + label: { 'en-US': 'Featured' }, + description: { 'en-US': 'Featured plugins' }, + rule: 'featured', + created_at: '', + updated_at: '', + searchable: true, + search_params: { query: 'featured' }, + }, + { + name: 'empty', + label: { 'en-US': 'Empty' }, + description: { 'en-US': 'No plugins' }, + rule: 'empty', + created_at: '', + updated_at: '', + searchable: false, + search_params: {}, + }, +] + +const pluginsMap: Record = { + featured: [ + { plugin_id: 'p1', name: 'Plugin One' }, + { plugin_id: 'p2', name: 'Plugin Two' }, + ] as Plugin[], + empty: [], +} + +describe('ListWithCollection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders only collections that contain plugins', () => { + render( + , + ) + + expect(screen.getByText('Featured')).toBeInTheDocument() + expect(screen.queryByText('Empty')).not.toBeInTheDocument() + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(2) + }) + + it('calls more handler for searchable collection', () => { + render( + , + ) + + fireEvent.click(screen.getByText('plugin.marketplace.viewMore')) + + expect(mockMoreClick).toHaveBeenCalledWith({ query: 'featured' }) + }) + + it('uses custom card renderer when provided', () => { + render( +
{plugin.name}
} + />, + ) + + expect(screen.getAllByTestId('custom-card')).toHaveLength(2) + expect(screen.queryByTestId('card-wrapper')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx new file mode 100644 index 0000000000..fecfea3007 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx @@ -0,0 +1,92 @@ +import type { MarketplaceCollection } from '../../types' +import type { Plugin } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ListWrapper from '../list-wrapper' + +const mockMarketplaceData = vi.hoisted(() => ({ + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollections: [] as MarketplaceCollection[], + marketplaceCollectionPluginsMap: {} as Record, + isLoading: false, + isFetchingNextPage: false, + page: 1, +})) + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, num?: number }) => + key === 'marketplace.pluginsResult' && options?.ns === 'plugin' + ? `${options.num} plugins found` + : options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('../../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: ({ className }: { className?: string }) =>
loading
, +})) + +vi.mock('../../sort-dropdown', () => ({ + default: () =>
sort
, +})) + +vi.mock('../index', () => ({ + default: ({ plugins }: { plugins?: Plugin[] }) =>
{plugins?.length ?? 'collections'}
, +})) + +describe('ListWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.marketplaceCollections = [] + mockMarketplaceData.marketplaceCollectionPluginsMap = {} + mockMarketplaceData.isLoading = false + mockMarketplaceData.isFetchingNextPage = false + mockMarketplaceData.page = 1 + }) + + it('shows result header and sort dropdown when plugins are loaded', () => { + mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] + mockMarketplaceData.pluginsTotal = 1 + + render() + + expect(screen.getByText('1 plugins found')).toBeInTheDocument() + expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() + }) + + it('shows centered loading only on initial loading page', () => { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 + + render() + + expect(screen.getByTestId('loading')).toBeInTheDocument() + expect(screen.queryByTestId('list')).not.toBeInTheDocument() + }) + + it('renders list when loading additional pages', () => { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] + + render() + + expect(screen.getByTestId('list')).toBeInTheDocument() + }) + + it('shows bottom loading indicator while fetching next page', () => { + mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] + mockMarketplaceData.isFetchingNextPage = true + + render() + + expect(screen.getAllByTestId('loading')).toHaveLength(1) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx new file mode 100644 index 0000000000..4a3b880c27 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import SearchBoxWrapper from '../search-box-wrapper' + +const mockHandleSearchPluginTextChange = vi.fn() +const mockHandleFilterPluginTagsChange = vi.fn() +const mockSearchBox = vi.fn() + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('../../atoms', () => ({ + useSearchPluginText: () => ['plugin search', mockHandleSearchPluginTextChange], + useFilterPluginTags: () => [['agent', 'rag'], mockHandleFilterPluginTagsChange], +})) + +vi.mock('../index', () => ({ + default: (props: Record) => { + mockSearchBox(props) + return
search-box
+ }, +})) + +describe('SearchBoxWrapper', () => { + it('passes marketplace search state into SearchBox', () => { + render() + + expect(screen.getByTestId('search-box')).toBeInTheDocument() + expect(mockSearchBox).toHaveBeenCalledWith(expect.objectContaining({ + wrapperClassName: 'z-11 mx-auto w-[640px] shrink-0', + inputClassName: 'w-full', + search: 'plugin search', + onSearchChange: mockHandleSearchPluginTextChange, + tags: ['agent', 'rag'], + onTagsChange: mockHandleFilterPluginTagsChange, + placeholder: 'plugin.searchPlugins', + usedInMarketplace: true, + })) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx new file mode 100644 index 0000000000..bb5d8e734c --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TagsFilter from '../tags-filter' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + ], + tagsMap: { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, + }, + }), +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ checked }: { checked: boolean }) => {String(checked)}, +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ + value, + onChange, + placeholder, + }: { + value: string + onChange: (event: { target: { value: string } }) => void + placeholder: string + }) => ( + onChange({ target: { value: event.target.value } })} + /> + ), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await import('react') + return { + PortalToFollowElem: ({ children }: { children: React.ReactNode }) =>
{children}
, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => , + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + } +}) + +vi.mock('../trigger/marketplace', () => ({ + default: ({ selectedTagsLength }: { selectedTagsLength: number }) => ( +
+ marketplace: + {selectedTagsLength} +
+ ), +})) + +vi.mock('../trigger/tool-selector', () => ({ + default: ({ selectedTagsLength }: { selectedTagsLength: number }) => ( +
+ tool: + {selectedTagsLength} +
+ ), +})) + +describe('TagsFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders marketplace trigger when used in marketplace', () => { + render() + + expect(screen.getByTestId('marketplace-trigger')).toHaveTextContent('marketplace:1') + expect(screen.queryByTestId('tool-trigger')).not.toBeInTheDocument() + }) + + it('renders tool selector trigger when used outside marketplace', () => { + render() + + expect(screen.getByTestId('tool-trigger')).toHaveTextContent('tool:1') + expect(screen.queryByTestId('marketplace-trigger')).not.toBeInTheDocument() + }) + + it('filters tag options by search text', () => { + render() + + expect(screen.getByText('Agent')).toBeInTheDocument() + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.getByText('Search')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('tags-search'), { target: { value: 'ra' } }) + + expect(screen.queryByText('Agent')).not.toBeInTheDocument() + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.queryByText('Search')).not.toBeInTheDocument() + }) + + it('adds and removes selected tags when options are clicked', () => { + const onTagsChange = vi.fn() + const { rerender } = render() + + fireEvent.click(screen.getByText('Agent')) + expect(onTagsChange).toHaveBeenCalledWith([]) + + rerender() + fireEvent.click(screen.getByText('RAG')) + expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag']) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx new file mode 100644 index 0000000000..4d1a11ac00 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx @@ -0,0 +1,67 @@ +import type { Tag } from '../../../../hooks' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import MarketplaceTrigger from '../marketplace' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +const tagsMap: Record = { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, +} + +describe('MarketplaceTrigger', () => { + it('shows all-tags text when no tags are selected', () => { + const { container } = render( + , + ) + + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() + expect(container.querySelectorAll('svg').length).toBeGreaterThan(0) + expect(container.querySelectorAll('svg').length).toBe(2) + }) + + it('shows selected tag labels and overflow count', () => { + render( + , + ) + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('clears selected tags when clear icon is clicked', () => { + const onTagsChange = vi.fn() + + const { container } = render( + , + ) + + fireEvent.click(container.querySelectorAll('svg')[1]!) + + expect(onTagsChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx new file mode 100644 index 0000000000..7e9069d61f --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx @@ -0,0 +1,61 @@ +import type { Tag } from '../../../../hooks' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ToolSelectorTrigger from '../tool-selector' + +const tagsMap: Record = { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, +} + +describe('ToolSelectorTrigger', () => { + it('renders only icon when no tags are selected', () => { + const { container } = render( + , + ) + + expect(container.querySelectorAll('svg')).toHaveLength(1) + expect(screen.queryByText('Agent')).not.toBeInTheDocument() + }) + + it('renders selected tag labels and overflow count', () => { + const { container } = render( + , + ) + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + expect(container.querySelectorAll('svg')).toHaveLength(2) + }) + + it('clears selected tags when clear icon is clicked', () => { + const onTagsChange = vi.fn() + + const { container } = render( + , + ) + + fireEvent.click(container.querySelectorAll('svg')[1]!) + + expect(onTagsChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx new file mode 100644 index 0000000000..f3dcfeab5d --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InputVarType } from '@/app/components/workflow/types' +import AppInputsForm from '../app-inputs-form' + +vi.mock('@/app/components/base/file-uploader', () => ({ + FileUploaderInAttachmentWrapper: ({ + onChange, + }: { + onChange: (files: Array>) => void + }) => ( + + ), +})) + +vi.mock('@/app/components/base/select', () => ({ + PortalSelect: ({ + items, + onSelect, + }: { + items: Array<{ value: string, name: string }> + onSelect: (item: { value: string }) => void + }) => ( +
+ {items.map(item => ( + + ))} +
+ ), +})) + +describe('AppInputsForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should update text input values', () => { + const onFormChange = vi.fn() + const inputsRef = { current: { question: '' } } + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Question'), { + target: { value: 'hello' }, + }) + + expect(onFormChange).toHaveBeenCalledWith({ question: 'hello' }) + }) + + it('should update select values', () => { + const onFormChange = vi.fn() + const inputsRef = { current: { tone: '' } } + + render( + , + ) + + fireEvent.click(screen.getByTestId('select-formal')) + + expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' }) + }) + + it('should update uploaded single file values', () => { + const onFormChange = vi.fn() + const inputsRef = { current: { attachment: null } } + + render( + , + ) + + fireEvent.click(screen.getByTestId('file-uploader')) + + expect(onFormChange).toHaveBeenCalledWith({ + attachment: { id: 'file-1', name: 'demo.png' }, + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx new file mode 100644 index 0000000000..3e1c2a5a2a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppInputsPanel from '../app-inputs-panel' + +let mockHookResult = { + inputFormSchema: [] as Array>, + isLoading: false, +} + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
Loading
, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form', () => ({ + default: ({ + onFormChange, + }: { + onFormChange: (value: Record) => void + }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema', () => ({ + useAppInputsFormSchema: () => mockHookResult, +})) + +describe('AppInputsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHookResult = { + inputFormSchema: [], + isLoading: false, + } + }) + + it('should render a loading state', () => { + mockHookResult = { + inputFormSchema: [], + isLoading: true, + } + + render( + , + ) + + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('should render an empty state when no inputs are available', () => { + render( + , + ) + + expect(screen.getByText('app.appSelector.noParams')).toBeInTheDocument() + }) + + it('should render the inputs form and propagate changes', () => { + const onFormChange = vi.fn() + mockHookResult = { + inputFormSchema: [{ variable: 'topic' }], + isLoading: false, + } + + render( + , + ) + + fireEvent.click(screen.getByTestId('app-inputs-form')) + + expect(onFormChange).toHaveBeenCalledWith({ topic: 'updated' }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx new file mode 100644 index 0000000000..a319d2f8c4 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx @@ -0,0 +1,179 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { AppModeEnum } from '@/types/app' +import AppPicker from '../app-picker' + +class MockIntersectionObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +class MockMutationObserver { + observe = vi.fn() + disconnect = vi.fn() + takeRecords = vi.fn().mockReturnValue([]) +} + +beforeAll(() => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + vi.stubGlobal('MutationObserver', MockMutationObserver) +}) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ + value, + onChange, + onClear, + }: { + value: string + onChange: (e: { target: { value: string } }) => void + onClear?: () => void + }) => ( +
+ onChange({ target: { value: e.target.value } })} + /> + +
+ ), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ + children, + open, + }: { + children: ReactNode + open: boolean + }) => ( +
+ {children} +
+ ), + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: ReactNode + onClick?: () => void + }) => ( + + ), + PortalToFollowElemContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +const apps = [ + { + id: 'app-1', + name: 'Chat App', + mode: AppModeEnum.CHAT, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#fff', + }, + { + id: 'app-2', + name: 'Workflow App', + mode: AppModeEnum.WORKFLOW, + icon_type: 'emoji', + icon: '⚙️', + icon_background: '#fff', + }, +] + +describe('AppPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should open when the trigger is clicked', () => { + const onShowChange = vi.fn() + + render( + Trigger} + isShow={false} + onShowChange={onShowChange} + onSelect={vi.fn()} + apps={apps as never} + isLoading={false} + hasMore={false} + onLoadMore={vi.fn()} + searchText="" + onSearchChange={vi.fn()} + />, + ) + + fireEvent.click(screen.getByTestId('picker-trigger')) + + expect(onShowChange).toHaveBeenCalledWith(true) + }) + + it('should render apps, select one, and handle search changes', () => { + const onSelect = vi.fn() + const onSearchChange = vi.fn() + + render( + Trigger} + isShow + onShowChange={vi.fn()} + onSelect={onSelect} + apps={apps as never} + isLoading={false} + hasMore={false} + onLoadMore={vi.fn()} + searchText="chat" + onSearchChange={onSearchChange} + />, + ) + + fireEvent.change(screen.getByTestId('search-input'), { + target: { value: 'workflow' }, + }) + fireEvent.click(screen.getByText('Workflow App')) + fireEvent.click(screen.getByTestId('clear-input')) + + expect(onSearchChange).toHaveBeenCalledWith('workflow') + expect(onSearchChange).toHaveBeenCalledWith('') + expect(onSelect).toHaveBeenCalledWith(apps[1]) + expect(screen.getByText('chat')).toBeInTheDocument() + }) + + it('should render loading text when loading more apps', () => { + render( + Trigger} + isShow + onShowChange={vi.fn()} + onSelect={vi.fn()} + apps={apps as never} + isLoading + hasMore + onLoadMore={vi.fn()} + searchText="" + onSearchChange={vi.fn()} + />, + ) + + expect(screen.getByText('common.loading')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts new file mode 100644 index 0000000000..d6a5b03236 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts @@ -0,0 +1,141 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { AppModeEnum, Resolution } from '@/types/app' +import { useAppInputsFormSchema } from '../use-app-inputs-form-schema' + +let mockAppDetailData: Record | null = null +let mockAppWorkflowData: Record | null = null + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ + data: { + file_size_limit: 15, + image_file_size_limit: 10, + }, + }), +})) + +vi.mock('@/service/use-apps', () => ({ + useAppDetail: () => ({ + data: mockAppDetailData, + isFetching: false, + }), +})) + +vi.mock('@/service/use-workflow', () => ({ + useAppWorkflow: () => ({ + data: mockAppWorkflowData, + isFetching: false, + }), +})) + +describe('useAppInputsFormSchema', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetailData = null + mockAppWorkflowData = null + }) + + it('should build basic app schemas and append image upload support', () => { + mockAppDetailData = { + id: 'app-1', + mode: AppModeEnum.COMPLETION, + model_config: { + user_input_form: [ + { + 'text-input': { + label: 'Question', + variable: 'question', + }, + }, + ], + file_upload: { + enabled: true, + image: { + enabled: true, + detail: Resolution.high, + number_limits: 2, + transfer_methods: ['local_file'], + }, + allowed_file_types: [SupportUploadFileTypes.image], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: ['local_file'], + number_limits: 2, + }, + }, + } + + const { result } = renderHook(() => useAppInputsFormSchema({ + appDetail: { + id: 'app-1', + mode: AppModeEnum.COMPLETION, + } as never, + })) + + expect(result.current.isLoading).toBe(false) + expect(result.current.inputFormSchema).toEqual(expect.arrayContaining([ + expect.objectContaining({ + variable: 'question', + type: 'text-input', + }), + expect.objectContaining({ + variable: '#image#', + type: InputVarType.singleFile, + allowed_file_extensions: ['.png'], + }), + ])) + }) + + it('should build workflow schemas from start node variables', () => { + mockAppDetailData = { + id: 'app-2', + mode: AppModeEnum.WORKFLOW, + } + mockAppWorkflowData = { + graph: { + nodes: [ + { + data: { + type: BlockEnum.Start, + variables: [ + { + label: 'Attachments', + variable: 'attachments', + type: InputVarType.multiFiles, + }, + ], + }, + }, + ], + }, + features: {}, + } + + const { result } = renderHook(() => useAppInputsFormSchema({ + appDetail: { + id: 'app-2', + mode: AppModeEnum.WORKFLOW, + } as never, + })) + + expect(result.current.inputFormSchema).toEqual([ + expect.objectContaining({ + variable: 'attachments', + type: InputVarType.multiFiles, + fileUploadConfig: expect.any(Object), + }), + ]) + }) + + it('should return an empty schema when app detail is unavailable', () => { + const { result } = renderHook(() => useAppInputsFormSchema({ + appDetail: { + id: 'missing-app', + mode: AppModeEnum.CHAT, + } as never, + })) + + expect(result.current.inputFormSchema).toEqual([]) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx new file mode 100644 index 0000000000..27ef4e7eb3 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx @@ -0,0 +1,251 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' +import DetailHeader from '../index' + +const mockSetTargetVersion = vi.fn() +const mockSetVersionPickerOpen = vi.fn() +const mockHandleUpdate = vi.fn() +const mockHandleUpdatedFromMarketplace = vi.fn() +const mockHandleDelete = vi.fn() + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { timezone: 'UTC' }, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', + useLocale: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ + data: [{ + name: 'tool-plugin/provider-a', + type: 'builtin', + allow_delete: true, + }], + }), +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`, +})) + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ onClick, children }: { onClick?: () => void, children: React.ReactNode }) => ( + + ), +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/badge', () => ({ + default: ({ text, children }: { text?: React.ReactNode, children?: React.ReactNode }) => ( +
{text ?? children}
+ ), +})) + +vi.mock('@/app/components/base/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/plugins/plugin-auth', () => ({ + AuthCategory: { + tool: 'tool', + }, + PluginAuth: ({ pluginPayload }: { pluginPayload: { provider: string } }) => ( +
{pluginPayload.provider}
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/operation-dropdown', () => ({ + default: ({ detailUrl }: { detailUrl: string }) =>
{detailUrl}
, +})) + +vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({ + default: ({ onSelect, trigger }: { + onSelect: (value: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void + trigger: React.ReactNode + }) => ( +
+ {trigger} + +
+ ), +})) + +vi.mock('@/app/components/base/badges/verified', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/deprecation-notice', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
{src}
, +})) + +vi.mock('@/app/components/plugins/card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('@/app/components/plugins/card/base/org-info', () => ({ + default: ({ orgName }: { orgName: string }) =>
{orgName}
, +})) + +vi.mock('@/app/components/plugins/card/base/title', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ + default: () => ({ + referenceSetting: { + auto_upgrade: { + upgrade_time_of_day: 0, + }, + }, + }), +})) + +vi.mock('@/app/components/plugins/reference-setting-modal/auto-update-setting/utils', () => ({ + convertUTCDaySecondsToLocalSeconds: (value: number) => value, + timeOfDayToDayjs: () => ({ + format: () => '10:00 AM', + }), +})) + +vi.mock('../components', () => ({ + HeaderModals: () =>
, + PluginSourceBadge: ({ source }: { source: string }) =>
{source}
, +})) + +vi.mock('../hooks', () => ({ + useDetailHeaderState: () => ({ + modalStates: { + isShowUpdateModal: false, + showUpdateModal: vi.fn(), + hideUpdateModal: vi.fn(), + isShowPluginInfo: false, + showPluginInfo: vi.fn(), + hidePluginInfo: vi.fn(), + isShowDeleteConfirm: false, + showDeleteConfirm: vi.fn(), + hideDeleteConfirm: vi.fn(), + deleting: false, + showDeleting: vi.fn(), + hideDeleting: vi.fn(), + }, + versionPicker: { + isShow: false, + setIsShow: mockSetVersionPickerOpen, + targetVersion: { + version: '1.0.0', + unique_identifier: 'uid-1', + }, + setTargetVersion: mockSetTargetVersion, + isDowngrade: false, + setIsDowngrade: vi.fn(), + }, + hasNewVersion: true, + isAutoUpgradeEnabled: true, + isFromGitHub: false, + isFromMarketplace: true, + }), + usePluginOperations: () => ({ + handleUpdate: mockHandleUpdate, + handleUpdatedFromMarketplace: mockHandleUpdatedFromMarketplace, + handleDelete: mockHandleDelete, + }), +})) + +const createDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-1', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'tool-plugin', + plugin_id: 'tool-plugin', + plugin_unique_identifier: 'tool-plugin@1.0.0', + declaration: { + author: 'acme', + category: PluginCategoryEnum.tool, + name: 'provider-a', + label: { en_US: 'Tool Plugin' }, + description: { en_US: 'Tool plugin description' }, + icon: 'icon.png', + icon_dark: 'icon-dark.png', + verified: true, + tool: { + identity: { + name: 'provider-a', + }, + }, + } as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '2.0.0', + latest_unique_identifier: 'uid-2', + source: PluginSource.marketplace, + status: 'active', + deprecated_reason: 'Deprecated', + alternative_plugin_id: 'plugin-2', + meta: undefined, + ...overrides, +}) as PluginDetail + +describe('DetailHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the plugin summary, source badge, auth section, and modal container', () => { + render() + + expect(screen.getByTestId('title')).toHaveTextContent('Tool Plugin') + expect(screen.getByTestId('description')).toHaveTextContent('Tool plugin description') + expect(screen.getByTestId('source-badge')).toHaveTextContent('marketplace') + expect(screen.getByTestId('plugin-auth')).toHaveTextContent('tool-plugin/provider-a') + expect(screen.getByTestId('operation-dropdown')).toHaveTextContent('https://marketplace.example.com/plugins/acme/provider-a') + expect(screen.getByTestId('header-modals')).toBeInTheDocument() + }) + + it('wires version selection, latest update, and hide actions', () => { + const onHide = vi.fn() + render() + + fireEvent.click(screen.getByTestId('version-select')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) + fireEvent.click(screen.getByTestId('close-button')) + + expect(mockSetTargetVersion).toHaveBeenCalledWith({ + version: '2.0.0', + unique_identifier: 'uid-2', + isDowngrade: true, + }) + expect(mockHandleUpdate).toHaveBeenCalledTimes(2) + expect(mockHandleUpdate).toHaveBeenNthCalledWith(1, true) + expect(onHide).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/index.spec.ts new file mode 100644 index 0000000000..a932907f44 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/index.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { HeaderModals, PluginSourceBadge } from '../index' + +describe('detail-header components index', () => { + it('re-exports header modal components', () => { + expect(HeaderModals).toBeDefined() + expect(PluginSourceBadge).toBeDefined() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/index.spec.ts new file mode 100644 index 0000000000..0edda1b86a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/index.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { useDetailHeaderState, usePluginOperations } from '../index' + +describe('detail-header hooks index', () => { + it('re-exports hook entrypoints', () => { + expect(useDetailHeaderState).toBeTypeOf('function') + expect(usePluginOperations).toBeTypeOf('function') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx new file mode 100644 index 0000000000..b5e2be7105 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx @@ -0,0 +1,112 @@ +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { ApiKeyStep } from '../../hooks/use-common-modal-state' +import { + ConfigurationStepContent, + MultiSteps, + VerifyStepContent, +} from '../modal-steps' + +const mockBaseForm = vi.fn() +vi.mock('@/app/components/base/form/components/base', () => ({ + BaseForm: ({ + formSchemas, + onChange, + }: { + formSchemas: Array<{ name: string }> + onChange?: () => void + }) => { + mockBaseForm(formSchemas) + return ( +
+ {formSchemas.map(schema => ( + + ))} +
+ ) + }, +})) + +vi.mock('../../../log-viewer', () => ({ + default: ({ logs }: { logs: Array<{ id: string, message: string }> }) => ( +
+ {logs.map(log => {log.message})} +
+ ), +})) + +const subscriptionBuilder: TriggerSubscriptionBuilder = { + id: 'builder-1', + name: 'builder', + provider: 'provider-a', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com/callback', + parameters: {}, + properties: {}, + workflows_in_use: 0, +} + +const formRef = { current: null } as React.RefObject + +describe('modal-steps', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the api key multi step indicator', () => { + render() + + expect(screen.getByText('pluginTrigger.modal.steps.verify')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.modal.steps.configuration')).toBeInTheDocument() + }) + + it('should render verify step content and forward change events', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('field-api_key')) + + expect(onChange).toHaveBeenCalled() + }) + + it('should render manual configuration content with logs', () => { + const onManualPropertiesChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('field-webhook_url')) + + expect(onManualPropertiesChange).toHaveBeenCalled() + expect(screen.getByTestId('log-viewer')).toHaveTextContent('log-entry') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts new file mode 100644 index 0000000000..61482e2912 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts @@ -0,0 +1,196 @@ +import type { RefObject } from 'react' +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { + buildSubscriptionPayload, + DEFAULT_FORM_VALUES, + getConfirmButtonText, + getFirstFieldName, + getFormValues, + toSchemaWithTooltip, + useInitializeSubscriptionBuilder, + useSyncSubscriptionEndpoint, +} from '../use-common-modal-state.helpers' + +type BuilderResponse = { + subscription_builder: TriggerSubscriptionBuilder +} + +const { + mockToastError, + mockIsPrivateOrLocalAddress, +} = vi.hoisted(() => ({ + mockToastError: vi.fn(), + mockIsPrivateOrLocalAddress: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: mockToastError, + }, +})) + +vi.mock('@/utils/urlValidation', () => ({ + isPrivateOrLocalAddress: (value: string) => mockIsPrivateOrLocalAddress(value), +})) + +describe('use-common-modal-state helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsPrivateOrLocalAddress.mockReturnValue(false) + }) + + it('returns default form values when the form ref is empty', () => { + expect(getFormValues({ current: null })).toEqual(DEFAULT_FORM_VALUES) + }) + + it('returns form values from the form ref when available', () => { + expect(getFormValues({ + current: { + getFormValues: () => ({ values: { subscription_name: 'Sub' }, isCheckValidated: true }), + }, + } as unknown as React.RefObject)).toEqual({ + values: { subscription_name: 'Sub' }, + isCheckValidated: true, + }) + }) + + it('derives the first field name from values or schema fallback', () => { + expect(getFirstFieldName({ callback_url: 'https://example.com' }, [{ name: 'fallback' }])).toBe('callback_url') + expect(getFirstFieldName({}, [{ name: 'fallback' }])).toBe('fallback') + expect(getFirstFieldName({}, [])).toBe('') + }) + + it('copies schema help into tooltip fields', () => { + expect(toSchemaWithTooltip([{ name: 'field', help: 'Help text' }])).toEqual([ + { + name: 'field', + help: 'Help text', + tooltip: 'Help text', + }, + ]) + }) + + it('builds subscription payloads for automatic and manual creation', () => { + expect(buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.APIKEY, + subscriptionFormValues: { values: { subscription_name: 'My Sub' }, isCheckValidated: true }, + autoCommonParametersSchemaLength: 1, + autoCommonParametersFormValues: { values: { api_key: '123' }, isCheckValidated: true }, + manualPropertiesSchemaLength: 0, + manualPropertiesFormValues: undefined, + })).toEqual({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + name: 'My Sub', + parameters: { api_key: '123' }, + }) + + expect(buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.MANUAL, + subscriptionFormValues: { values: { subscription_name: 'Manual Sub' }, isCheckValidated: true }, + autoCommonParametersSchemaLength: 0, + autoCommonParametersFormValues: undefined, + manualPropertiesSchemaLength: 1, + manualPropertiesFormValues: { values: { custom: 'value' }, isCheckValidated: true }, + })).toEqual({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + name: 'Manual Sub', + }) + }) + + it('returns null when required validation is missing', () => { + expect(buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.APIKEY, + subscriptionFormValues: { values: {}, isCheckValidated: false }, + autoCommonParametersSchemaLength: 1, + autoCommonParametersFormValues: { values: {}, isCheckValidated: true }, + manualPropertiesSchemaLength: 0, + manualPropertiesFormValues: undefined, + })).toBeNull() + }) + + it('builds confirm button text for verify and create states', () => { + const t = (key: string, options?: Record) => `${options?.ns}.${key}` + + expect(getConfirmButtonText({ + isVerifyStep: true, + isVerifyingCredentials: false, + isBuilding: false, + t, + })).toBe('pluginTrigger.modal.common.verify') + + expect(getConfirmButtonText({ + isVerifyStep: false, + isVerifyingCredentials: false, + isBuilding: true, + t, + })).toBe('pluginTrigger.modal.common.creating') + }) + + it('initializes the subscription builder once when provider is available', async () => { + const createBuilder = vi.fn(async () => ({ + subscription_builder: { id: 'builder-1' }, + })) as unknown as (params: { + provider: string + credential_type: string + }) => Promise + const setSubscriptionBuilder = vi.fn() + + renderHook(() => useInitializeSubscriptionBuilder({ + createBuilder, + credentialType: 'oauth', + provider: 'provider-a', + subscriptionBuilder: undefined, + setSubscriptionBuilder, + t: (key: string, options?: Record) => `${options?.ns}.${key}`, + })) + + await waitFor(() => { + expect(createBuilder).toHaveBeenCalledWith({ + provider: 'provider-a', + credential_type: 'oauth', + }) + expect(setSubscriptionBuilder).toHaveBeenCalledWith({ id: 'builder-1' }) + }) + }) + + it('syncs callback endpoint and warnings into the subscription form', async () => { + mockIsPrivateOrLocalAddress.mockReturnValue(true) + const setFieldValue = vi.fn() + const setFields = vi.fn() + const subscriptionFormRef = { + current: { + getForm: () => ({ + setFieldValue, + }), + setFields, + }, + } as unknown as RefObject + + renderHook(() => useSyncSubscriptionEndpoint({ + endpoint: 'http://127.0.0.1/callback', + isConfigurationStep: true, + subscriptionFormRef, + t: (key: string, options?: Record) => `${options?.ns}.${key}`, + })) + + await waitFor(() => { + expect(setFieldValue).toHaveBeenCalledWith('callback_url', 'http://127.0.0.1/callback') + expect(setFields).toHaveBeenCalledWith([{ + name: 'callback_url', + warnings: ['pluginTrigger.modal.form.callbackUrl.privateAddressWarning'], + }]) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts new file mode 100644 index 0000000000..399d3ba60c --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts @@ -0,0 +1,253 @@ +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { ApiKeyStep, useCommonModalState } from '../use-common-modal-state' + +type MockPluginDetail = { + plugin_id: string + provider: string + name: string + declaration: { + trigger: { + subscription_schema: Array<{ name: string, type: string, description?: string }> + subscription_constructor: { + credentials_schema: Array<{ name: string, type: string, help?: string }> + parameters: Array<{ name: string, type: string }> + } + } + } +} + +const createMockBuilder = (overrides: Partial = {}): TriggerSubscriptionBuilder => ({ + id: 'builder-1', + name: 'builder', + provider: 'provider-a', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com/callback', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +const mockDetail: MockPluginDetail = { + plugin_id: 'plugin-id', + provider: 'provider-a', + name: 'Plugin A', + declaration: { + trigger: { + subscription_schema: [{ name: 'webhook_url', type: 'string', description: 'Webhook URL' }], + subscription_constructor: { + credentials_schema: [{ name: 'api_key', type: 'string', help: 'API key help' }], + parameters: [{ name: 'repo_name', type: 'string' }], + }, + }, + }, +} + +const mockUsePluginStore = vi.fn(() => mockDetail) +vi.mock('../../../../store', () => ({ + usePluginStore: () => mockUsePluginStore(), +})) + +const mockRefetch = vi.fn() +vi.mock('../../../use-subscription-list', () => ({ + useSubscriptionList: () => ({ refetch: mockRefetch }), +})) + +const mockVerifyCredentials = vi.fn() +const mockCreateBuilder = vi.fn() +const mockBuildSubscription = vi.fn() +const mockUpdateBuilder = vi.fn() +let mockIsVerifyingCredentials = false +let mockIsBuilding = false + +vi.mock('@/service/use-triggers', () => ({ + useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ + mutate: mockVerifyCredentials, + get isPending() { return mockIsVerifyingCredentials }, + }), + useCreateTriggerSubscriptionBuilder: () => ({ + mutateAsync: mockCreateBuilder, + }), + useBuildTriggerSubscription: () => ({ + mutate: mockBuildSubscription, + get isPending() { return mockIsBuilding }, + }), + useUpdateTriggerSubscriptionBuilder: () => ({ + mutate: mockUpdateBuilder, + }), + useTriggerSubscriptionBuilderLogs: () => ({ + data: { logs: [] }, + }), +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (message: string) => mockToastNotify({ type: 'success', message }), + error: (message: string) => mockToastNotify({ type: 'error', message }), + }, +})) + +const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null) +vi.mock('@/utils/error-parser', () => ({ + parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args), +})) + +vi.mock('@/utils/urlValidation', () => ({ + isPrivateOrLocalAddress: vi.fn().mockReturnValue(false), +})) + +const createFormRef = ({ + values = {}, + isCheckValidated = true, +}: { + values?: Record + isCheckValidated?: boolean +} = {}): FormRefObject => ({ + getFormValues: vi.fn().mockReturnValue({ values, isCheckValidated }), + setFields: vi.fn(), + getForm: vi.fn().mockReturnValue({ + setFieldValue: vi.fn(), + }), +} as unknown as FormRefObject) + +describe('useCommonModalState', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsVerifyingCredentials = false + mockIsBuilding = false + mockCreateBuilder.mockResolvedValue({ + subscription_builder: createMockBuilder(), + }) + }) + + it('should initialize api key builders and expose verify step state', async () => { + const { result } = renderHook(() => useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + onClose: vi.fn(), + })) + + await waitFor(() => { + expect(result.current.subscriptionBuilder?.id).toBe('builder-1') + }) + + expect(mockCreateBuilder).toHaveBeenCalledWith({ + provider: 'provider-a', + credential_type: TriggerCredentialTypeEnum.ApiKey, + }) + expect(result.current.currentStep).toBe(ApiKeyStep.Verify) + expect(result.current.apiKeyCredentialsSchema[0]).toMatchObject({ + name: 'api_key', + tooltip: 'API key help', + }) + }) + + it('should verify credentials and advance to configuration step', async () => { + mockVerifyCredentials.mockImplementation((_payload, options) => { + options?.onSuccess?.() + }) + + const builder = createMockBuilder() + const { result } = renderHook(() => useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + builder, + onClose: vi.fn(), + })) + + const credentialsFormRef = result.current.formRefs.apiKeyCredentialsFormRef as { current: FormRefObject | null } + credentialsFormRef.current = createFormRef({ + values: { api_key: 'secret' }, + }) + + act(() => { + result.current.handleVerify() + }) + + expect(mockVerifyCredentials).toHaveBeenCalledWith({ + provider: 'provider-a', + subscriptionBuilderId: builder.id, + credentials: { api_key: 'secret' }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + expect(result.current.currentStep).toBe(ApiKeyStep.Configuration) + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + + it('should build subscriptions with validated automatic parameters', () => { + const onClose = vi.fn() + const builder = createMockBuilder() + const { result } = renderHook(() => useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + builder, + onClose, + })) + + const subscriptionFormRef = result.current.formRefs.subscriptionFormRef as { current: FormRefObject | null } + const autoParamsFormRef = result.current.formRefs.autoCommonParametersFormRef as { current: FormRefObject | null } + + subscriptionFormRef.current = createFormRef({ + values: { subscription_name: 'Subscription A' }, + }) + autoParamsFormRef.current = createFormRef({ + values: { repo_name: 'repo-a' }, + }) + + act(() => { + result.current.handleCreate() + }) + + expect(mockBuildSubscription).toHaveBeenCalledWith({ + provider: 'provider-a', + subscriptionBuilderId: builder.id, + name: 'Subscription A', + parameters: { repo_name: 'repo-a' }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + }) + + it('should debounce manual property updates', async () => { + vi.useFakeTimers() + + const builder = createMockBuilder({ + credential_type: TriggerCredentialTypeEnum.Unauthorized, + }) + const { result } = renderHook(() => useCommonModalState({ + createType: SupportedCreationMethods.MANUAL, + builder, + onClose: vi.fn(), + })) + + const manualFormRef = result.current.formRefs.manualPropertiesFormRef as { current: FormRefObject | null } + manualFormRef.current = createFormRef({ + values: { webhook_url: 'https://hook.example.com' }, + isCheckValidated: true, + }) + + act(() => { + result.current.handleManualPropertiesChange() + vi.advanceTimersByTime(500) + }) + + expect(mockUpdateBuilder).toHaveBeenCalledWith({ + provider: 'provider-a', + subscriptionBuilderId: builder.id, + properties: { webhook_url: 'https://hook.example.com' }, + }, expect.objectContaining({ + onError: expect.any(Function), + })) + + vi.useRealTimers() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts new file mode 100644 index 0000000000..8df864c4fa --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts @@ -0,0 +1,180 @@ +'use client' +import type { Dispatch, SetStateAction } from 'react' +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' +import { useEffect, useRef } from 'react' +import { toast } from '@/app/components/base/ui/toast' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { isPrivateOrLocalAddress } from '@/utils/urlValidation' + +type FormValuesResult = { + values: Record + isCheckValidated: boolean +} + +type InitializeBuilderParams = { + createBuilder: (params: { + provider: string + credential_type: string + }) => Promise<{ subscription_builder: TriggerSubscriptionBuilder }> + credentialType: string + provider?: string + subscriptionBuilder?: TriggerSubscriptionBuilder + setSubscriptionBuilder: Dispatch> + t: (key: string, options?: Record) => string +} + +type SyncEndpointParams = { + endpoint?: string + isConfigurationStep: boolean + subscriptionFormRef: React.RefObject + t: (key: string, options?: Record) => string +} + +type BuildPayloadParams = { + provider: string + subscriptionBuilderId: string + createType: SupportedCreationMethods + subscriptionFormValues?: FormValuesResult + autoCommonParametersSchemaLength: number + autoCommonParametersFormValues?: FormValuesResult + manualPropertiesSchemaLength: number + manualPropertiesFormValues?: FormValuesResult +} + +export const DEFAULT_FORM_VALUES: FormValuesResult = { values: {}, isCheckValidated: false } + +export const getFormValues = (formRef: React.RefObject) => { + return formRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES +} + +export const getFirstFieldName = ( + values: Record, + fallbackSchema: Array<{ name: string }>, +) => { + return Object.keys(values)[0] || fallbackSchema[0]?.name || '' +} + +export const toSchemaWithTooltip = (schemas: T[] = []) => { + return schemas.map(schema => ({ + ...schema, + tooltip: schema.help, + })) +} + +export const buildSubscriptionPayload = ({ + provider, + subscriptionBuilderId, + createType, + subscriptionFormValues, + autoCommonParametersSchemaLength, + autoCommonParametersFormValues, + manualPropertiesSchemaLength, + manualPropertiesFormValues, +}: BuildPayloadParams): BuildTriggerSubscriptionPayload | null => { + if (!subscriptionFormValues?.isCheckValidated) + return null + + const subscriptionNameValue = subscriptionFormValues.values.subscription_name as string + + const params: BuildTriggerSubscriptionPayload = { + provider, + subscriptionBuilderId, + name: subscriptionNameValue, + } + + if (createType !== SupportedCreationMethods.MANUAL) { + if (!autoCommonParametersSchemaLength) + return params + + if (!autoCommonParametersFormValues?.isCheckValidated) + return null + + params.parameters = autoCommonParametersFormValues.values + return params + } + + if (manualPropertiesSchemaLength && !manualPropertiesFormValues?.isCheckValidated) + return null + + return params +} + +export const getConfirmButtonText = ({ + isVerifyStep, + isVerifyingCredentials, + isBuilding, + t, +}: { + isVerifyStep: boolean + isVerifyingCredentials: boolean + isBuilding: boolean + t: (key: string, options?: Record) => string +}) => { + if (isVerifyStep) { + return isVerifyingCredentials + ? t('modal.common.verifying', { ns: 'pluginTrigger' }) + : t('modal.common.verify', { ns: 'pluginTrigger' }) + } + + return isBuilding + ? t('modal.common.creating', { ns: 'pluginTrigger' }) + : t('modal.common.create', { ns: 'pluginTrigger' }) +} + +export const useInitializeSubscriptionBuilder = ({ + createBuilder, + credentialType, + provider, + subscriptionBuilder, + setSubscriptionBuilder, + t, +}: InitializeBuilderParams) => { + const isInitializedRef = useRef(false) + + useEffect(() => { + const initializeBuilder = async () => { + isInitializedRef.current = true + try { + const response = await createBuilder({ + provider: provider || '', + credential_type: credentialType, + }) + setSubscriptionBuilder(response.subscription_builder) + } + catch (error) { + console.error('createBuilder error:', error) + toast.error(t('modal.errors.createFailed', { ns: 'pluginTrigger' })) + } + } + + if (!isInitializedRef.current && !subscriptionBuilder && provider) + initializeBuilder() + }, [subscriptionBuilder, provider, credentialType, createBuilder, setSubscriptionBuilder, t]) +} + +export const useSyncSubscriptionEndpoint = ({ + endpoint, + isConfigurationStep, + subscriptionFormRef, + t, +}: SyncEndpointParams) => { + useEffect(() => { + if (!endpoint || !subscriptionFormRef.current || !isConfigurationStep) + return + + const form = subscriptionFormRef.current.getForm() + if (form) + form.setFieldValue('callback_url', endpoint) + + const warnings = isPrivateOrLocalAddress(endpoint) + ? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })] + : [] + + subscriptionFormRef.current.setFields([{ + name: 'callback_url', + warnings, + }]) + }, [endpoint, isConfigurationStep, subscriptionFormRef, t]) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts index 339f782b45..e55f9525fe 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts @@ -3,7 +3,6 @@ import type { SimpleDetail } from '../../../store' import type { SchemaItem } from '../components/modal-steps' import type { FormRefObject } from '@/app/components/base/form/types' import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' -import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' import { debounce } from 'es-toolkit/compat' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,9 +17,17 @@ import { useVerifyAndUpdateTriggerSubscriptionBuilder, } from '@/service/use-triggers' import { parsePluginErrorMessage } from '@/utils/error-parser' -import { isPrivateOrLocalAddress } from '@/utils/urlValidation' import { usePluginStore } from '../../../store' import { useSubscriptionList } from '../../use-subscription-list' +import { + buildSubscriptionPayload, + getConfirmButtonText, + getFirstFieldName, + getFormValues, + toSchemaWithTooltip, + useInitializeSubscriptionBuilder, + useSyncSubscriptionEndpoint, +} from './use-common-modal-state.helpers' // ============================================================================ // Types @@ -85,8 +92,6 @@ type UseCommonModalStateReturn = { handleApiKeyCredentialsChange: () => void } -const DEFAULT_FORM_VALUES = { values: {}, isCheckValidated: false } - // ============================================================================ // Hook Implementation // ============================================================================ @@ -105,7 +110,6 @@ export const useCommonModalState = ({ createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration, ) const [subscriptionBuilder, setSubscriptionBuilder] = useState(builder) - const isInitializedRef = useRef(false) // Form refs const manualPropertiesFormRef = useRef(null) @@ -123,12 +127,9 @@ export const useCommonModalState = ({ const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] - const apiKeyCredentialsSchema = useMemo(() => { + const apiKeyCredentialsSchema = useMemo(() => { const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || [] - return rawSchema.map(schema => ({ - ...schema, - tooltip: schema.help, - })) + return toSchemaWithTooltip(rawSchema) as SchemaItem[] }, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema]) // Log data for manual mode @@ -162,25 +163,14 @@ export const useCommonModalState = ({ [updateBuilder, t], ) - // Initialize builder - useEffect(() => { - const initializeBuilder = async () => { - isInitializedRef.current = true - try { - const response = await createBuilder({ - provider: detail?.provider || '', - credential_type: CREDENTIAL_TYPE_MAP[createType], - }) - setSubscriptionBuilder(response.subscription_builder) - } - catch (error) { - console.error('createBuilder error:', error) - toast.error(t('modal.errors.createFailed', { ns: 'pluginTrigger' })) - } - } - if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider) - initializeBuilder() - }, [subscriptionBuilder, detail?.provider, createType, createBuilder, t]) + useInitializeSubscriptionBuilder({ + createBuilder, + credentialType: CREDENTIAL_TYPE_MAP[createType], + provider: detail?.provider, + subscriptionBuilder, + setSubscriptionBuilder, + t, + }) // Cleanup debounced function useEffect(() => { @@ -189,24 +179,12 @@ export const useCommonModalState = ({ } }, [debouncedUpdate]) - // Update endpoint in form when endpoint changes - useEffect(() => { - if (!subscriptionBuilder?.endpoint || !subscriptionFormRef.current || currentStep !== ApiKeyStep.Configuration) - return - - const form = subscriptionFormRef.current.getForm() - if (form) - form.setFieldValue('callback_url', subscriptionBuilder.endpoint) - - const warnings = isPrivateOrLocalAddress(subscriptionBuilder.endpoint) - ? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })] - : [] - - subscriptionFormRef.current?.setFields([{ - name: 'callback_url', - warnings, - }]) - }, [subscriptionBuilder?.endpoint, currentStep, t]) + useSyncSubscriptionEndpoint({ + endpoint: subscriptionBuilder?.endpoint, + isConfigurationStep: currentStep === ApiKeyStep.Configuration, + subscriptionFormRef, + t, + }) // Handle manual properties change const handleManualPropertiesChange = useCallback(() => { @@ -237,7 +215,7 @@ export const useCommonModalState = ({ return } - const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES + const apiKeyCredentialsFormValues = getFormValues(apiKeyCredentialsFormRef) const credentials = apiKeyCredentialsFormValues.values if (!Object.keys(credentials).length) { @@ -245,8 +223,10 @@ export const useCommonModalState = ({ return } + const credentialFieldName = getFirstFieldName(credentials, apiKeyCredentialsSchema) + apiKeyCredentialsFormRef.current?.setFields([{ - name: Object.keys(credentials)[0], + name: credentialFieldName, errors: [], }]) @@ -264,13 +244,13 @@ export const useCommonModalState = ({ onError: async (error: unknown) => { const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' }) apiKeyCredentialsFormRef.current?.setFields([{ - name: Object.keys(credentials)[0], + name: credentialFieldName, errors: [errorMessage], }]) }, }, ) - }, [detail?.provider, subscriptionBuilder?.id, verifyCredentials, t]) + }, [apiKeyCredentialsSchema, detail?.provider, subscriptionBuilder?.id, verifyCredentials, t]) // Handle create const handleCreate = useCallback(() => { @@ -279,31 +259,19 @@ export const useCommonModalState = ({ return } - const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({}) - if (!subscriptionFormValues?.isCheckValidated) - return - - const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string - - const params: BuildTriggerSubscriptionPayload = { + const params = buildSubscriptionPayload({ provider: detail?.provider || '', subscriptionBuilderId: subscriptionBuilder.id, - name: subscriptionNameValue, - } + createType, + subscriptionFormValues: getFormValues(subscriptionFormRef), + autoCommonParametersSchemaLength: autoCommonParametersSchema.length, + autoCommonParametersFormValues: getFormValues(autoCommonParametersFormRef), + manualPropertiesSchemaLength: manualPropertiesSchema.length, + manualPropertiesFormValues: getFormValues(manualPropertiesFormRef), + }) - if (createType !== SupportedCreationMethods.MANUAL) { - if (autoCommonParametersSchema.length > 0) { - const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES - if (!autoCommonParametersFormValues?.isCheckValidated) - return - params.parameters = autoCommonParametersFormValues.values - } - } - else if (manualPropertiesSchema.length > 0) { - const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES - if (!manualFormValues?.isCheckValidated) - return - } + if (!params) + return buildSubscription( params, @@ -341,14 +309,12 @@ export const useCommonModalState = ({ // Confirm button text const confirmButtonText = useMemo(() => { - if (currentStep === ApiKeyStep.Verify) { - return isVerifyingCredentials - ? t('modal.common.verifying', { ns: 'pluginTrigger' }) - : t('modal.common.verify', { ns: 'pluginTrigger' }) - } - return isBuilding - ? t('modal.common.creating', { ns: 'pluginTrigger' }) - : t('modal.common.create', { ns: 'pluginTrigger' }) + return getConfirmButtonText({ + isVerifyStep: currentStep === ApiKeyStep.Verify, + isVerifyingCredentials, + isBuilding, + t, + }) }, [currentStep, isVerifyingCredentials, isBuilding, t]) return { diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/index.spec.ts new file mode 100644 index 0000000000..3ff43c4fb6 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/index.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + SchemaModal, + ToolAuthorizationSection, + ToolBaseForm, + ToolCredentialsForm, + ToolItem, + ToolSettingsPanel, + ToolTrigger, +} from '../index' + +describe('tool-selector components index', () => { + it('re-exports the tool selector components', () => { + expect(SchemaModal).toBeDefined() + expect(ToolAuthorizationSection).toBeDefined() + expect(ToolBaseForm).toBeDefined() + expect(ToolCredentialsForm).toBeDefined() + expect(ToolItem).toBeDefined() + expect(ToolSettingsPanel).toBeDefined() + expect(ToolTrigger).toBeDefined() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts new file mode 100644 index 0000000000..24d7fd036d --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import { VarType } from '@/app/components/workflow/types' +import { + createEmptyAppValue, + createFilterVar, + createPickerProps, + createReasoningFormContext, + getFieldFlags, + getFieldTitle, + getVarKindType, + getVisibleSelectOptions, + mergeReasoningValue, + resolveTargetVarType, + updateInputAutoState, + updateReasoningValue, + updateVariableSelectorValue, + updateVariableTypeValue, +} from '../reasoning-config-form.helpers' + +describe('reasoning-config-form helpers', () => { + it('maps schema types to variable-kind types and target variable types', () => { + expect(getVarKindType(FormTypeEnum.files)).toBe(VarKindType.variable) + expect(getVarKindType(FormTypeEnum.textNumber)).toBe(VarKindType.constant) + expect(getVarKindType(FormTypeEnum.textInput)).toBe(VarKindType.mixed) + expect(getVarKindType(FormTypeEnum.dynamicSelect)).toBeUndefined() + + expect(resolveTargetVarType(FormTypeEnum.textInput)).toBe(VarType.string) + expect(resolveTargetVarType(FormTypeEnum.textNumber)).toBe(VarType.number) + expect(resolveTargetVarType(FormTypeEnum.files)).toBe(VarType.arrayFile) + expect(resolveTargetVarType(FormTypeEnum.file)).toBe(VarType.file) + expect(resolveTargetVarType(FormTypeEnum.checkbox)).toBe(VarType.boolean) + expect(resolveTargetVarType(FormTypeEnum.object)).toBe(VarType.object) + expect(resolveTargetVarType(FormTypeEnum.array)).toBe(VarType.arrayObject) + }) + + it('creates variable filters for supported field types', () => { + const numberFilter = createFilterVar(FormTypeEnum.textNumber) + const stringFilter = createFilterVar(FormTypeEnum.textInput) + const fileFilter = createFilterVar(FormTypeEnum.files) + + expect(numberFilter?.({ type: VarType.number } as never)).toBe(true) + expect(numberFilter?.({ type: VarType.string } as never)).toBe(false) + expect(stringFilter?.({ type: VarType.secret } as never)).toBe(true) + expect(fileFilter?.({ type: VarType.arrayFile } as never)).toBe(true) + }) + + it('filters select options based on show_on conditions', () => { + const options = [ + { + value: 'one', + label: { en_US: 'One', zh_Hans: 'One' }, + show_on: [], + }, + { + value: 'two', + label: { en_US: 'Two', zh_Hans: 'Two' }, + show_on: [{ variable: 'mode', value: 'advanced' }], + }, + ] + + expect(getVisibleSelectOptions(options as never, { + mode: { value: { value: 'advanced' } }, + }, 'en_US')).toEqual([ + { value: 'one', name: 'One' }, + { value: 'two', name: 'Two' }, + ]) + + expect(getVisibleSelectOptions(options as never, { + mode: { value: { value: 'basic' } }, + }, 'en_US')).toEqual([ + { value: 'one', name: 'One' }, + ]) + }) + + it('updates reasoning values for auto, constant, variable, and merged states', () => { + const value = { + prompt: { + value: { + type: VarKindType.constant, + value: 'hello', + }, + auto: 0 as const, + }, + } + + expect(updateInputAutoState(value, 'prompt', true, FormTypeEnum.textInput)).toEqual({ + prompt: { + value: null, + auto: 1, + }, + }) + + expect(updateVariableTypeValue(value, 'prompt', VarKindType.variable, '')).toEqual({ + prompt: { + value: { + type: VarKindType.variable, + value: '', + }, + auto: 0, + }, + }) + + expect(updateReasoningValue(value, 'prompt', FormTypeEnum.textInput, 'updated')).toEqual({ + prompt: { + value: { + type: VarKindType.mixed, + value: 'updated', + }, + auto: 0, + }, + }) + + expect(mergeReasoningValue(value, 'prompt', { extra: true })).toEqual({ + prompt: { + value: { + type: VarKindType.constant, + value: 'hello', + extra: true, + }, + auto: 0, + }, + }) + + expect(updateVariableSelectorValue(value, 'prompt', ['node', 'field'])).toEqual({ + prompt: { + value: { + type: VarKindType.variable, + value: ['node', 'field'], + }, + auto: 0, + }, + }) + }) + + it('derives field flags and picker props from schema types', () => { + expect(getFieldFlags(FormTypeEnum.object, { type: VarKindType.constant })).toEqual(expect.objectContaining({ + isObject: true, + isShowJSONEditor: true, + showTypeSwitch: true, + isConstant: true, + })) + + expect(createPickerProps({ + type: FormTypeEnum.select, + value: {}, + language: 'en_US', + schema: { + options: [ + { + value: 'one', + label: { en_US: 'One', zh_Hans: 'One' }, + show_on: [], + }, + ], + } as never, + })).toEqual(expect.objectContaining({ + targetVarType: VarType.string, + selectItems: [{ value: 'one', name: 'One' }], + })) + }) + + it('provides label helpers and empty defaults', () => { + expect(getFieldTitle({ en_US: 'Prompt', zh_Hans: 'Prompt' }, 'en_US')).toBe('Prompt') + expect(createEmptyAppValue()).toEqual({ + app_id: '', + inputs: {}, + files: [], + }) + expect(createReasoningFormContext({ + availableNodes: [{ id: 'node-1' }] as never, + nodeId: 'node-current', + nodeOutputVars: [{ nodeId: 'node-1' }] as never, + })).toEqual({ + availableNodes: [{ id: 'node-1' }], + nodeId: 'node-current', + nodeOutputVars: [{ nodeId: 'node-1' }], + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx new file mode 100644 index 0000000000..f64d396d07 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx @@ -0,0 +1,340 @@ +import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { Type } from '@/app/components/workflow/nodes/llm/types' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import ReasoningConfigForm from '../reasoning-config-form' + +vi.mock('@/app/components/base/input', () => ({ + default: ({ value, onChange }: { value?: string, onChange: (e: { target: { value: string } }) => void }) => ( + onChange({ target: { value: e.target.value } })} /> + ), +})) + +vi.mock('@/app/components/base/select', () => ({ + SimpleSelect: ({ + items, + onSelect, + }: { + items: Array<{ value: string, name: string }> + onSelect: (item: { value: string }) => void + }) => ( +
+ {items.map(item => ( + + ))} +
+ ), +})) + +vi.mock('@/app/components/base/switch', () => ({ + default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ + default: ({ onSelect }: { onSelect: (value: Record) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ setModel }: { setModel: (value: Record) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/form-input-boolean', () => ({ + default: ({ onChange }: { onChange: (value: boolean) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch', () => ({ + default: ({ onChange }: { onChange: (value: VarKindType) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('../schema-modal', () => ({ + default: ({ isShow, rootName, onClose }: { isShow: boolean, rootName: string, onClose: () => void }) => ( + isShow + ? ( +
+ {rootName} + +
+ ) + : null + ), +})) + +const createSchema = (overrides: Partial = {}): ToolFormSchema => ({ + variable: 'field', + type: FormTypeEnum.textInput, + default: '', + required: false, + label: { en_US: 'Field', zh_Hans: '字段' }, + tooltip: { en_US: 'Tooltip', zh_Hans: '提示' }, + scope: 'all', + url: '', + input_schema: {}, + placeholder: { en_US: 'Placeholder', zh_Hans: '占位符' }, + options: [], + ...overrides, +} as ToolFormSchema) + +describe('ReasoningConfigForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should toggle automatic values for text fields', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('auto-switch')) + + expect(onChange).toHaveBeenCalledWith({ + field: { + auto: 1, + value: null, + }, + }) + }) + + it('should update mixed text and variable types', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('mixed-input')) + fireEvent.click(screen.getByTestId('type-switch')) + + expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ + field: { + auto: 0, + value: { type: VarKindType.mixed, value: 'updated-text' }, + }, + })) + expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ + count: { + auto: 0, + value: { type: VarKindType.variable, value: '' }, + }, + })) + }) + + it('should open schema modal for object fields and support app selection', () => { + const onChange = vi.fn() + + const { container } = render( + , + ) + + fireEvent.click(container.querySelector('div.ml-0\\.5.cursor-pointer')!) + expect(screen.getByTestId('schema-modal')).toHaveTextContent('Config') + fireEvent.click(screen.getByTestId('close-schema')) + + fireEvent.click(screen.getByTestId('app-selector')) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + app: { + auto: 0, + value: { + type: undefined, + value: { app_id: 'app-1', inputs: { topic: 'hello' } }, + }, + }, + })) + }) + + it('should merge model selector values into the current field value', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('model-selector')) + + expect(onChange).toHaveBeenCalledWith({ + model: { + auto: 0, + value: { + provider: 'openai', + model: 'gpt-4.1', + }, + }, + }) + }) + + it('should update file fields from the variable selector', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('var-picker')) + + expect(onChange).toHaveBeenCalledWith({ + files: { + auto: 0, + value: { + type: VarKindType.variable, + value: ['node', 'field'], + }, + }, + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx new file mode 100644 index 0000000000..86158ab950 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SchemaModal from '../schema-modal' + +vi.mock('@/app/components/base/modal', () => ({ + default: ({ + children, + isShow, + }: { + children: React.ReactNode + isShow: boolean + }) => isShow ?
{children}
: null, +})) + +vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor', () => ({ + default: ({ rootName }: { rootName: string }) =>
{rootName}
, +})) + +vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context', () => ({ + MittProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + VisualEditorContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +describe('SchemaModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not render content when hidden', () => { + render( + , + ) + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('renders the schema title and closes when the close control is clicked', () => { + const onClose = vi.fn() + render( + , + ) + + expect(screen.getByText('workflow.nodes.agent.parameterSchema')).toBeInTheDocument() + expect(screen.getByTestId('visual-editor')).toHaveTextContent('response') + + const closeButton = document.body.querySelector('div.absolute.right-5.top-5') + fireEvent.click(closeButton!) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx new file mode 100644 index 0000000000..03b684faac --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx @@ -0,0 +1,64 @@ +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { CollectionType } from '@/app/components/tools/types' +import ToolAuthorizationSection from '../tool-authorization-section' + +vi.mock('@/app/components/plugins/plugin-auth', () => ({ + AuthCategory: { + tool: 'tool', + }, + PluginAuthInAgent: ({ pluginPayload, credentialId }: { + pluginPayload: { provider: string, providerType: string } + credentialId?: string + }) => ( +
+ {pluginPayload.provider} + : + {pluginPayload.providerType} + : + {credentialId} +
+ ), +})) + +const createProvider = (overrides: Partial = {}): ToolWithProvider => ({ + name: 'provider-a', + type: CollectionType.builtIn, + allow_delete: true, + ...overrides, +}) as ToolWithProvider + +describe('ToolAuthorizationSection', () => { + it('returns null for providers that are not removable built-ins', () => { + const { container, rerender } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders the authorization panel for removable built-in providers', () => { + render( + , + ) + + expect(screen.getByTestId('plugin-auth-in-agent')).toHaveTextContent('provider-a:builtin:credential-1') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx new file mode 100644 index 0000000000..9a689dec8c --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ToolItem from '../tool-item' + +let mcpAllowed = true + +vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({ + useMCPToolAvailability: () => ({ + allowed: mcpAllowed, + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip', () => ({ + default: () =>
mcp unavailable
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ + InstallPluginButton: ({ onSuccess }: { onSuccess: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({ + SwitchPluginVersion: ({ onChange }: { onChange: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + children, + popupContent, + }: { + children: React.ReactNode + popupContent: React.ReactNode + }) => ( +
+ {children} +
{popupContent}
+
+ ), +})) + +describe('ToolItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mcpAllowed = true + }) + + it('shows auth status actions for no-auth and auth-removed states', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('tools.notAuthorized')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument() + }) + + it('surfaces install and version mismatch recovery actions', () => { + const onInstall = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByText('install plugin')) + expect(onInstall).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + fireEvent.click(screen.getByText('switch version')) + expect(onInstall).toHaveBeenCalledTimes(2) + }) + + it('blocks unsupported MCP tools and still exposes error state', () => { + mcpAllowed = false + const { rerender } = render( + , + ) + + expect(screen.getByTestId('mcp-tooltip')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('tool failed')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx new file mode 100644 index 0000000000..56c98f695d --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx @@ -0,0 +1,100 @@ +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ToolSettingsPanel from '../tool-settings-panel' + +vi.mock('@/app/components/base/tab-slider-plain', () => ({ + default: ({ + options, + onChange, + }: { + options: Array<{ value: string, text: string }> + onChange: (value: string) => void + }) => ( +
+ {options.map(option => ( + + ))} +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/tool-form', () => ({ + default: ({ schema }: { schema: Array<{ name: string }> }) =>
{schema.map(item => item.name).join(',')}
, +})) + +vi.mock('../reasoning-config-form', () => ({ + default: ({ schemas }: { schemas: Array<{ name: string }> }) =>
{schemas.map(item => item.name).join(',')}
, +})) + +const baseProps = { + nodeId: 'node-1', + currType: 'settings' as const, + settingsFormSchemas: [{ name: 'api_key' }] as never[], + paramsFormSchemas: [{ name: 'temperature' }] as never[], + settingsValue: {}, + showTabSlider: true, + userSettingsOnly: false, + reasoningConfigOnly: false, + nodeOutputVars: [], + availableNodes: [], + onCurrTypeChange: vi.fn(), + onSettingsFormChange: vi.fn(), + onParamsFormChange: vi.fn(), + currentProvider: { + is_team_authorization: true, + } as ToolWithProvider, +} + +describe('ToolSettingsPanel', () => { + it('returns null when the provider is not team-authorized or has no forms', () => { + const { container, rerender } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders the settings form and lets the tab slider switch to params', () => { + const onCurrTypeChange = vi.fn() + render( + , + ) + + expect(screen.getByTestId('tool-form')).toHaveTextContent('api_key') + fireEvent.click(screen.getByText('plugin.detailPanel.toolSelector.params')) + + expect(onCurrTypeChange).toHaveBeenCalledWith('params') + }) + + it('renders params tips and the reasoning config form for params-only views', () => { + render( + , + ) + + expect(screen.getAllByText('plugin.detailPanel.toolSelector.paramsTip1')).toHaveLength(2) + expect(screen.getByTestId('reasoning-config-form')).toHaveTextContent('temperature') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-trigger.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-trigger.spec.tsx new file mode 100644 index 0000000000..903e1ef687 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-trigger.spec.tsx @@ -0,0 +1,38 @@ +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ToolTrigger from '../tool-trigger' + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) + +describe('ToolTrigger', () => { + it('renders the placeholder for the unconfigured state', () => { + render() + + expect(screen.getByText('plugin.detailPanel.toolSelector.placeholder')).toBeInTheDocument() + }) + + it('renders the selected provider icon and tool label', () => { + render( + , + ) + + expect(screen.getByTestId('block-icon')).toBeInTheDocument() + expect(screen.getByText('Search Tool')).toBeInTheDocument() + }) + + it('switches to the configure placeholder when requested', () => { + render() + + expect(screen.getByText('plugin.detailPanel.configureTool')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts new file mode 100644 index 0000000000..86e42aab6b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts @@ -0,0 +1,233 @@ +import type { Node } from 'reactflow' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' +import { produce } from 'immer' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import { VarType } from '@/app/components/workflow/types' + +export type ReasoningConfigInputValue = { + type?: VarKindType + value?: unknown + [key: string]: unknown +} | null + +export type ReasoningConfigInput = { + value: ReasoningConfigInputValue + auto?: 0 | 1 +} + +export type ReasoningConfigValue = Record + +export const getVarKindType = (type: string) => { + if (type === FormTypeEnum.file || type === FormTypeEnum.files) + return VarKindType.variable + + if ([FormTypeEnum.select, FormTypeEnum.checkbox, FormTypeEnum.textNumber, FormTypeEnum.array, FormTypeEnum.object].includes(type as FormTypeEnum)) + return VarKindType.constant + + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return VarKindType.mixed + + return undefined +} + +export const resolveTargetVarType = (type: string) => { + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return VarType.string + if (type === FormTypeEnum.textNumber) + return VarType.number + if (type === FormTypeEnum.files) + return VarType.arrayFile + if (type === FormTypeEnum.file) + return VarType.file + if (type === FormTypeEnum.checkbox) + return VarType.boolean + if (type === FormTypeEnum.object) + return VarType.object + if (type === FormTypeEnum.array) + return VarType.arrayObject + + return VarType.string +} + +export const createFilterVar = (type: string) => { + if (type === FormTypeEnum.textNumber) + return (varPayload: Var) => varPayload.type === VarType.number + + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + + if (type === FormTypeEnum.file || type === FormTypeEnum.files) + return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type) + + if (type === FormTypeEnum.checkbox) + return (varPayload: Var) => varPayload.type === VarType.boolean + + if (type === FormTypeEnum.object) + return (varPayload: Var) => varPayload.type === VarType.object + + if (type === FormTypeEnum.array) + return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) + + return undefined +} + +export const getVisibleSelectOptions = ( + options: NonNullable, + value: ReasoningConfigValue, + language: string, +) => { + return options.filter((option) => { + if (option.show_on.length) + return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value) + + return true + }).map(option => ({ + value: option.value, + name: option.label[language] || option.label.en_US, + })) +} + +export const updateInputAutoState = ( + value: ReasoningConfigValue, + variable: string, + enabled: boolean, + type: string, +) => { + return { + ...value, + [variable]: { + value: enabled ? null : { type: getVarKindType(type), value: null }, + auto: enabled ? 1 as const : 0 as const, + }, + } +} + +export const updateVariableTypeValue = ( + value: ReasoningConfigValue, + variable: string, + newType: VarKindType, + defaultValue: unknown, +) => { + return produce(value, (draft) => { + draft[variable].value = { + type: newType, + value: newType === VarKindType.variable ? '' : defaultValue, + } + }) +} + +export const updateReasoningValue = ( + value: ReasoningConfigValue, + variable: string, + type: string, + newValue: unknown, +) => { + return produce(value, (draft) => { + draft[variable].value = { + type: getVarKindType(type), + value: newValue, + } + }) +} + +export const mergeReasoningValue = ( + value: ReasoningConfigValue, + variable: string, + newValue: Record, +) => { + return produce(value, (draft) => { + const currentValue = draft[variable].value as Record | undefined + draft[variable].value = { + ...currentValue, + ...newValue, + } + }) +} + +export const updateVariableSelectorValue = ( + value: ReasoningConfigValue, + variable: string, + newValue: ValueSelector | string, +) => { + return produce(value, (draft) => { + draft[variable].value = { + type: VarKindType.variable, + value: newValue, + } + }) +} + +export const getFieldFlags = (type: string, varInput?: ReasoningConfigInputValue) => { + const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput + const isNumber = type === FormTypeEnum.textNumber + const isObject = type === FormTypeEnum.object + const isArray = type === FormTypeEnum.array + const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files + const isBoolean = type === FormTypeEnum.checkbox + const isSelect = type === FormTypeEnum.select + const isAppSelector = type === FormTypeEnum.appSelector + const isModelSelector = type === FormTypeEnum.modelSelector + const isConstant = varInput?.type === VarKindType.constant || !varInput?.type + + return { + isString, + isNumber, + isObject, + isArray, + isShowJSONEditor: isObject || isArray, + isFile, + isBoolean, + isSelect, + isAppSelector, + isModelSelector, + showTypeSwitch: isNumber || isObject || isArray, + isConstant, + showVariableSelector: isFile || varInput?.type === VarKindType.variable, + } +} + +export const createPickerProps = ({ + type, + value, + language, + schema, +}: { + type: string + value: ReasoningConfigValue + language: string + schema: ToolFormSchema +}) => { + return { + filterVar: createFilterVar(type), + schema: schema as Partial, + targetVarType: resolveTargetVarType(type), + selectItems: schema.options ? getVisibleSelectOptions(schema.options, value, language) : [], + } +} + +export const getFieldTitle = (labels: { [key: string]: string }, language: string) => { + return labels[language] || labels.en_US +} + +export const createEmptyAppValue = () => ({ + app_id: '', + inputs: {}, + files: [], +}) + +export const createReasoningFormContext = ({ + availableNodes, + nodeId, + nodeOutputVars, +}: { + availableNodes: Node[] + nodeId: string + nodeOutputVars: NodeOutPutVar[] +}) => ({ + availableNodes, + nodeId, + nodeOutputVars, +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx index 38328aa8b3..1edc147370 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx @@ -1,19 +1,16 @@ import type { Node } from 'reactflow' -import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ReasoningConfigValue as ReasoningConfigValueShape } from './reasoning-config-form.helpers' import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' -import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' import type { NodeOutPutVar, ValueSelector, - Var, } from '@/app/components/workflow/types' import { RiArrowRightUpLine, RiBracesLine, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { produce } from 'immer' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' @@ -31,21 +28,21 @@ import VarReferencePicker from '@/app/components/workflow/nodes/_base/components import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' -import { VarType } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' +import { + createPickerProps, + getFieldFlags, + getFieldTitle, + mergeReasoningValue, + resolveTargetVarType, + updateInputAutoState, + updateReasoningValue, + updateVariableSelectorValue, + updateVariableTypeValue, +} from './reasoning-config-form.helpers' import SchemaModal from './schema-modal' -type ReasoningConfigInputValue = { - type?: VarKindType - value?: unknown -} | null - -type ReasoningConfigInput = { - value: ReasoningConfigInputValue - auto?: 0 | 1 -} - -export type ReasoningConfigValue = Record +export type ReasoningConfigValue = ReasoningConfigValueShape type Props = { value: ReasoningConfigValue @@ -66,79 +63,42 @@ const ReasoningConfigForm: React.FC = ({ }) => { const { t } = useTranslation() const language = useLanguage() - const getVarKindType = (type: string) => { - if (type === FormTypeEnum.file || type === FormTypeEnum.files) - return VarKindType.variable - if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object) - return VarKindType.constant - if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) - return VarKindType.mixed - } const handleAutomatic = (key: string, val: boolean, type: string) => { - onChange({ - ...value, - [key]: { - value: val ? null : { type: getVarKindType(type), value: null }, - auto: val ? 1 : 0, - }, - }) + onChange(updateInputAutoState(value, key, val, type)) } + const handleTypeChange = useCallback((variable: string, defaultValue: unknown) => { return (newType: VarKindType) => { - const res = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = { - type: newType, - value: newType === VarKindType.variable ? '' : defaultValue, - } - }) - onChange(res) + onChange(updateVariableTypeValue(value, variable, newType, defaultValue)) } }, [onChange, value]) + const handleValueChange = useCallback((variable: string, varType: string) => { return (newValue: unknown) => { - const res = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = { - type: getVarKindType(varType), - value: newValue, - } - }) - onChange(res) + onChange(updateReasoningValue(value, variable, varType, newValue)) } }, [onChange, value]) + const handleAppChange = useCallback((variable: string) => { return (app: { app_id: string inputs: Record files?: unknown[] }) => { - const newValue = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = app - }) - onChange(newValue) + onChange(updateReasoningValue(value, variable, FormTypeEnum.appSelector, app)) } }, [onChange, value]) + const handleModelChange = useCallback((variable: string) => { return (model: Record) => { - const newValue = produce(value, (draft: ToolVarInputs) => { - const currentValue = draft[variable].value as Record | undefined - draft[variable].value = { - ...currentValue, - ...model, - } - }) - onChange(newValue) + onChange(mergeReasoningValue(value, variable, model)) } }, [onChange, value]) + const handleVariableSelectorChange = useCallback((variable: string) => { return (newValue: ValueSelector | string) => { - const res = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = { - type: VarKindType.variable, - value: newValue, - } - }) - onChange(res) + onChange(updateVariableSelectorValue(value, variable, newValue)) } }, [onChange, value]) @@ -165,6 +125,7 @@ const ReasoningConfigForm: React.FC = ({ options, } = schema const auto = value[variable]?.auto + const fieldTitle = getFieldTitle(label, language) const tooltipContent = (tooltip && ( = ({ /> )) const varInput = value[variable].value - const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput - const isNumber = type === FormTypeEnum.textNumber - const isObject = type === FormTypeEnum.object - const isArray = type === FormTypeEnum.array - const isShowJSONEditor = isObject || isArray - const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files - const isBoolean = type === FormTypeEnum.checkbox - const isSelect = type === FormTypeEnum.select - const isAppSelector = type === FormTypeEnum.appSelector - const isModelSelector = type === FormTypeEnum.modelSelector - const showTypeSwitch = isNumber || isObject || isArray - const isConstant = varInput?.type === VarKindType.constant || !varInput?.type - const showVariableSelector = isFile || varInput?.type === VarKindType.variable - const targetVarType = () => { - if (isString) - return VarType.string - else if (isNumber) - return VarType.number - else if (type === FormTypeEnum.files) - return VarType.arrayFile - else if (type === FormTypeEnum.file) - return VarType.file - else if (isBoolean) - return VarType.boolean - else if (isObject) - return VarType.object - else if (isArray) - return VarType.arrayObject - else - return VarType.string - } - const getFilterVar = () => { - if (isNumber) - return (varPayload: Var) => varPayload.type === VarType.number - else if (isString) - return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) - else if (isFile) - return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type) - else if (isBoolean) - return (varPayload: Var) => varPayload.type === VarType.boolean - else if (isObject) - return (varPayload: Var) => varPayload.type === VarType.object - else if (isArray) - return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) - return undefined - } + const { + isString, + isNumber, + isShowJSONEditor, + isBoolean, + isSelect, + isAppSelector, + isModelSelector, + showTypeSwitch, + isConstant, + showVariableSelector, + } = getFieldFlags(type, varInput) + const pickerProps = createPickerProps({ + type, + value, + language, + schema, + }) return (
- {label[language] || label.en_US} + {fieldTitle} {required && ( * )} {tooltipContent} · - {targetVarType()} + {resolveTargetVarType(type)} {isShowJSONEditor && ( = ({ >
showSchema(input_schema as SchemaRoot, label[language] || label.en_US)} + onClick={() => showSchema(input_schema as SchemaRoot, fieldTitle)} >
@@ -295,12 +228,7 @@ const ReasoningConfigForm: React.FC = ({ { - if (option.show_on.length) - return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value) - - return true - }).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))} + items={pickerProps.selectItems} onSelect={item => handleValueChange(variable, type)(item.value as string)} placeholder={placeholder?.[language] || placeholder?.en_US} /> @@ -347,9 +275,9 @@ const ReasoningConfigForm: React.FC = ({ nodeId={nodeId} value={(varInput?.value as string | ValueSelector) || []} onChange={handleVariableSelectorChange(variable)} - filterVar={getFilterVar()} - schema={schema as Partial} - valueTypePlaceHolder={targetVarType()} + filterVar={pickerProps.filterVar} + schema={pickerProps.schema} + valueTypePlaceHolder={pickerProps.targetVarType} /> )}
diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts new file mode 100644 index 0000000000..33a05be1b8 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { usePluginInstalledCheck, useToolSelectorState } from '../index' + +describe('tool-selector hooks index', () => { + it('re-exports the tool selector hooks', () => { + expect(usePluginInstalledCheck).toBeTypeOf('function') + expect(useToolSelectorState).toBeTypeOf('function') + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx new file mode 100644 index 0000000000..476ab8e145 --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx @@ -0,0 +1,76 @@ +import { fireEvent, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { renderWithNuqs } from '@/test/nuqs-testing' +import { usePluginPageContext } from '../context' +import { PluginPageContextProvider } from '../context-provider' + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) + +vi.mock('../../hooks', () => ({ + PLUGIN_PAGE_TABS_MAP: { + plugins: 'plugins', + marketplace: 'discover', + }, + usePluginPageTabs: () => [ + { value: 'plugins', text: 'Plugins' }, + { value: 'discover', text: 'Discover' }, + ], +})) + +const mockGlobalPublicStore = (enableMarketplace: boolean) => { + vi.mocked(useGlobalPublicStore).mockImplementation((selector) => { + const state = { systemFeatures: { enable_marketplace: enableMarketplace } } + return selector(state as Parameters[0]) + }) +} + +const Consumer = () => { + const currentPluginID = usePluginPageContext(v => v.currentPluginID) + const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID) + const options = usePluginPageContext(v => v.options) + + return ( +
+ {currentPluginID ?? 'none'} + {options.length} + +
+ ) +} + +describe('PluginPageContextProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('filters out the marketplace tab when the feature is disabled', () => { + mockGlobalPublicStore(false) + + renderWithNuqs( + + + , + ) + + expect(screen.getByTestId('options-count')).toHaveTextContent('1') + }) + + it('keeps the query-state tab and updates the current plugin id', () => { + mockGlobalPublicStore(true) + + renderWithNuqs( + + + , + { searchParams: '?tab=discover' }, + ) + + fireEvent.click(screen.getByText('select plugin')) + + expect(screen.getByTestId('current-plugin')).toHaveTextContent('plugin-1') + expect(screen.getByTestId('options-count')).toHaveTextContent('2') + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx new file mode 100644 index 0000000000..ceec84a286 --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DebugInfo from '../debug-info' + +const mockDebugKey = vi.hoisted(() => ({ + data: null as null | { key: string, host: string, port: number }, + isLoading: false, +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +vi.mock('@/service/use-plugins', () => ({ + useDebugKey: () => mockDebugKey, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children }: { children: React.ReactNode }) => , +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + children, + disabled, + popupContent, + }: { + children: React.ReactNode + disabled?: boolean + popupContent: React.ReactNode + }) => ( +
+ {children} + {!disabled &&
{popupContent}
} +
+ ), +})) + +vi.mock('../../base/key-value-item', () => ({ + default: ({ + label, + value, + maskedValue, + }: { + label: string + value: string + maskedValue?: string + }) => ( +
+ {label} + : + {maskedValue || value} +
+ ), +})) + +describe('DebugInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDebugKey.data = null + mockDebugKey.isLoading = false + }) + + it('renders nothing while the debug key is loading', () => { + mockDebugKey.isLoading = true + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('renders debug metadata and masks the key when info is available', () => { + mockDebugKey.data = { + host: '127.0.0.1', + port: 5001, + key: '12345678abcdefghijklmnopqrst87654321', + } + + render() + + expect(screen.getByTestId('debug-button')).toBeInTheDocument() + expect(screen.getByText('plugin.debugInfo.title')).toBeInTheDocument() + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + 'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin', + ) + expect(screen.getByTestId('kv-URL')).toHaveTextContent('URL:127.0.0.1:5001') + expect(screen.getByTestId('kv-Key')).toHaveTextContent('Key:12345678********87654321') + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx new file mode 100644 index 0000000000..d3b72ebe5b --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import InstallPluginDropdown from '../install-plugin-dropdown' + +let portalOpen = false +const { + mockSystemFeatures, +} = vi.hoisted(() => ({ + mockSystemFeatures: { + enable_marketplace: true, + plugin_installation_permission: { + restrict_to_marketplace_only: false, + }, + }, +})) + +vi.mock('@/config', () => ({ + SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS: '.difypkg,.zip', +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) => + selector({ systemFeatures: mockSystemFeatures }), +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/files', () => ({ + FileZip: () => file, +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({ + Github: () => github, +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({ + MagicBox: () => magic, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children }: { children: React.ReactNode }) => {children}, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await import('react') + return { + PortalToFollowElem: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => { + portalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => , + PortalToFollowElemContent: ({ + children, + }: { + children: React.ReactNode + }) => portalOpen ?
{children}
: null, + } +}) + +vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ + default: ({ + file, + onClose, + }: { + file: File + onClose: () => void + }) => ( +
+ {file.name} + +
+ ), +})) + +describe('InstallPluginDropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + portalOpen = false + mockSystemFeatures.enable_marketplace = true + mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = false + }) + + it('shows all install methods when marketplace and custom installs are enabled', () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + + expect(screen.getByText('plugin.installFrom')).toBeInTheDocument() + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() + }) + + it('shows only marketplace when installation is restricted', () => { + mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = true + + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument() + }) + + it('switches to marketplace when the marketplace action is selected', () => { + const onSwitchToMarketplaceTab = vi.fn() + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.marketplace')) + + expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1) + }) + + it('opens the github installer when github is selected', () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.github')) + + expect(screen.getByTestId('github-modal')).toBeInTheDocument() + }) + + it('opens the local package installer when a file is selected', () => { + const { container } = render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.change(container.querySelector('input[type="file"]')!, { + target: { + files: [new File(['content'], 'plugin.difypkg')], + }, + }) + + expect(screen.getByTestId('local-modal')).toBeInTheDocument() + expect(screen.getByText('plugin.difypkg')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx new file mode 100644 index 0000000000..bad857077a --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -0,0 +1,200 @@ +import type { PluginDetail } from '../../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import PluginsPanel from '../plugins-panel' + +const mockState = vi.hoisted(() => ({ + filters: { + categories: [] as string[], + tags: [] as string[], + searchQuery: '', + }, + currentPluginID: undefined as string | undefined, +})) + +const mockSetFilters = vi.fn() +const mockSetCurrentPluginID = vi.fn() +const mockLoadNextPage = vi.fn() +const mockInvalidateInstalledPluginList = vi.fn() +const mockUseInstalledPluginList = vi.fn() +const mockPluginListWithLatestVersion = vi.fn<() => PluginDetail[]>(() => []) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (value: Record, locale: string) => value[locale] || '', +})) + +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => mockUseInstalledPluginList(), + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +vi.mock('../../hooks', () => ({ + usePluginsWithLatestVersion: () => mockPluginListWithLatestVersion(), +})) + +vi.mock('../context', () => ({ + usePluginPageContext: (selector: (value: { + filters: typeof mockState.filters + setFilters: typeof mockSetFilters + currentPluginID: string | undefined + setCurrentPluginID: typeof mockSetCurrentPluginID + }) => unknown) => selector({ + filters: mockState.filters, + setFilters: mockSetFilters, + currentPluginID: mockState.currentPluginID, + setCurrentPluginID: mockSetCurrentPluginID, + }), +})) + +vi.mock('../filter-management', () => ({ + default: ({ onFilterChange }: { onFilterChange: (filters: typeof mockState.filters) => void }) => ( + + ), +})) + +vi.mock('../empty', () => ({ + default: () =>
empty
, +})) + +vi.mock('../list', () => ({ + default: ({ pluginList }: { pluginList: PluginDetail[] }) =>
{pluginList.map(plugin => plugin.plugin_id).join(',')}
, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ + default: ({ detail, onHide, onUpdate }: { + detail?: PluginDetail + onHide: () => void + onUpdate: () => void + }) => ( +
+ {detail?.plugin_id ?? 'none'} + + +
+ ), +})) + +const createPlugin = (pluginId: string, label: string, tags: string[] = []): PluginDetail => ({ + id: pluginId, + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: label, + plugin_id: pluginId, + plugin_unique_identifier: `${pluginId}-uid`, + declaration: { + category: 'tool', + name: pluginId, + label: { en_US: label }, + description: { en_US: `${label} description` }, + tags, + } as PluginDetail['declaration'], + installation_id: `${pluginId}-install`, + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: `${pluginId}-uid`, + source: 'marketplace' as PluginDetail['source'], + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', +}) as PluginDetail + +describe('PluginsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockState.filters = { categories: [], tags: [], searchQuery: '' } + mockState.currentPluginID = undefined + mockUseInstalledPluginList.mockReturnValue({ + data: { plugins: [] }, + isLoading: false, + isFetching: false, + isLastPage: true, + loadNextPage: mockLoadNextPage, + }) + mockPluginListWithLatestVersion.mockReturnValue([]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the loading state while the plugin list is pending', () => { + mockUseInstalledPluginList.mockReturnValue({ + data: { plugins: [] }, + isLoading: true, + isFetching: false, + isLastPage: true, + loadNextPage: mockLoadNextPage, + }) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('filters the list and exposes the load-more action', () => { + mockState.filters.searchQuery = 'alpha' + mockPluginListWithLatestVersion.mockReturnValue([ + createPlugin('alpha-tool', 'Alpha Tool', ['search']), + createPlugin('beta-tool', 'Beta Tool', ['rag']), + ]) + mockUseInstalledPluginList.mockReturnValue({ + data: { plugins: [] }, + isLoading: false, + isFetching: false, + isLastPage: false, + loadNextPage: mockLoadNextPage, + }) + + render() + + expect(screen.getByTestId('plugin-list')).toHaveTextContent('alpha-tool') + expect(screen.queryByText('beta-tool')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('workflow.common.loadMore')) + fireEvent.click(screen.getByTestId('filter-management')) + vi.runAllTimers() + + expect(mockLoadNextPage).toHaveBeenCalled() + expect(mockSetFilters).toHaveBeenCalledWith({ + categories: [], + tags: [], + searchQuery: 'beta', + }) + }) + + it('renders the empty state and keeps the current plugin detail in sync', () => { + mockState.currentPluginID = 'beta-tool' + mockState.filters.searchQuery = 'missing' + mockPluginListWithLatestVersion.mockReturnValue([ + createPlugin('beta-tool', 'Beta Tool'), + ]) + + render() + + expect(screen.getByTestId('empty-state')).toBeInTheDocument() + expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('beta-tool') + + fireEvent.click(screen.getByText('hide detail')) + fireEvent.click(screen.getByText('refresh detail')) + + expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined) + expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts b/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts new file mode 100644 index 0000000000..7286ff549f --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts @@ -0,0 +1,32 @@ +import type { Category, Tag } from '../constant' +import { describe, expect, it } from 'vitest' + +describe('filter-management constant types', () => { + it('accepts tag objects with binding counts', () => { + const tag: Tag = { + id: 'tag-1', + name: 'search', + type: 'plugin', + binding_count: 3, + } + + expect(tag).toEqual({ + id: 'tag-1', + name: 'search', + type: 'plugin', + binding_count: 3, + }) + }) + + it('accepts supported category names', () => { + const category: Category = { + name: 'tool', + binding_count: 8, + } + + expect(category).toEqual({ + name: 'tool', + binding_count: 8, + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx new file mode 100644 index 0000000000..ff3cd3d97c --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TagFilter from '../tag-filter' + +let portalOpen = false + +vi.mock('../../../hooks', () => ({ + useTags: () => ({ + tags: [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + ], + getTagLabel: (name: string) => ({ + agent: 'Agent', + rag: 'RAG', + search: 'Search', + }[name] ?? name), + }), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ + children, + open, + }: { + children: React.ReactNode + open: boolean + }) => { + portalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => , + PortalToFollowElemContent: ({ + children, + }: { + children: React.ReactNode + }) => portalOpen ?
{children}
: null, +})) + +describe('TagFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + portalOpen = false + }) + + it('renders selected tag labels and the overflow counter', () => { + render() + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('filters options by search text and toggles tag selection', () => { + const onChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId('trigger')) + const portal = screen.getByTestId('portal-content') + + fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } }) + + expect(within(portal).queryByText('Agent')).not.toBeInTheDocument() + expect(within(portal).getByText('RAG')).toBeInTheDocument() + + fireEvent.click(within(portal).getByText('RAG')) + + expect(onChange).toHaveBeenCalledWith(['agent', 'rag']) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts new file mode 100644 index 0000000000..36450a4386 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { defaultValue } from '../config' +import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../types' + +describe('auto-update config', () => { + it('provides the expected default auto update value', () => { + expect(defaultValue).toEqual({ + strategy_setting: AUTO_UPDATE_STRATEGY.disabled, + upgrade_time_of_day: 0, + upgrade_mode: AUTO_UPDATE_MODE.update_all, + exclude_plugins: [], + include_plugins: [], + }) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx new file mode 100644 index 0000000000..d205682690 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import NoDataPlaceholder from '../no-data-placeholder' + +describe('NoDataPlaceholder', () => { + it('renders the no-found state by default', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noFound')).toBeInTheDocument() + }) + + it('renders the no-installed state when noPlugins is true', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx new file mode 100644 index 0000000000..ba172ad3d6 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import NoPluginSelected from '../no-plugin-selected' +import { AUTO_UPDATE_MODE } from '../types' + +describe('NoPluginSelected', () => { + it('renders partial mode placeholder', () => { + render() + + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument() + }) + + it('renders exclude mode placeholder', () => { + render() + + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.exclude')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx new file mode 100644 index 0000000000..4330f35bb4 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import PluginsPicker from '../plugins-picker' +import { AUTO_UPDATE_MODE } from '../types' + +const mockToolPicker = vi.fn() + +vi.mock('@/app/components/base/button', () => ({ + default: ({ + children, + }: { + children: React.ReactNode + }) => , +})) + +vi.mock('../no-plugin-selected', () => ({ + default: ({ updateMode }: { updateMode: AUTO_UPDATE_MODE }) =>
{updateMode}
, +})) + +vi.mock('../plugins-selected', () => ({ + default: ({ plugins }: { plugins: string[] }) =>
{plugins.join(',')}
, +})) + +vi.mock('../tool-picker', () => ({ + default: (props: Record) => { + mockToolPicker(props) + return
tool-picker
+ }, +})) + +describe('PluginsPicker', () => { + it('renders the empty state when no plugins are selected', () => { + render( + , + ) + + expect(screen.getByTestId('no-plugin-selected')).toHaveTextContent(AUTO_UPDATE_MODE.partial) + expect(screen.queryByTestId('plugins-selected')).not.toBeInTheDocument() + expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({ + value: [], + isShow: false, + onShowChange: expect.any(Function), + })) + }) + + it('renders selected plugins summary and clears them', () => { + const onChange = vi.fn() + render( + , + ) + + expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":2}')).toBeInTheDocument() + expect(screen.getByTestId('plugins-selected')).toHaveTextContent('dify/plugin-1,dify/plugin-2') + + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) + + expect(onChange).toHaveBeenCalledWith([]) + }) + + it('passes the select button trigger into ToolPicker', () => { + render( + , + ) + + expect(screen.getByTestId('tool-picker')).toBeInTheDocument() + expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({ + trigger: expect.anything(), + })) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx new file mode 100644 index 0000000000..cc4693f89c --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import PluginsSelected from '../plugins-selected' + +vi.mock('@/config', () => ({ + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
{src}
, +})) + +describe('PluginsSelected', () => { + it('renders all selected plugin icons when the count is below the limit', () => { + render() + + expect(screen.getAllByTestId('plugin-icon')).toHaveLength(2) + expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument() + expect(screen.queryByText('+1')).not.toBeInTheDocument() + }) + + it('renders the overflow badge when more than fourteen plugins are selected', () => { + const plugins = Array.from({ length: 16 }, (_, index) => `dify/plugin-${index}`) + render() + + expect(screen.getAllByTestId('plugin-icon')).toHaveLength(14) + expect(screen.getByText('+2')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx new file mode 100644 index 0000000000..aec57a2739 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import StrategyPicker from '../strategy-picker' +import { AUTO_UPDATE_STRATEGY } from '../types' + +let portalOpen = false + +vi.mock('@/app/components/base/button', () => ({ + default: ({ + children, + }: { + children: React.ReactNode + }) => {children}, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await import('react') + return { + PortalToFollowElem: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => { + portalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void + }) => ( + + ), + PortalToFollowElemContent: ({ + children, + }: { + children: React.ReactNode + }) => portalOpen ?
{children}
: null, + } +}) + +describe('StrategyPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + portalOpen = false + }) + + it('renders the selected strategy label in the trigger', () => { + render( + , + ) + + expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name') + }) + + it('opens the option list when the trigger is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger')) + + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1) + expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument() + }) + + it('calls onChange when a new strategy is selected', () => { + const onChange = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger')) + fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name')) + + expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx new file mode 100644 index 0000000000..f15fe5933f --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx @@ -0,0 +1,65 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ToolItem from '../tool-item' + +vi.mock('@/config', () => ({ + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (value: Record, language: string) => value[language], +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
{src}
, +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ + checked, + onCheck, + }: { + checked?: boolean + onCheck: () => void + }) => ( + + ), +})) + +const payload = { + plugin_id: 'dify/plugin-1', + declaration: { + label: { + en_US: 'Plugin One', + zh_Hans: 'Plugin One', + }, + author: 'Dify', + }, +} as PluginDetail + +describe('ToolItem', () => { + it('renders plugin metadata and marketplace icon', () => { + render() + + expect(screen.getByText('Plugin One')).toBeInTheDocument() + expect(screen.getByText('Dify')).toBeInTheDocument() + expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument() + expect(screen.getByText('true')).toBeInTheDocument() + }) + + it('calls onCheckChange when checkbox is clicked', () => { + const onCheckChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId('checkbox')) + + expect(onCheckChange).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx new file mode 100644 index 0000000000..9e63622d3f --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx @@ -0,0 +1,248 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginSource } from '@/app/components/plugins/types' +import ToolPicker from '../tool-picker' + +let portalOpen = false + +const mockInstalledPluginList = vi.hoisted(() => ({ + data: { + plugins: [] as PluginDetail[], + }, + isLoading: false, +})) + +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => mockInstalledPluginList, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await import('react') + return { + PortalToFollowElem: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => { + portalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => , + PortalToFollowElemContent: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => portalOpen ?
{children}
: null, + } +}) + +vi.mock('@/app/components/plugins/marketplace/search-box', () => ({ + default: ({ + search, + tags, + onSearchChange, + onTagsChange, + placeholder, + }: { + search: string + tags: string[] + onSearchChange: (value: string) => void + onTagsChange: (value: string[]) => void + placeholder: string + }) => ( +
+
{placeholder}
+
{search}
+
{tags.join(',')}
+ + +
+ ), +})) + +vi.mock('../no-data-placeholder', () => ({ + default: ({ + noPlugins, + }: { + noPlugins?: boolean + }) =>
{String(noPlugins)}
, +})) + +vi.mock('../tool-item', () => ({ + default: ({ + payload, + isChecked, + onCheckChange, + }: { + payload: PluginDetail + isChecked?: boolean + onCheckChange: () => void + }) => ( +
+ {payload.plugin_id} + {String(isChecked)} + +
+ ), +})) + +const createPlugin = ( + pluginId: string, + source: PluginDetail['source'], + category: string, + tags: string[], +): PluginDetail => ({ + plugin_id: pluginId, + source, + declaration: { + category, + tags, + }, +} as PluginDetail) + +describe('ToolPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + portalOpen = false + mockInstalledPluginList.data = { + plugins: [], + } + mockInstalledPluginList.isLoading = false + }) + + it('toggles popup visibility from the trigger', () => { + const onShowChange = vi.fn() + render( + trigger} + value={[]} + onChange={vi.fn()} + isShow={false} + onShowChange={onShowChange} + />, + ) + + fireEvent.click(screen.getByTestId('trigger')) + + expect(onShowChange).toHaveBeenCalledWith(true) + }) + + it('renders loading content while installed plugins are loading', () => { + mockInstalledPluginList.isLoading = true + + render( + trigger} + value={[]} + onChange={vi.fn()} + isShow + onShowChange={vi.fn()} + />, + ) + + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('renders no-data placeholder when there are no matching marketplace plugins', () => { + render( + trigger} + value={[]} + onChange={vi.fn()} + isShow + onShowChange={vi.fn()} + />, + ) + + expect(screen.getByTestId('no-data')).toHaveTextContent('true') + }) + + it('filters by plugin type, tags, and query', () => { + mockInstalledPluginList.data = { + plugins: [ + createPlugin('tool-search', PluginSource.marketplace, 'tool', ['search']), + createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']), + createPlugin('model-agent', PluginSource.marketplace, 'model', ['agent']), + createPlugin('github-tool', PluginSource.github, 'tool', ['rag']), + ], + } + + render( + trigger} + value={[]} + onChange={vi.fn()} + isShow + onShowChange={vi.fn()} + />, + ) + + expect(screen.getAllByTestId('tool-item')).toHaveLength(3) + expect(screen.queryByText('github-tool')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('plugin.category.models')) + expect(screen.getAllByTestId('tool-item')).toHaveLength(1) + expect(screen.getByText('model-agent')).toBeInTheDocument() + + fireEvent.click(screen.getByText('plugin.category.tools')) + expect(screen.getAllByTestId('tool-item')).toHaveLength(2) + + fireEvent.click(screen.getByTestId('set-tags')) + expect(screen.getAllByTestId('tool-item')).toHaveLength(1) + expect(screen.getByText('tool-rag')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('set-query')) + expect(screen.getAllByTestId('tool-item')).toHaveLength(1) + expect(screen.getByTestId('search-state')).toHaveTextContent('tool-rag') + }) + + it('adds and removes plugin ids from the selection', () => { + mockInstalledPluginList.data = { + plugins: [ + createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']), + createPlugin('tool-search', PluginSource.marketplace, 'tool', ['search']), + ], + } + const onChange = vi.fn() + const { rerender } = render( + trigger} + value={['tool-rag']} + onChange={onChange} + isShow + onShowChange={vi.fn()} + />, + ) + + fireEvent.click(screen.getByTestId('toggle-tool-search')) + expect(onChange).toHaveBeenCalledWith(['tool-rag', 'tool-search']) + + rerender( + trigger} + value={['tool-rag']} + onChange={onChange} + isShow + onShowChange={vi.fn()} + />, + ) + + fireEvent.click(screen.getByTestId('toggle-tool-rag')) + expect(onChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx new file mode 100644 index 0000000000..b66ab20a45 --- /dev/null +++ b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx @@ -0,0 +1,226 @@ +import type { UpdateFromMarketPlacePayload } from '@/app/components/plugins/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types' +import UpdateFromMarketplace from '../from-market-place' + +const { + mockStop, + mockCheck, + mockHandleRefetch, + mockInvalidateReferenceSettings, + mockRemoveAutoUpgrade, + mockUpdateFromMarketPlace, + mockToastError, +} = vi.hoisted(() => ({ + mockStop: vi.fn(), + mockCheck: vi.fn(), + mockHandleRefetch: vi.fn(), + mockInvalidateReferenceSettings: vi.fn(), + mockRemoveAutoUpgrade: vi.fn(), + mockUpdateFromMarketPlace: vi.fn(), + mockToastError: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogCloseButton: () => , +})) + +vi.mock('@/app/components/base/badge/index', () => ({ + __esModule: true, + BadgeState: { + Warning: 'warning', + }, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => , +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: mockToastError, + }, +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ titleLeft, payload }: { titleLeft: React.ReactNode, payload: { label: Record } }) => ( +
+
{payload.label.en_US}
+
{titleLeft}
+
+ ), +})) + +vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({ + default: () => ({ + check: mockCheck, + stop: mockStop, + }), +})) + +vi.mock('@/app/components/plugins/install-plugin/utils', () => ({ + pluginManifestToCardPluginProps: (payload: unknown) => payload, +})) + +vi.mock('@/service/plugins', () => ({ + updateFromMarketPlace: mockUpdateFromMarketPlace, +})) + +vi.mock('@/service/use-plugins', () => ({ + usePluginTaskList: () => ({ + handleRefetch: mockHandleRefetch, + }), + useRemoveAutoUpgrade: () => ({ + mutateAsync: mockRemoveAutoUpgrade, + }), + useInvalidateReferenceSettings: () => mockInvalidateReferenceSettings, +})) + +vi.mock('../install-plugin/base/use-get-icon', () => ({ + default: () => ({ + getIconUrl: async (icon: string) => `https://cdn.example.com/${icon}`, + }), +})) + +vi.mock('../downgrade-warning', () => ({ + default: ({ + onCancel, + onJustDowngrade, + onExcludeAndDowngrade, + }: { + onCancel: () => void + onJustDowngrade: () => void + onExcludeAndDowngrade: () => void + }) => ( +
+ + + +
+ ), +})) + +const createPayload = (overrides: Partial = {}): UpdateFromMarketPlacePayload => ({ + category: PluginCategoryEnum.tool, + originalPackageInfo: { + id: 'plugin@1.0.0', + payload: { + version: '1.0.0', + icon: 'plugin.png', + label: { en_US: 'Plugin Label' }, + } as UpdateFromMarketPlacePayload['originalPackageInfo']['payload'], + }, + targetPackageInfo: { + id: 'plugin@2.0.0', + version: '2.0.0', + }, + ...overrides, +}) + +describe('UpdateFromMarketplace', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheck.mockResolvedValue({ status: TaskStatus.success }) + mockUpdateFromMarketPlace.mockResolvedValue({ + all_installed: true, + task_id: 'task-1', + }) + }) + + it('renders the upgrade modal content and current version transition', async () => { + render( + , + ) + + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.description')).toBeInTheDocument() + expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('plugin-card')).toHaveTextContent('Plugin Label') + }) + }) + + it('submits the marketplace upgrade and calls onSave when installation is immediate', async () => { + const onSave = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('plugin.upgrade.upgrade')) + + await waitFor(() => { + expect(mockUpdateFromMarketPlace).toHaveBeenCalledWith({ + original_plugin_unique_identifier: 'plugin@1.0.0', + new_plugin_unique_identifier: 'plugin@2.0.0', + }) + expect(onSave).toHaveBeenCalled() + }) + }) + + it('surfaces failed upgrade messages from the response task payload', async () => { + mockUpdateFromMarketPlace.mockResolvedValue({ + task: { + status: TaskStatus.failed, + plugins: [{ + plugin_unique_identifier: 'plugin@2.0.0', + message: 'upgrade failed', + }], + }, + }) + + render( + , + ) + + fireEvent.click(screen.getByText('plugin.upgrade.upgrade')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('upgrade failed') + }) + }) + + it('removes auto-upgrade before downgrading when the warning modal is shown', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('exclude and downgrade')) + + await waitFor(() => { + expect(mockRemoveAutoUpgrade).toHaveBeenCalledWith({ plugin_id: 'plugin-1' }) + expect(mockInvalidateReferenceSettings).toHaveBeenCalled() + expect(mockUpdateFromMarketPlace).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx new file mode 100644 index 0000000000..b65c6a6e42 --- /dev/null +++ b/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PluginVersionPicker from '../plugin-version-picker' + +type VersionItem = { + version: string + unique_identifier: string + created_at: string +} + +const mockVersionList = vi.hoisted(() => ({ + data: { + versions: [] as VersionItem[], + }, +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatDate: (value: string, format: string) => `${value}:${format}`, + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useVersionListOfPlugin: () => ({ + data: mockVersionList, + }), +})) + +describe('PluginVersionPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVersionList.data.versions = [ + { + version: '2.0.0', + unique_identifier: 'uid-current', + created_at: '2024-01-02', + }, + { + version: '1.0.0', + unique_identifier: 'uid-old', + created_at: '2023-12-01', + }, + ] + }) + + it('renders version options and highlights the current version', () => { + render( + trigger} + onSelect={vi.fn()} + />, + ) + + expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument() + expect(screen.getByText('2.0.0')).toBeInTheDocument() + expect(screen.getByText('2024-01-02:appLog.dateTimeFormat')).toBeInTheDocument() + expect(screen.getByText('CURRENT')).toBeInTheDocument() + }) + + it('calls onSelect with downgrade metadata and closes the picker', () => { + const onSelect = vi.fn() + const onShowChange = vi.fn() + + render( + trigger} + onSelect={onSelect} + />, + ) + + fireEvent.click(screen.getByText('1.0.0')) + + expect(onSelect).toHaveBeenCalledWith({ + version: '1.0.0', + unique_identifier: 'uid-old', + isDowngrade: true, + }) + expect(onShowChange).toHaveBeenCalledWith(false) + }) + + it('does not call onSelect when the current version is clicked', () => { + const onSelect = vi.fn() + + render( + trigger} + onSelect={onSelect} + />, + ) + + fireEvent.click(screen.getByText('2.0.0')) + + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx new file mode 100644 index 0000000000..fcb208fc67 --- /dev/null +++ b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx @@ -0,0 +1,141 @@ +import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' +import RagPipelineChildren from '../rag-pipeline-children' + +let mockShowImportDSLModal = false +let mockSubscription: ((value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null + +const { + mockSetShowImportDSLModal, + mockHandlePaneContextmenuCancel, + mockExportCheck, + mockHandleExportDSL, + mockUseRagPipelineSearch, +} = vi.hoisted(() => ({ + mockSetShowImportDSLModal: vi.fn((value: boolean) => { + mockShowImportDSLModal = value + }), + mockHandlePaneContextmenuCancel: vi.fn(), + mockExportCheck: vi.fn(), + mockHandleExportDSL: vi.fn(), + mockUseRagPipelineSearch: vi.fn(), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: (callback: (value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => { + mockSubscription = callback + }, + }, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + showImportDSLModal: boolean + setShowImportDSLModal: typeof mockSetShowImportDSLModal + }) => unknown) => selector({ + showImportDSLModal: mockShowImportDSLModal, + setShowImportDSLModal: mockSetShowImportDSLModal, + }), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useDSL: () => ({ + exportCheck: mockExportCheck, + handleExportDSL: mockHandleExportDSL, + }), + usePanelInteractions: () => ({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + }), +})) + +vi.mock('../../hooks/use-rag-pipeline-search', () => ({ + useRagPipelineSearch: mockUseRagPipelineSearch, +})) + +vi.mock('../../../workflow/plugin-dependency', () => ({ + default: () =>
, +})) + +vi.mock('../panel', () => ({ + default: () =>
, +})) + +vi.mock('../publish-toast', () => ({ + default: () =>
, +})) + +vi.mock('../rag-pipeline-header', () => ({ + default: () =>
, +})) + +vi.mock('../update-dsl-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ + default: ({ + envList, + onConfirm, + onClose, + }: { + envList: EnvironmentVariable[] + onConfirm: () => void + onClose: () => void + }) => ( +
+
{envList.map(env => env.name).join(',')}
+ + +
+ ), +})) + +describe('RagPipelineChildren', () => { + beforeEach(() => { + vi.clearAllMocks() + mockShowImportDSLModal = false + mockSubscription = null + }) + + it('should render the main pipeline children and the import modal when enabled', () => { + mockShowImportDSLModal = true + + render() + + fireEvent.click(screen.getByText('close import')) + + expect(mockUseRagPipelineSearch).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument() + expect(screen.getByTestId('rag-header')).toBeInTheDocument() + expect(screen.getByTestId('rag-panel')).toBeInTheDocument() + expect(screen.getByTestId('publish-toast')).toBeInTheDocument() + expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument() + expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false) + }) + + it('should show the DSL export confirmation modal after receiving the export event', () => { + render() + + act(() => { + mockSubscription?.({ + type: DSL_EXPORT_CHECK, + payload: { + data: [{ name: 'API_KEY' } as EnvironmentVariable], + }, + }) + }) + + fireEvent.click(screen.getByText('confirm export')) + + expect(screen.getByTestId('dsl-export-modal')).toHaveTextContent('API_KEY') + expect(mockHandleExportDSL).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx new file mode 100644 index 0000000000..1854b2a683 --- /dev/null +++ b/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import PipelineScreenShot from '../screenshot' + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: 'dark', + }), +})) + +vi.mock('@/utils/var', () => ({ + basePath: '/console', +})) + +describe('PipelineScreenShot', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build themed screenshot sources', () => { + const { container } = render() + const sources = container.querySelectorAll('source') + + expect(sources).toHaveLength(3) + expect(sources[0]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline.png') + expect(sources[1]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@2x.png') + expect(sources[2]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@3x.png') + expect(screen.getByAltText('Pipeline Screenshot')).toHaveAttribute('src', '/console/screenshots/dark/Pipeline.png') + }) +}) diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx new file mode 100644 index 0000000000..43dffb80f9 --- /dev/null +++ b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import QAItem from '../q-a-item' +import { QAItemType } from '../types' + +describe('QAItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the question prefix', () => { + render() + + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('What is Dify?')).toBeInTheDocument() + }) + + it('should render the answer prefix', () => { + render() + + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('An LLM app platform.')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts new file mode 100644 index 0000000000..e4e53a4c5b --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts @@ -0,0 +1,97 @@ +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { VAR_ITEM_TEMPLATE_IN_PIPELINE } from '@/config' +import { PipelineInputVarType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' +import { convertFormDataToINputField, convertToInputFieldFormData } from '../utils' + +describe('input-field editor utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should convert pipeline input vars into form data', () => { + const result = convertToInputFieldFormData({ + type: PipelineInputVarType.multiFiles, + label: 'Upload files', + variable: 'documents', + max_length: 5, + default_value: 'initial-value', + required: false, + tooltips: 'Tooltip text', + options: ['a', 'b'], + placeholder: 'Select files', + unit: 'MB', + allowed_file_upload_methods: [TransferMethod.local_file], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['pdf'], + }) + + expect(result).toEqual({ + type: PipelineInputVarType.multiFiles, + label: 'Upload files', + variable: 'documents', + maxLength: 5, + default: 'initial-value', + required: false, + tooltips: 'Tooltip text', + options: ['a', 'b'], + placeholder: 'Select files', + unit: 'MB', + allowedFileUploadMethods: [TransferMethod.local_file], + allowedTypesAndExtensions: { + allowedFileTypes: [SupportUploadFileTypes.document], + allowedFileExtensions: ['pdf'], + }, + }) + }) + + it('should fall back to the default input variable template', () => { + const result = convertToInputFieldFormData() + + expect(result).toEqual({ + type: VAR_ITEM_TEMPLATE_IN_PIPELINE.type, + label: VAR_ITEM_TEMPLATE_IN_PIPELINE.label, + variable: VAR_ITEM_TEMPLATE_IN_PIPELINE.variable, + maxLength: undefined, + required: VAR_ITEM_TEMPLATE_IN_PIPELINE.required, + options: VAR_ITEM_TEMPLATE_IN_PIPELINE.options, + allowedTypesAndExtensions: {}, + }) + }) + + it('should convert form data back into pipeline input variables', () => { + const result = convertFormDataToINputField({ + type: PipelineInputVarType.select, + label: 'Category', + variable: 'category', + maxLength: 10, + default: 'books', + required: true, + tooltips: 'Pick one', + options: ['books', 'music'], + placeholder: 'Choose', + unit: '', + allowedFileUploadMethods: [TransferMethod.local_file], + allowedTypesAndExtensions: { + allowedFileTypes: [SupportUploadFileTypes.document], + allowedFileExtensions: ['txt'], + }, + }) + + expect(result).toEqual({ + type: PipelineInputVarType.select, + label: 'Category', + variable: 'category', + max_length: 10, + default_value: 'books', + required: true, + tooltips: 'Pick one', + options: ['books', 'music'], + placeholder: 'Choose', + unit: '', + allowed_file_upload_methods: [TransferMethod.local_file], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['txt'], + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx new file mode 100644 index 0000000000..0a5b748c7b --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx @@ -0,0 +1,73 @@ +import type { InputFieldFormProps } from '../types' +import { render, screen } from '@testing-library/react' +import { useAppForm } from '@/app/components/base/form' +import HiddenFields from '../hidden-fields' +import { useHiddenConfigurations } from '../hooks' + +const { mockInputField } = vi.hoisted(() => ({ + mockInputField: vi.fn(({ config }: { config: { variable: string } }) => { + return function FieldComponent() { + return
{config.variable}
+ } + }), +})) + +vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({ + default: mockInputField, +})) + +vi.mock('../hooks', () => ({ + useHiddenConfigurations: vi.fn(), +})) + +describe('HiddenFields', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build fields from the hidden configuration list', () => { + vi.mocked(useHiddenConfigurations).mockReturnValue([ + { variable: 'default' }, + { variable: 'tooltips' }, + ] as ReturnType) + + const HiddenFieldsHarness = () => { + const initialData: InputFieldFormProps['initialData'] = { + variable: 'field_1', + options: ['option-a', 'option-b'], + } + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const HiddenFieldsComp = HiddenFields({ initialData }) + return + } + render() + + expect(useHiddenConfigurations).toHaveBeenCalledWith({ + options: ['option-a', 'option-b'], + }) + expect(mockInputField).toHaveBeenCalledTimes(2) + expect(screen.getAllByTestId('input-field')).toHaveLength(2) + expect(screen.getByText('default')).toBeInTheDocument() + expect(screen.getByText('tooltips')).toBeInTheDocument() + }) + + it('should render nothing when there are no hidden configurations', () => { + vi.mocked(useHiddenConfigurations).mockReturnValue([]) + + const HiddenFieldsHarness = () => { + const initialData: InputFieldFormProps['initialData'] = { options: [] } + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const HiddenFieldsComp = HiddenFields({ initialData }) + return + } + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx new file mode 100644 index 0000000000..e6bf21ed74 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx @@ -0,0 +1,85 @@ +import type { ComponentType } from 'react' +import { render, screen } from '@testing-library/react' +import { useConfigurations } from '../hooks' +import InitialFields from '../initial-fields' + +type MockForm = { + store: object + getFieldValue: (fieldName: string) => unknown + setFieldValue: (fieldName: string, value: unknown) => void +} + +const { + mockForm, + mockInputField, +} = vi.hoisted(() => ({ + mockForm: { + store: {}, + getFieldValue: vi.fn(), + setFieldValue: vi.fn(), + } as MockForm, + mockInputField: vi.fn(({ config }: { config: { variable: string } }) => { + return function FieldComponent() { + return
{config.variable}
+ } + }), +})) + +vi.mock('@/app/components/base/form', () => ({ + withForm: ({ render }: { + render: (props: { form: MockForm }) => React.ReactNode + }) => ({ form }: { form?: MockForm }) => render({ form: form ?? mockForm }), +})) + +vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({ + default: mockInputField, +})) + +vi.mock('../hooks', () => ({ + useConfigurations: vi.fn(), +})) + +describe('InitialFields', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build initial fields with the form accessors and supportFile flag', () => { + vi.mocked(useConfigurations).mockReturnValue([ + { variable: 'type' }, + { variable: 'label' }, + ] as ReturnType) + + const InitialFieldsComp = InitialFields({ + initialData: { variable: 'field_1' }, + supportFile: true, + }) as unknown as ComponentType + render() + + expect(useConfigurations).toHaveBeenCalledWith(expect.objectContaining({ + supportFile: true, + getFieldValue: expect.any(Function), + setFieldValue: expect.any(Function), + })) + expect(screen.getAllByTestId('input-field')).toHaveLength(2) + expect(screen.getByText('type')).toBeInTheDocument() + expect(screen.getByText('label')).toBeInTheDocument() + }) + + it('should delegate field accessors to the underlying form instance', () => { + vi.mocked(useConfigurations).mockReturnValue([] as ReturnType) + mockForm.getFieldValue = vi.fn(() => 'label-value') + mockForm.setFieldValue = vi.fn() + + const InitialFieldsComp = InitialFields({ supportFile: false }) as unknown as ComponentType + render() + + const call = vi.mocked(useConfigurations).mock.calls[0]?.[0] + const value = call?.getFieldValue('label') + call?.setFieldValue('label', 'next-value') + + expect(value).toBe('label-value') + expect(mockForm.getFieldValue).toHaveBeenCalledWith('label') + expect(mockForm.setFieldValue).toHaveBeenCalledWith('label', 'next-value') + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx new file mode 100644 index 0000000000..9dd943f969 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx @@ -0,0 +1,62 @@ +import type { InputFieldFormProps } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { useAppForm } from '@/app/components/base/form' +import { PipelineInputVarType } from '@/models/pipeline' +import { useHiddenFieldNames } from '../hooks' +import ShowAllSettings from '../show-all-settings' + +vi.mock('../hooks', () => ({ + useHiddenFieldNames: vi.fn(), +})) + +describe('ShowAllSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useHiddenFieldNames).mockReturnValue('default value, placeholder') + }) + + it('should render the summary and hidden field names', () => { + const ShowAllSettingsHarness = () => { + const initialData: InputFieldFormProps['initialData'] = { + type: PipelineInputVarType.textInput, + } + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const ShowAllSettingsComp = ShowAllSettings({ + initialData, + handleShowAllSettings: vi.fn(), + }) + return + } + render() + + expect(useHiddenFieldNames).toHaveBeenCalledWith(PipelineInputVarType.textInput) + expect(screen.getByText('appDebug.variableConfig.showAllSettings')).toBeInTheDocument() + expect(screen.getByText('default value, placeholder')).toBeInTheDocument() + }) + + it('should call the click handler when the row is pressed', () => { + const handleShowAllSettings = vi.fn() + const ShowAllSettingsHarness = () => { + const initialData: InputFieldFormProps['initialData'] = { + type: PipelineInputVarType.textInput, + } + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const ShowAllSettingsComp = ShowAllSettings({ + initialData, + handleShowAllSettings, + }) + return + } + render() + + fireEvent.click(screen.getByText('appDebug.variableConfig.showAllSettings')) + + expect(handleShowAllSettings).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx new file mode 100644 index 0000000000..4a738761d0 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx @@ -0,0 +1,83 @@ +import type { InputVar } from '@/models/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { PipelineInputVarType } from '@/models/pipeline' +import FieldItem from '../field-item' + +const createInputVar = (overrides: Partial = {}): InputVar => ({ + type: PipelineInputVarType.textInput, + label: 'Field Label', + variable: 'field_name', + max_length: 48, + default_value: '', + required: true, + tooltips: '', + options: [], + placeholder: '', + unit: '', + allowed_file_upload_methods: [], + allowed_file_types: [], + allowed_file_extensions: [], + ...overrides, +}) + +describe('FieldItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the variable, label, and required badge', () => { + render( + , + ) + + expect(screen.getByText('field_name')).toBeInTheDocument() + expect(screen.getByText('Field Label')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.start.required')).toBeInTheDocument() + }) + + it('should show edit and delete controls on hover and trigger both callbacks', () => { + const onClickEdit = vi.fn() + const onRemove = vi.fn() + const { container } = render( + , + ) + + fireEvent.mouseEnter(container.firstChild!) + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + fireEvent.click(buttons[1]) + + expect(onClickEdit).toHaveBeenCalledWith('custom_field') + expect(onRemove).toHaveBeenCalledWith(2) + }) + + it('should keep the row readonly when readonly is enabled', () => { + const onClickEdit = vi.fn() + const onRemove = vi.fn() + const { container } = render( + , + ) + + fireEvent.mouseEnter(container.firstChild!) + + expect(screen.queryAllByRole('button')).toHaveLength(0) + expect(onClickEdit).not.toHaveBeenCalled() + expect(onRemove).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx new file mode 100644 index 0000000000..5e49a4c9b4 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx @@ -0,0 +1,60 @@ +import type { InputVar } from '@/models/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { PipelineInputVarType } from '@/models/pipeline' +import FieldListContainer from '../field-list-container' + +const createInputVar = (variable: string): InputVar => ({ + type: PipelineInputVarType.textInput, + label: variable, + variable, + max_length: 48, + default_value: '', + required: true, + tooltips: '', + options: [], + placeholder: '', + unit: '', + allowed_file_upload_methods: [], + allowed_file_types: [], + allowed_file_extensions: [], +}) + +describe('FieldListContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the field items inside the sortable container', () => { + const onListSortChange = vi.fn() + const { container } = render( + , + ) + + expect(screen.getAllByText('field_1').length).toBeGreaterThan(0) + expect(screen.getAllByText('field_2').length).toBeGreaterThan(0) + expect(container.querySelector('.handle')).toBeInTheDocument() + expect(onListSortChange).not.toHaveBeenCalled() + }) + + it('should honor readonly mode for the rendered field rows', () => { + const { container } = render( + , + ) + + const firstRow = container.querySelector('.handle') + fireEvent.mouseEnter(firstRow!) + + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx new file mode 100644 index 0000000000..b0ab5d5312 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx @@ -0,0 +1,24 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { render, screen } from '@testing-library/react' +import Datasource from '../datasource' + +vi.mock('@/app/components/workflow/hooks', () => ({ + useToolIcon: () => 'tool-icon', +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: ({ toolIcon }: { toolIcon: string }) =>
{toolIcon}
, +})) + +describe('Datasource', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the datasource title and icon', () => { + render() + + expect(screen.getByTestId('block-icon')).toHaveTextContent('tool-icon') + expect(screen.getByText('Knowledge Base')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx new file mode 100644 index 0000000000..602a8a4708 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import GlobalInputs from '../global-inputs' + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + popupContent, + }: { + popupContent: React.ReactNode + }) =>
{popupContent}
, +})) + +describe('GlobalInputs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the title and tooltip copy', () => { + render() + + expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument() + expect(screen.getByTestId('tooltip')).toHaveTextContent('datasetPipeline.inputFieldPanel.globalInputs.tooltip') + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx new file mode 100644 index 0000000000..04701aeba4 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx @@ -0,0 +1,73 @@ +import type { Datasource } from '../../../test-run/types' +import { fireEvent, render, screen } from '@testing-library/react' +import DataSource from '../data-source' + +const { + mockOnSelect, + mockUseDraftPipelinePreProcessingParams, +} = vi.hoisted(() => ({ + mockOnSelect: vi.fn(), + mockUseDraftPipelinePreProcessingParams: vi.fn(() => ({ + data: { + variables: [{ variable: 'source' }], + }, + })), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useDraftPipelinePreProcessingParams: mockUseDraftPipelinePreProcessingParams, +})) + +vi.mock('../../../test-run/preparation/data-source-options', () => ({ + default: ({ + onSelect, + dataSourceNodeId, + }: { + onSelect: (data: Datasource) => void + dataSourceNodeId: string + }) => ( +
+ +
+ ), +})) + +vi.mock('../form', () => ({ + default: ({ variables }: { variables: Array<{ variable: string }> }) => ( +
{variables.map(item => item.variable).join(',')}
+ ), +})) + +describe('DataSource preview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the datasource selection step and forward selected values', () => { + render( + , + ) + + fireEvent.click(screen.getByText('select datasource')) + + expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle')).toBeInTheDocument() + expect(screen.getByTestId('data-source-options')).toHaveAttribute('data-node-id', 'node-1') + expect(screen.getByTestId('preview-form')).toHaveTextContent('source') + expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-1', + node_id: 'node-1', + }, true) + expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'source-node' }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx new file mode 100644 index 0000000000..66299e112f --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx @@ -0,0 +1,64 @@ +import type { RAGPipelineVariables } from '@/models/pipeline' +import { render, screen } from '@testing-library/react' +import Form from '../form' + +type MockForm = { + id: string +} + +const { + mockForm, + mockBaseField, + mockUseInitialData, + mockUseConfigurations, +} = vi.hoisted(() => ({ + mockForm: { + id: 'form-1', + } as MockForm, + mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => { + return function FieldComponent() { + return
{config.variable}
+ } + }), + mockUseInitialData: vi.fn(() => ({ source: 'node-1' })), + mockUseConfigurations: vi.fn(() => [{ variable: 'source' }, { variable: 'chunkSize' }]), +})) + +vi.mock('@/app/components/base/form', () => ({ + useAppForm: () => mockForm, +})) + +vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ + default: mockBaseField, +})) + +vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ + useInitialData: mockUseInitialData, + useConfigurations: mockUseConfigurations, +})) + +describe('Preview form', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build fields from the pipeline variable configuration', () => { + render() + + expect(mockUseInitialData).toHaveBeenCalled() + expect(mockUseConfigurations).toHaveBeenCalled() + expect(screen.getAllByTestId('base-field')).toHaveLength(2) + expect(screen.getByText('source')).toBeInTheDocument() + expect(screen.getByText('chunkSize')).toBeInTheDocument() + }) + + it('should prevent the native form submission', () => { + const { container } = render() + const form = container.querySelector('form')! + const submitEvent = new Event('submit', { bubbles: true, cancelable: true }) + + form.dispatchEvent(submitEvent) + + expect(submitEvent.defaultPrevented).toBe(true) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx new file mode 100644 index 0000000000..3e4944d775 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react' +import ProcessDocuments from '../process-documents' + +const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({ + data: { + variables: [{ variable: 'chunkSize' }], + }, +}))) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams, +})) + +vi.mock('../form', () => ({ + default: ({ variables }: { variables: Array<{ variable: string }> }) => ( +
{variables.map(item => item.variable).join(',')}
+ ), +})) + +describe('ProcessDocuments preview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the processing step and its variables', () => { + render() + + expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle')).toBeInTheDocument() + expect(screen.getByTestId('preview-form')).toHaveTextContent('chunkSize') + expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-1', + node_id: 'node-2', + }, true) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx new file mode 100644 index 0000000000..8149bac144 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Header from '../header' + +const { + mockSetIsPreparingDataSource, + mockHandleCancelDebugAndPreviewPanel, + mockWorkflowStore, +} = vi.hoisted(() => ({ + mockSetIsPreparingDataSource: vi.fn(), + mockHandleCancelDebugAndPreviewPanel: vi.fn(), + mockWorkflowStore: { + getState: vi.fn(() => ({ + isPreparingDataSource: true, + setIsPreparingDataSource: vi.fn(), + })), + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => mockWorkflowStore, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useWorkflowInteractions: () => ({ + handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, + }), +})) + +describe('TestRun header', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWorkflowStore.getState.mockReturnValue({ + isPreparingDataSource: true, + setIsPreparingDataSource: mockSetIsPreparingDataSource, + }) + }) + + it('should render the title and reset preparing state on close', () => { + render(
) + + fireEvent.click(screen.getByRole('button')) + + expect(screen.getByText('datasetPipeline.testRun.title')).toBeInTheDocument() + expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false) + expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1) + }) + + it('should only cancel the panel when the datasource preparation flag is false', () => { + mockWorkflowStore.getState.mockReturnValue({ + isPreparingDataSource: false, + setIsPreparingDataSource: mockSetIsPreparingDataSource, + }) + + render(
) + fireEvent.click(screen.getByRole('button')) + + expect(mockSetIsPreparingDataSource).not.toHaveBeenCalled() + expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx new file mode 100644 index 0000000000..b4eab3fe72 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react' +import FooterTips from '../footer-tips' + +describe('FooterTips', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the localized footer copy', () => { + render() + + expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx new file mode 100644 index 0000000000..d5985f2969 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import StepIndicator from '../step-indicator' + +describe('StepIndicator', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render all step labels and highlight the current step', () => { + const { container } = render( + , + ) + + expect(screen.getByText('Select source')).toBeInTheDocument() + expect(screen.getByText('Process docs')).toBeInTheDocument() + expect(screen.getByText('Run test')).toBeInTheDocument() + expect(container.querySelector('.bg-state-accent-solid')).toBeInTheDocument() + expect(screen.getByText('Process docs').parentElement).toHaveClass('text-state-accent-solid') + }) + + it('should keep inactive steps in the tertiary state', () => { + render( + , + ) + + expect(screen.getByText('Process docs').parentElement).toHaveClass('text-text-tertiary') + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx new file mode 100644 index 0000000000..83cb252943 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx @@ -0,0 +1,49 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { fireEvent, render, screen } from '@testing-library/react' +import OptionCard from '../option-card' + +vi.mock('@/app/components/workflow/hooks', () => ({ + useToolIcon: () => 'source-icon', +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: ({ toolIcon }: { toolIcon: string }) =>
{toolIcon}
, +})) + +describe('OptionCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the datasource label and icon', () => { + render( + , + ) + + expect(screen.getByTestId('block-icon')).toHaveTextContent('source-icon') + expect(screen.getByText('Website Crawl')).toBeInTheDocument() + }) + + it('should call onClick with the card value and apply selected styles', () => { + const onClick = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('Online Drive')) + + expect(onClick).toHaveBeenCalledWith('online-drive') + expect(screen.getByText('Online Drive')).toHaveClass('text-text-primary') + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx new file mode 100644 index 0000000000..69f576eae7 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx @@ -0,0 +1,67 @@ +import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions' +import { fireEvent, render, screen } from '@testing-library/react' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import Actions from '../actions' + +let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus } } | undefined + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { workflowRunningData: typeof mockWorkflowRunningData }) => unknown) => selector({ + workflowRunningData: mockWorkflowRunningData, + }), +})) + +const createFormParams = (overrides: Partial = {}): CustomActionsProps => ({ + form: { + handleSubmit: vi.fn(), + } as unknown as CustomActionsProps['form'], + isSubmitting: false, + canSubmit: true, + ...overrides, +}) + +describe('Document processing actions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWorkflowRunningData = undefined + }) + + it('should render back/process actions and trigger both callbacks', () => { + const onBack = vi.fn() + const formParams = createFormParams() + + render() + + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.backToDataSource' })) + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })) + + expect(onBack).toHaveBeenCalledTimes(1) + expect(formParams.form.handleSubmit).toHaveBeenCalledTimes(1) + }) + + it('should disable processing when runDisabled or the workflow is already running', () => { + const { rerender } = render( + , + ) + + expect(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })).toBeDisabled() + + mockWorkflowRunningData = { + result: { + status: WorkflowRunningStatus.Running, + }, + } + rerender( + , + ) + + expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i })).toBeDisabled() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..822d553732 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts @@ -0,0 +1,32 @@ +import { renderHook } from '@testing-library/react' +import { useInputVariables } from '../hooks' + +const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({ + data: { variables: [{ variable: 'chunkSize' }] }, + isFetching: true, +}))) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams, +})) + +describe('useInputVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should query processing params with the current pipeline id and datasource node id', () => { + const { result } = renderHook(() => useInputVariables('datasource-node')) + + expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-1', + node_id: 'datasource-node', + }) + expect(result.current.isFetchingParams).toBe(true) + expect(result.current.paramsConfig).toEqual({ variables: [{ variable: 'chunkSize' }] }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx new file mode 100644 index 0000000000..fcfa305bb3 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx @@ -0,0 +1,140 @@ +import type { ZodSchema } from 'zod' +import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions' +import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Options from '../options' + +const { + mockFormValue, + mockHandleSubmit, + mockToastError, + mockBaseField, +} = vi.hoisted(() => ({ + mockFormValue: { chunkSize: 256 } as Record, + mockHandleSubmit: vi.fn(), + mockToastError: vi.fn(), + mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => { + return function FieldComponent() { + return
{config.variable}
+ } + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: mockToastError, + }, +})) + +vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ + default: mockBaseField, +})) + +vi.mock('@/app/components/base/form', () => ({ + useAppForm: ({ + onSubmit, + validators, + }: { + onSubmit: (params: { value: Record }) => void + validators?: { + onSubmit?: (params: { value: Record }) => string | undefined + } + }) => ({ + handleSubmit: () => { + const validationResult = validators?.onSubmit?.({ value: mockFormValue }) + if (!validationResult) + onSubmit({ value: mockFormValue }) + mockHandleSubmit() + }, + AppForm: ({ children }: { children: React.ReactNode }) =>
{children}
, + Actions: ({ CustomActions }: { CustomActions: (props: CustomActionsProps) => React.ReactNode }) => ( +
+ {CustomActions({ + form: { + handleSubmit: mockHandleSubmit, + } as unknown as CustomActionsProps['form'], + isSubmitting: false, + canSubmit: true, + })} +
+ ), + }), +})) + +const createSchema = (success: boolean): ZodSchema => ({ + safeParse: vi.fn(() => { + if (success) + return { success: true } + + return { + success: false, + error: { + issues: [{ + path: ['chunkSize'], + message: 'Invalid value', + }], + }, + } + }), +}) as unknown as ZodSchema + +describe('Document processing options', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render base fields and the custom actions slot', () => { + render( +
custom actions
} + onSubmit={vi.fn()} + />, + ) + + expect(screen.getByTestId('base-field')).toHaveTextContent('chunkSize') + expect(screen.getByTestId('form-actions')).toBeInTheDocument() + expect(screen.getByTestId('custom-actions')).toBeInTheDocument() + }) + + it('should validate and toast the first schema error before submitting', async () => { + const onSubmit = vi.fn() + const { container } = render( +
actions
} + onSubmit={onSubmit} + />, + ) + + fireEvent.submit(container.querySelector('form')!) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('Path: chunkSize Error: Invalid value') + }) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should submit the parsed form value when validation succeeds', async () => { + const onSubmit = vi.fn() + const { container } = render( +
actions
} + onSubmit={onSubmit} + />, + ) + + fireEvent.submit(container.querySelector('form')!) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(mockFormValue) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts new file mode 100644 index 0000000000..376b529d40 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts @@ -0,0 +1,84 @@ +import { ChunkingMode } from '@/models/datasets' +import { formatPreviewChunks } from '../utils' + +vi.mock('@/config', () => ({ + RAG_PIPELINE_PREVIEW_CHUNK_NUM: 2, +})) + +describe('result preview utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return undefined for empty outputs', () => { + expect(formatPreviewChunks(undefined)).toBeUndefined() + expect(formatPreviewChunks(null)).toBeUndefined() + }) + + it('should format text chunks and limit them to the preview length', () => { + const result = formatPreviewChunks({ + chunk_structure: ChunkingMode.text, + preview: [ + { content: 'Chunk 1', summary: 'S1' }, + { content: 'Chunk 2', summary: 'S2' }, + { content: 'Chunk 3', summary: 'S3' }, + ], + }) + + expect(result).toEqual([ + { content: 'Chunk 1', summary: 'S1' }, + { content: 'Chunk 2', summary: 'S2' }, + ]) + }) + + it('should format paragraph and full-doc parent-child previews differently', () => { + const paragraph = formatPreviewChunks({ + chunk_structure: ChunkingMode.parentChild, + parent_mode: 'paragraph', + preview: [ + { content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] }, + { content: 'Parent 2', child_chunks: ['c4'] }, + { content: 'Parent 3', child_chunks: ['c5'] }, + ], + }) + const fullDoc = formatPreviewChunks({ + chunk_structure: ChunkingMode.parentChild, + parent_mode: 'full-doc', + preview: [ + { content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] }, + ], + }) + + expect(paragraph).toEqual({ + parent_mode: 'paragraph', + parent_child_chunks: [ + { parent_content: 'Parent 1', parent_summary: undefined, child_contents: ['c1', 'c2', 'c3'], parent_mode: 'paragraph' }, + { parent_content: 'Parent 2', parent_summary: undefined, child_contents: ['c4'], parent_mode: 'paragraph' }, + ], + }) + expect(fullDoc).toEqual({ + parent_mode: 'full-doc', + parent_child_chunks: [ + { parent_content: 'Parent 1', child_contents: ['c1', 'c2'], parent_mode: 'full-doc' }, + ], + }) + }) + + it('should format qa previews and limit them to the preview size', () => { + const result = formatPreviewChunks({ + chunk_structure: ChunkingMode.qa, + qa_preview: [ + { question: 'Q1', answer: 'A1' }, + { question: 'Q2', answer: 'A2' }, + { question: 'Q3', answer: 'A3' }, + ], + }) + + expect(result).toEqual({ + qa_chunks: [ + { question: 'Q1', answer: 'A1' }, + { question: 'Q2', answer: 'A2' }, + ], + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx new file mode 100644 index 0000000000..0597bc3de8 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx @@ -0,0 +1,64 @@ +import type { WorkflowRunningData } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import Tab from '../tab' + +const createWorkflowRunningData = (): WorkflowRunningData => ({ + task_id: 'task-1', + message_id: 'message-1', + conversation_id: 'conversation-1', + result: { + workflow_id: 'workflow-1', + inputs: '{}', + inputs_truncated: false, + process_data: '{}', + process_data_truncated: false, + outputs: '{}', + outputs_truncated: false, + status: 'succeeded', + elapsed_time: 10, + total_tokens: 20, + created_at: Date.now(), + finished_at: Date.now(), + steps: 1, + total_steps: 1, + }, + tracing: [], +}) + +describe('Tab', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render an active tab and pass its value on click', () => { + const onClick = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Preview' })) + + expect(screen.getByRole('button')).toHaveClass('border-util-colors-blue-brand-blue-brand-600') + expect(onClick).toHaveBeenCalledWith('preview') + }) + + it('should disable the tab when workflow run data is unavailable', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Trace' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Trace' })).toHaveClass('opacity-30') + }) +}) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx new file mode 100644 index 0000000000..493f3c3014 --- /dev/null +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import InputFieldButton from '../input-field-button' + +const { + mockSetShowInputFieldPanel, + mockSetShowEnvPanel, +} = vi.hoisted(() => ({ + mockSetShowInputFieldPanel: vi.fn(), + mockSetShowEnvPanel: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + setShowInputFieldPanel: typeof mockSetShowInputFieldPanel + setShowEnvPanel: typeof mockSetShowEnvPanel + }) => unknown) => selector({ + setShowInputFieldPanel: mockSetShowInputFieldPanel, + setShowEnvPanel: mockSetShowEnvPanel, + }), +})) + +describe('InputFieldButton', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should open the input field panel and close the env panel', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.inputField' })) + + expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(true) + expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts b/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts new file mode 100644 index 0000000000..c90e702d8e --- /dev/null +++ b/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts @@ -0,0 +1,92 @@ +import type { Viewport } from 'reactflow' +import type { Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { processNodesWithoutDataSource } from '../nodes' + +vi.mock('@/app/components/workflow/constants', () => ({ + CUSTOM_NODE: 'custom', + NODE_WIDTH_X_OFFSET: 400, + START_INITIAL_POSITION: { x: 100, y: 100 }, +})) + +vi.mock('@/app/components/workflow/nodes/data-source-empty/constants', () => ({ + CUSTOM_DATA_SOURCE_EMPTY_NODE: 'data-source-empty', +})) + +vi.mock('@/app/components/workflow/note-node/constants', () => ({ + CUSTOM_NOTE_NODE: 'note', +})) + +vi.mock('@/app/components/workflow/note-node/types', () => ({ + NoteTheme: { blue: 'blue' }, +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + generateNewNode: ({ id, type, data, position }: { id: string, type: string, data: object, position: { x: number, y: number } }) => ({ + newNode: { id, type, data, position }, + }), +})) + +describe('processNodesWithoutDataSource', () => { + it('should return the original nodes when a datasource node already exists', () => { + const nodes = [ + { + id: 'node-1', + type: 'custom', + data: { type: BlockEnum.DataSource }, + position: { x: 100, y: 100 }, + }, + ] as Node[] + const viewport: Viewport = { x: 0, y: 0, zoom: 1 } + + const result = processNodesWithoutDataSource(nodes, viewport) + + expect(result.nodes).toBe(nodes) + expect(result.viewport).toBe(viewport) + }) + + it('should prepend datasource empty and note nodes when the pipeline starts without a datasource', () => { + const nodes = [ + { + id: 'node-1', + type: 'custom', + data: { type: BlockEnum.KnowledgeBase }, + position: { x: 300, y: 200 }, + }, + ] as Node[] + + const result = processNodesWithoutDataSource(nodes, { x: 0, y: 0, zoom: 2 }) + + expect(result.nodes[0]).toEqual(expect.objectContaining({ + id: 'data-source-empty', + type: 'data-source-empty', + position: { x: -100, y: 200 }, + })) + expect(result.nodes[1]).toEqual(expect.objectContaining({ + id: 'note', + type: 'note', + position: { x: -100, y: 300 }, + })) + expect(result.viewport).toEqual({ + x: 400, + y: -200, + zoom: 2, + }) + }) + + it('should leave nodes unchanged when there is no custom node to anchor from', () => { + const nodes = [ + { + id: 'node-1', + type: 'note', + data: { type: BlockEnum.Answer }, + position: { x: 100, y: 100 }, + }, + ] as Node[] + + const result = processNodesWithoutDataSource(nodes) + + expect(result.nodes).toBe(nodes) + expect(result.viewport).toBeUndefined() + }) +}) diff --git a/web/app/components/share/text-generation/__tests__/index.spec.tsx b/web/app/components/share/text-generation/__tests__/index.spec.tsx new file mode 100644 index 0000000000..e3746b1da1 --- /dev/null +++ b/web/app/components/share/text-generation/__tests__/index.spec.tsx @@ -0,0 +1,219 @@ +import type { TextGenerationRunControl } from '../types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { AccessMode } from '@/models/access-control' +import TextGeneration from '../index' + +const { + mockMode, + mockMedia, + mockAppStateRef, + mockBatchStateRef, + sidebarPropsSpy, + resultPanelPropsSpy, + mockSetIsCallBatchAPI, + mockResetBatchExecution, + mockHandleRunBatch, +} = vi.hoisted(() => ({ + mockMode: { value: 'create' }, + mockMedia: { value: 'pc' }, + mockAppStateRef: { value: null as unknown }, + mockBatchStateRef: { value: null as unknown }, + sidebarPropsSpy: vi.fn(), + resultPanelPropsSpy: vi.fn(), + mockSetIsCallBatchAPI: vi.fn(), + mockResetBatchExecution: vi.fn(), + mockHandleRunBatch: vi.fn(), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + MediaType: { + mobile: 'mobile', + pc: 'pc', + tablet: 'tablet', + }, + default: () => mockMedia.value, +})) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => key === 'mode' ? mockMode.value : null, + }), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: ({ type }: { type: string }) =>
{type}
, +})) + +vi.mock('../hooks/use-text-generation-app-state', () => ({ + useTextGenerationAppState: () => mockAppStateRef.value, +})) + +vi.mock('../hooks/use-text-generation-batch', () => ({ + useTextGenerationBatch: () => mockBatchStateRef.value, +})) + +vi.mock('../text-generation-sidebar', () => ({ + default: (props: { + currentTab: string + onRunOnceSend: () => void + onBatchSend: (data: string[][]) => void + }) => { + sidebarPropsSpy(props) + return ( +
+ {props.currentTab} + + +
+ ) + }, +})) + +vi.mock('../text-generation-result-panel', () => ({ + default: (props: { + allTaskList: unknown[] + controlSend: number + controlStopResponding: number + isShowResultPanel: boolean + onRunControlChange: (value: TextGenerationRunControl | null) => void + onRunStart: () => void + }) => { + resultPanelPropsSpy(props) + return ( +
+ {props.isShowResultPanel ? 'shown' : 'hidden'} + {String(props.controlSend)} + {String(props.controlStopResponding)} + {String(props.allTaskList.length)} + + +
+ ) + }, +})) + +const createAppState = (overrides: Record = {}) => ({ + accessMode: AccessMode.PUBLIC, + appId: 'app-1', + appSourceType: 'webApp', + customConfig: { + remove_webapp_brand: false, + replace_webapp_logo: '', + }, + handleRemoveSavedMessage: vi.fn(), + handleSaveMessage: vi.fn(), + moreLikeThisConfig: { enabled: true }, + promptConfig: { + prompt_template: '', + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], + }, + savedMessages: [], + siteInfo: { + title: 'Generator', + description: 'Description', + }, + systemFeatures: {}, + textToSpeechConfig: { enabled: true }, + visionConfig: { enabled: false }, + ...overrides, +}) + +const createBatchState = (overrides: Record = {}) => ({ + allFailedTaskList: [], + allSuccessTaskList: [], + allTaskList: [], + allTasksRun: true, + controlRetry: 0, + exportRes: [], + handleCompleted: vi.fn(), + handleRetryAllFailedTask: vi.fn(), + handleRunBatch: (data: string[][], options: { onStart: () => void }) => { + mockHandleRunBatch(data, options) + options.onStart() + return true + }, + isCallBatchAPI: false, + noPendingTask: true, + resetBatchExecution: () => mockResetBatchExecution(), + setIsCallBatchAPI: (value: boolean) => mockSetIsCallBatchAPI(value), + showTaskList: [], + ...overrides, +}) + +describe('TextGeneration', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockMode.value = 'create' + mockMedia.value = 'pc' + mockAppStateRef.value = createAppState() + mockBatchStateRef.value = createBatchState() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should render the loading state until app state is ready', () => { + mockAppStateRef.value = createAppState({ appId: '', siteInfo: null, promptConfig: null }) + + render() + + expect(screen.getByTestId('loading-app')).toHaveTextContent('app') + }) + + it('should fall back to create mode for unsupported query params and keep installed-app layout classes', () => { + mockMode.value = 'unsupported' + + const { container } = render() + + expect(screen.getByTestId('sidebar-current-tab')).toHaveTextContent('create') + expect(sidebarPropsSpy).toHaveBeenCalledWith(expect.objectContaining({ + currentTab: 'create', + isInstalledApp: true, + isPC: true, + })) + + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass('flex', 'h-full', 'rounded-2xl', 'shadow-md') + }) + + it('should orchestrate a run-once request and reveal the result panel', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'run-once' })) + + act(() => { + vi.runAllTimers() + }) + + expect(mockSetIsCallBatchAPI).toHaveBeenCalledWith(false) + expect(mockResetBatchExecution).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('show-result')).toHaveTextContent('shown') + expect(Number(screen.getByTestId('control-send').textContent)).toBeGreaterThan(0) + }) + + it('should orchestrate batch runs through the batch hook and expose the result panel', async () => { + mockMode.value = 'batch' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'run-batch' })) + + act(() => { + vi.runAllTimers() + }) + + expect(mockHandleRunBatch).toHaveBeenCalledWith( + [['name'], ['Alice']], + expect.objectContaining({ onStart: expect.any(Function) }), + ) + expect(screen.getByTestId('show-result')).toHaveTextContent('shown') + expect(Number(screen.getByTestId('control-stop').textContent)).toBeGreaterThan(0) + }) +}) diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts b/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts new file mode 100644 index 0000000000..6fe3576c26 --- /dev/null +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import examples from '../examples' + +describe('edit-custom-collection examples', () => { + it('provides json, yaml, and blank templates in fixed order', () => { + expect(examples.map(example => example.key)).toEqual([ + 'json', + 'yaml', + 'blankTemplate', + ]) + }) + + it('contains representative OpenAPI content for each template', () => { + expect(examples[0].content).toContain('"openapi": "3.1.0"') + expect(examples[1].content).toContain('openapi: "3.0.0"') + expect(examples[2].content).toContain('"title": "Untitled"') + }) +}) diff --git a/web/app/components/tools/labels/__tests__/constant.spec.ts b/web/app/components/tools/labels/__tests__/constant.spec.ts new file mode 100644 index 0000000000..614476fb8c --- /dev/null +++ b/web/app/components/tools/labels/__tests__/constant.spec.ts @@ -0,0 +1,33 @@ +import type { Label } from '../constant' +import { describe, expect, it } from 'vitest' + +describe('tool label type contract', () => { + it('accepts string labels', () => { + const label: Label = { + name: 'agent', + label: 'Agent', + icon: 'robot', + } + + expect(label).toEqual({ + name: 'agent', + label: 'Agent', + icon: 'robot', + }) + }) + + it('accepts i18n labels', () => { + const label: Label = { + name: 'workflow', + label: { + en_US: 'Workflow', + zh_Hans: '工作流', + }, + } + + expect(label.label).toEqual({ + en_US: 'Workflow', + zh_Hans: '工作流', + }) + }) +}) diff --git a/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts b/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts new file mode 100644 index 0000000000..acf8aafdf8 --- /dev/null +++ b/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts @@ -0,0 +1,102 @@ +import type { TFunction } from 'i18next' +import { describe, expect, it } from 'vitest' +import { VarType } from '@/app/components/workflow/types' +import { + buildWorkflowToolRequestPayload, + getReservedWorkflowOutputParameters, + getWorkflowOutputParameters, + hasReservedWorkflowOutputConflict, + isWorkflowToolNameValid, + RESERVED_WORKFLOW_OUTPUTS, +} from '../helpers' + +describe('workflow-tool helpers', () => { + it('validates workflow tool names', () => { + expect(isWorkflowToolNameValid('')).toBe(true) + expect(isWorkflowToolNameValid('workflow_tool_1')).toBe(true) + expect(isWorkflowToolNameValid('workflow-tool')).toBe(false) + expect(isWorkflowToolNameValid('workflow tool')).toBe(false) + }) + + it('builds translated reserved workflow outputs', () => { + const t = ((key: string, options?: { ns?: string }) => `${options?.ns}:${key}`) as TFunction + + expect(getReservedWorkflowOutputParameters(t)).toEqual([ + { + ...RESERVED_WORKFLOW_OUTPUTS[0], + description: 'workflow:nodes.tool.outputVars.text', + }, + { + ...RESERVED_WORKFLOW_OUTPUTS[1], + description: 'workflow:nodes.tool.outputVars.files.title', + }, + { + ...RESERVED_WORKFLOW_OUTPUTS[2], + description: 'workflow:nodes.tool.outputVars.json', + }, + ]) + }) + + it('detects reserved output conflicts', () => { + expect(hasReservedWorkflowOutputConflict(RESERVED_WORKFLOW_OUTPUTS, 'text')).toBe(true) + expect(hasReservedWorkflowOutputConflict(RESERVED_WORKFLOW_OUTPUTS, 'custom')).toBe(false) + }) + + it('derives workflow output parameters from schema through helper wrapper', () => { + expect(getWorkflowOutputParameters([], { + type: 'object', + properties: { + text: { + type: VarType.string, + description: 'Result text', + }, + }, + })).toEqual([ + { + name: 'text', + description: 'Result text', + type: VarType.string, + }, + ]) + }) + + it('builds workflow tool request payload', () => { + expect(buildWorkflowToolRequestPayload({ + name: 'workflow_tool', + description: 'Workflow tool', + emoji: { + content: '🧠', + background: '#ffffff', + }, + label: 'Workflow Tool', + labels: ['agent', 'workflow'], + parameters: [ + { + name: 'question', + type: VarType.string, + required: true, + form: 'llm', + description: 'Question to ask', + }, + ], + privacyPolicy: 'https://example.com/privacy', + })).toEqual({ + name: 'workflow_tool', + description: 'Workflow tool', + icon: { + content: '🧠', + background: '#ffffff', + }, + label: 'Workflow Tool', + labels: ['agent', 'workflow'], + parameters: [ + { + name: 'question', + description: 'Question to ask', + form: 'llm', + }, + ], + privacy_policy: 'https://example.com/privacy', + }) + }) +}) diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f3f229abea --- /dev/null +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -0,0 +1,200 @@ +import type { WorkflowToolModalPayload } from '../index' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WorkflowToolAsModal from '../index' + +vi.mock('@/app/components/base/drawer-plus', () => ({ + default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => ( + isShow + ? ( +
+ {title} + + {body} +
+ ) + : null + ), +})) + +vi.mock('@/app/components/base/emoji-picker', () => ({ + default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ onClick, icon }: { onClick?: () => void, icon: string }) => ( + + ), +})) + +vi.mock('@/app/components/tools/labels/selector', () => ({ + default: ({ value, onChange }: { value: string[], onChange: (labels: string[]) => void }) => ( +
+ {value.join(',')} + +
+ ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + children, + popupContent, + }: { + children?: React.ReactNode + popupContent?: React.ReactNode + }) => ( +
+ {children} + {popupContent} +
+ ), +})) + +vi.mock('../confirm-modal', () => ({ + default: ({ show, onClose, onConfirm }: { show: boolean, onClose: () => void, onConfirm: () => void }) => ( + show + ? ( +
+ + +
+ ) + : null + ), +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (message: string) => mockToastNotify({ type: 'success', message }), + error: (message: string) => mockToastNotify({ type: 'error', message }), + }, +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: [ + { name: 'label1', label: 'Label 1' }, + { name: 'label2', label: 'Label 2' }, + ], + }), +})) + +const createPayload = (overrides: Partial = {}): WorkflowToolModalPayload => ({ + icon: { content: '🔧', background: '#ffffff' }, + label: 'My Tool', + name: 'my_tool', + description: 'Tool description', + parameters: [ + { name: 'param1', description: 'Parameter 1', form: 'llm', required: true, type: 'string' }, + ], + outputParameters: [ + { name: 'output1', description: 'Output 1' }, + { name: 'text', description: 'Reserved output duplicate' }, + ], + labels: ['label1'], + privacy_policy: '', + workflow_app_id: 'workflow-app-1', + workflow_tool_id: 'workflow-tool-1', + ...overrides, +}) + +describe('WorkflowToolAsModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should create workflow tools with edited form values', async () => { + const user = userEvent.setup() + const onCreate = vi.fn() + + render( + , + ) + + await user.clear(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')) + await user.type(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), 'Created Tool') + await user.click(screen.getByTestId('append-label')) + await user.click(screen.getByTestId('app-icon')) + await user.click(screen.getByTestId('select-emoji')) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ + workflow_app_id: 'workflow-app-1', + label: 'Created Tool', + icon: { content: '🚀', background: '#000000' }, + labels: ['label1', 'new-label'], + })) + }) + + it('should block invalid tool-call names before saving', async () => { + const user = userEvent.setup() + const onCreate = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onCreate).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + it('should require confirmation before saving existing workflow tools', async () => { + const user = userEvent.setup() + const onSave = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + + await user.click(screen.getByTestId('confirm-save')) + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + workflow_tool_id: 'workflow-tool-1', + name: 'my_tool', + })) + }) + }) + + it('should show duplicate reserved output warnings', () => { + render( + , + ) + + expect(screen.getAllByText('tools.createTool.toolOutput.reservedParameterDuplicateTip').length).toBeGreaterThan(0) + }) +}) diff --git a/web/app/components/tools/workflow-tool/helpers.ts b/web/app/components/tools/workflow-tool/helpers.ts new file mode 100644 index 0000000000..9af1107c80 --- /dev/null +++ b/web/app/components/tools/workflow-tool/helpers.ts @@ -0,0 +1,95 @@ +import type { TFunction } from 'i18next' +import type { + Emoji, + WorkflowToolProviderOutputParameter, + WorkflowToolProviderOutputSchema, + WorkflowToolProviderParameter, + WorkflowToolProviderRequest, +} from '../types' +import { VarType } from '@/app/components/workflow/types' +import { buildWorkflowOutputParameters } from './utils' + +export const RESERVED_WORKFLOW_OUTPUTS: WorkflowToolProviderOutputParameter[] = [ + { + name: 'text', + description: '', + type: VarType.string, + reserved: true, + }, + { + name: 'files', + description: '', + type: VarType.arrayFile, + reserved: true, + }, + { + name: 'json', + description: '', + type: VarType.arrayObject, + reserved: true, + }, +] + +export const isWorkflowToolNameValid = (name: string) => { + if (name === '') + return true + + return /^\w+$/.test(name) +} + +export const getReservedWorkflowOutputParameters = (t: TFunction) => { + return RESERVED_WORKFLOW_OUTPUTS.map(output => ({ + ...output, + description: output.name === 'text' + ? t('nodes.tool.outputVars.text', { ns: 'workflow' }) + : output.name === 'files' + ? t('nodes.tool.outputVars.files.title', { ns: 'workflow' }) + : t('nodes.tool.outputVars.json', { ns: 'workflow' }), + })) +} + +export const hasReservedWorkflowOutputConflict = ( + reservedOutputParameters: WorkflowToolProviderOutputParameter[], + name: string, +) => { + return reservedOutputParameters.some(parameter => parameter.name === name) +} + +export const getWorkflowOutputParameters = ( + rawOutputParameters: WorkflowToolProviderOutputParameter[], + outputSchema?: WorkflowToolProviderOutputSchema, +) => { + return buildWorkflowOutputParameters(rawOutputParameters, outputSchema) +} + +export const buildWorkflowToolRequestPayload = ({ + description, + emoji, + label, + labels, + name, + parameters, + privacyPolicy, +}: { + description: string + emoji: Emoji + label: string + labels: string[] + name: string + parameters: WorkflowToolProviderParameter[] + privacyPolicy: string +}): WorkflowToolProviderRequest & { label: string } => { + return { + name, + description, + icon: emoji, + label, + parameters: parameters.map(item => ({ + name: item.name, + description: item.description, + form: item.form, + })), + labels, + privacy_policy: privacyPolicy, + } +} diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index 23329f6a2c..219a0d8f53 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -17,9 +17,14 @@ import { toast } from '@/app/components/base/ui/toast' import LabelSelector from '@/app/components/tools/labels/selector' import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal' import MethodSelector from '@/app/components/tools/workflow-tool/method-selector' -import { VarType } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' -import { buildWorkflowOutputParameters } from './utils' +import { + buildWorkflowToolRequestPayload, + getReservedWorkflowOutputParameters, + getWorkflowOutputParameters, + hasReservedWorkflowOutputConflict, + isWorkflowToolNameValid, +} from './helpers' export type WorkflowToolModalPayload = { icon: Emoji @@ -67,27 +72,14 @@ const WorkflowToolAsModal: FC = ({ const [parameters, setParameters] = useState(payload.parameters) const rawOutputParameters = payload.outputParameters const outputSchema = payload.tool?.output_schema - const outputParameters = useMemo(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema]) - const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [ - { - name: 'text', - description: t('nodes.tool.outputVars.text', { ns: 'workflow' }), - type: VarType.string, - reserved: true, - }, - { - name: 'files', - description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }), - type: VarType.arrayFile, - reserved: true, - }, - { - name: 'json', - description: t('nodes.tool.outputVars.json', { ns: 'workflow' }), - type: VarType.arrayObject, - reserved: true, - }, - ] + const outputParameters = useMemo( + () => getWorkflowOutputParameters(rawOutputParameters, outputSchema), + [rawOutputParameters, outputSchema], + ) + const reservedOutputParameters = useMemo( + () => getReservedWorkflowOutputParameters(t), + [t], + ) const handleParameterChange = (key: string, value: string, index: number) => { const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => { @@ -105,18 +97,6 @@ const WorkflowToolAsModal: FC = ({ const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy) const [showModal, setShowModal] = useState(false) - const isNameValid = (name: string) => { - // when the user has not input anything, no need for a warning - if (name === '') - return true - - return /^\w+$/.test(name) - } - - const isOutputParameterReserved = (name: string) => { - return reservedOutputParameters.find(p => p.name === name) - } - const onConfirm = () => { let errorMessage = '' if (!label) @@ -125,7 +105,7 @@ const WorkflowToolAsModal: FC = ({ if (!name) errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) }) - if (!isNameValid(name)) + if (!isWorkflowToolNameValid(name)) errorMessage = t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' }) if (errorMessage) { @@ -133,19 +113,15 @@ const WorkflowToolAsModal: FC = ({ return } - const requestParams = { + const requestParams = buildWorkflowToolRequestPayload({ name, description, - icon: emoji, + emoji, label, - parameters: parameters.map(item => ({ - name: item.name, - description: item.description, - form: item.form, - })), + parameters, labels, - privacy_policy: privacyPolicy, - } + privacyPolicy, + }) if (!isAdd) { onSave?.({ ...requestParams, @@ -175,7 +151,7 @@ const WorkflowToolAsModal: FC = ({
{/* name & icon */}
-
+
{t('createTool.name', { ns: 'tools' })} {' '} * @@ -192,7 +168,7 @@ const WorkflowToolAsModal: FC = ({
{/* name for tool call */}
-
+
{t('createTool.nameForToolCall', { ns: 'tools' })} {' '} * @@ -210,13 +186,13 @@ const WorkflowToolAsModal: FC = ({ value={name} onChange={e => setName(e.target.value)} /> - {!isNameValid(name) && ( + {!isWorkflowToolNameValid(name) && (
{t('createTool.nameForToolCallTip', { ns: 'tools' })}
)}
{/* description */}
-
{t('createTool.description', { ns: 'tools' })}
+
{t('createTool.description', { ns: 'tools' })}