mirror of
https://github.com/langgenius/dify.git
synced 2026-06-08 09:27:39 +08:00
Compare commits
439 Commits
dependabot
...
deploy/dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 958e0709db | |||
| 2bfd051d0d | |||
| a1d9340a62 | |||
| 3addc1e386 | |||
| 24876bb05d | |||
| 0cdd478f25 | |||
| 0db9714eb6 | |||
| 9da4d167fa | |||
| a1ad4be61e | |||
| 8cb2cffbf7 | |||
| a66ffa66dd | |||
| 59f287a491 | |||
| d2416e0106 | |||
| a348304ad0 | |||
| 5c17a01e21 | |||
| a244cbcbdf | |||
| 8056f56fc0 | |||
| 0cedbafe46 | |||
| b7e7351ffa | |||
| 446dfae257 | |||
| 45c1b4b692 | |||
| b15f15fa06 | |||
| ec4dd8a2f3 | |||
| 710ffea9fc | |||
| 5e5871c16b | |||
| 226c232e80 | |||
| ac18ba4a04 | |||
| caa727d312 | |||
| 409ac5b0ed | |||
| 374a418b0d | |||
| 81c81de04a | |||
| 89bb58204a | |||
| f39f71a6a6 | |||
| 7dc57d0973 | |||
| bb3f11c465 | |||
| 1392945f69 | |||
| abc02921c8 | |||
| ad5951e91b | |||
| 10a14eb2fa | |||
| eead4f9260 | |||
| 9a42969577 | |||
| f0b238c180 | |||
| e53969a73a | |||
| 9d0f1819ec | |||
| eff1586e69 | |||
| ea9063284e | |||
| 17a79788e3 | |||
| 6cbc82fe4e | |||
| b5797ce5ba | |||
| 7afc71e149 | |||
| d4b2bd9aac | |||
| bc226fc54b | |||
| 8c4e6b962f | |||
| e24d5b5c4f | |||
| e71fe27fe0 | |||
| 31711c7bd0 | |||
| 9d6ba8ea69 | |||
| 48cce619a2 | |||
| baff73e2e1 | |||
| c05d6b1ebb | |||
| cb6fbbc5df | |||
| ab0eedd78c | |||
| b0d623dde9 | |||
| 3ac832e3e7 | |||
| c594ccd238 | |||
| 529686ae6f | |||
| bc8b069f90 | |||
| 6a34cc1491 | |||
| d0df0172bc | |||
| 5ca88518b8 | |||
| 3e32c4d3ca | |||
| 7f14c02243 | |||
| 5636077c1a | |||
| 43cd83284a | |||
| 502591e838 | |||
| bccc948031 | |||
| 3c1bc6ac36 | |||
| 00cf21a1b7 | |||
| 705d317f57 | |||
| e0158fe9fd | |||
| 48e6902f5f | |||
| efe98b1e52 | |||
| a62c616664 | |||
| 307611d16e | |||
| 41ba73835d | |||
| e7afd1e596 | |||
| ed442771f4 | |||
| 5730ede96f | |||
| 107bba0116 | |||
| c46a313d78 | |||
| e1fec86a2a | |||
| 6ec893cb0e | |||
| 8be34ee000 | |||
| 458f669883 | |||
| 94fd4e9c67 | |||
| f5da3ce499 | |||
| 08f2971f72 | |||
| 54ac42fbc4 | |||
| 1d1d571213 | |||
| 056caa8b2f | |||
| 1cf6cdb764 | |||
| ac9083fbf1 | |||
| fdfc9ab3d3 | |||
| 83cd1a8d7a | |||
| a3dfd670b0 | |||
| facace019b | |||
| fd9543868d | |||
| 0e6cb87f08 | |||
| ef9c607f04 | |||
| 9d082489c9 | |||
| c550d6b085 | |||
| db5d5bfffe | |||
| 0f2cbc2968 | |||
| 892387ea38 | |||
| 2f351641e4 | |||
| 36a51dca8b | |||
| 93d9423c95 | |||
| 3cabe9058b | |||
| d925ed2f28 | |||
| d859728dd7 | |||
| 89188256e1 | |||
| bba3a1bcee | |||
| d66bfc7434 | |||
| 7c0be7f905 | |||
| 599e3475f2 | |||
| 718fe548e9 | |||
| 060ceaffd1 | |||
| 00908ca0fb | |||
| 2812d61e24 | |||
| 8adcac87a5 | |||
| 544d8567c9 | |||
| be1d6520f9 | |||
| eeb1cd19bd | |||
| 7fb2e4751f | |||
| e4620b4b22 | |||
| 8af1766081 | |||
| 5441992604 | |||
| 9d0597c22d | |||
| 5d489ab92d | |||
| 930da499d1 | |||
| f1527ef7c1 | |||
| 20f89b6e90 | |||
| 05e69b104a | |||
| f39b1b6731 | |||
| a7005efab3 | |||
| f605288429 | |||
| 2bb3b439e0 | |||
| 75daf8e61b | |||
| bf30b11d0d | |||
| 778e472173 | |||
| b91de7e54b | |||
| 2885ba8519 | |||
| e23c3d1491 | |||
| 888292564b | |||
| 1a0c8f6173 | |||
| d8851a4994 | |||
| 8a21679ea8 | |||
| b8a594def0 | |||
| 69a77ad9ce | |||
| 93728bb39f | |||
| c4da7a0bed | |||
| 05fd412670 | |||
| a4821288cc | |||
| fc0a4a6b56 | |||
| 0a3bb67778 | |||
| 5e9f419154 | |||
| 6b84383590 | |||
| d7f99d6458 | |||
| 6c80ee8f48 | |||
| ea71990388 | |||
| 36e8677b1a | |||
| 5c31a774ea | |||
| 9e137e12ab | |||
| 18e2ecd6c5 | |||
| 8a23126f29 | |||
| 6c5f6699d2 | |||
| 124b786dfb | |||
| dd54ca0cab | |||
| 8a72e46ce8 | |||
| f00f8e020f | |||
| aa078a854c | |||
| 712aae4d98 | |||
| bacadc4d35 | |||
| b060e81824 | |||
| b45f83492e | |||
| d1e1a4a8ab | |||
| 4519847e81 | |||
| 3763efbc7c | |||
| 552f202ca8 | |||
| dc76f4082f | |||
| 6d01095586 | |||
| b914e48a41 | |||
| da482ec455 | |||
| 48c38ace54 | |||
| 2b1496c857 | |||
| c15e437ff7 | |||
| 0ac0eccce4 | |||
| 678327e994 | |||
| b0478f4df7 | |||
| 00319f0e43 | |||
| 55eb894d8e | |||
| c59a80a41f | |||
| 24b482893d | |||
| ad58895b25 | |||
| 25fc518c5d | |||
| d92722e7ab | |||
| 4041fd7e5c | |||
| 06ea73a19b | |||
| 7384a3c121 | |||
| c18c953a7c | |||
| ae2df0c35e | |||
| dacc7fc740 | |||
| 9af2c1252c | |||
| 35bfe26a3a | |||
| 8686362aeb | |||
| f5955489ec | |||
| aaa15770d5 | |||
| 08c01c4f3f | |||
| 0903c30060 | |||
| b420298398 | |||
| 2607eb8d32 | |||
| d8173b1cda | |||
| c56f1a8216 | |||
| 31e74371ef | |||
| e48f13f173 | |||
| c574363cf6 | |||
| 70fd4a5c88 | |||
| e62a67c719 | |||
| 57c1195253 | |||
| 42889d23e5 | |||
| 3a7f09a250 | |||
| d95d4335bf | |||
| 735e88f673 | |||
| c55105bff3 | |||
| 77afc805e1 | |||
| 9dd73b4d47 | |||
| f2b12bfef7 | |||
| dbeaf79d77 | |||
| 63dcb4dd6c | |||
| 9df3a7bcf9 | |||
| 89163edd16 | |||
| eaa55aab1e | |||
| 8d3a690c0a | |||
| 5263a65ed6 | |||
| 24d3e8edba | |||
| b371dd2cdf | |||
| 597ad8c425 | |||
| 33f9d96caa | |||
| 689571df22 | |||
| a3242f0634 | |||
| f5112928b3 | |||
| bcd87ddc58 | |||
| 7c8a87af05 | |||
| 8e2d507e5c | |||
| b6fbec066d | |||
| bd136cadce | |||
| 0a934e1143 | |||
| c44ba62da3 | |||
| 76c0aed05c | |||
| e7fc22c6b3 | |||
| b91727b804 | |||
| 534fd79377 | |||
| 3ea4742b29 | |||
| 364c0eb6e2 | |||
| 322b3ff641 | |||
| 38736c154b | |||
| 129f681c59 | |||
| d776fc0827 | |||
| 7af6074cb5 | |||
| 7aa700bf2b | |||
| 0d47750b15 | |||
| a9dc57eeef | |||
| 5bfebd371d | |||
| f1da2c76d1 | |||
| b5dc774093 | |||
| b7fe45d800 | |||
| 7f5bbe0ee3 | |||
| 40632589a2 | |||
| e6e063138e | |||
| 605af8d60e | |||
| 03660c19ef | |||
| 8747e3a2d3 | |||
| 7fd549fd39 | |||
| 1712a2732a | |||
| 46bc76bae3 | |||
| e24b6c27b0 | |||
| 8c6dda125f | |||
| f6047aafe8 | |||
| dce5715982 | |||
| ea910b8e7d | |||
| e51af66d95 | |||
| f93b287949 | |||
| 627fbd2e86 | |||
| e4c056a57a | |||
| 23291398ec | |||
| 79fc352a5a | |||
| 8b6b3cddea | |||
| d1ca468c1e | |||
| ce28ad771c | |||
| ba951b01de | |||
| 670ab16ea1 | |||
| 4680535ecd | |||
| f96e63460e | |||
| 2df79c0404 | |||
| acef9630d5 | |||
| 12c3b2e0cd | |||
| 577707ae50 | |||
| 03325e9750 | |||
| a7ef8f9c12 | |||
| 40284d9f95 | |||
| 5efe8b8bd7 | |||
| 8dc6d736ee | |||
| 5316372772 | |||
| 4d1499ef75 | |||
| 0438285277 | |||
| 4879ea5cd5 | |||
| 2a1761ac06 | |||
| c29245c1cb | |||
| 5069694bba | |||
| d1a80a85c0 | |||
| 5c93d74dec | |||
| e52dbd49be | |||
| ccc8a5f278 | |||
| cfb5b9dfea | |||
| 73d95245f8 | |||
| fb91984fcb | |||
| 29cb1fa12e | |||
| 78240ed199 | |||
| 8f8707fd77 | |||
| ed3db06154 | |||
| 7c05a68876 | |||
| 6cfc0dd8e1 | |||
| 81baeae5c4 | |||
| a3010bdc0b | |||
| 8133e550ed | |||
| 2bb0eab636 | |||
| 5311b5d00d | |||
| 9b02ccdd12 | |||
| 231783eebe | |||
| 756606f478 | |||
| 6651c1c5da | |||
| 61e257b2a8 | |||
| 3ac4caf735 | |||
| 268ae1751d | |||
| 015cbf850b | |||
| 873e13c2fb | |||
| 688bf7e7a1 | |||
| a6ffff3b39 | |||
| 023fc55bd5 | |||
| 351b909a53 | |||
| 6bec4f65c9 | |||
| 74f87ce152 | |||
| 92c472ccc7 | |||
| b92b8becd1 | |||
| 23d0d6a65d | |||
| 1660067d6e | |||
| 0642475b85 | |||
| 8cb634c9bc | |||
| 768b41c3cf | |||
| ca88516d54 | |||
| 871a2a149f | |||
| 60e381eff0 | |||
| 768b3eb6f9 | |||
| 2f88da4a6d | |||
| a8cdf6964c | |||
| 985c3db4fd | |||
| 9636472db7 | |||
| 0ad268aa7d | |||
| a4ea33167d | |||
| 0f13aabea8 | |||
| 1e76ef5ccb | |||
| e6e3229d17 | |||
| dccf8e723a | |||
| c41ba7d627 | |||
| a6e9316de3 | |||
| 559d326cbd | |||
| abedf2506f | |||
| d01428b5bc | |||
| 0de1f17e5c | |||
| 17d07a5a43 | |||
| 3bdbea99a3 | |||
| b7683aedb1 | |||
| 515036e758 | |||
| 22b382527f | |||
| 2cfe4b5b86 | |||
| 6876c8041c | |||
| 7de45584ce | |||
| 5572d7c7e8 | |||
| db0a2fe52e | |||
| f0ae8d6167 | |||
| 2514e181ba | |||
| be2e6e9a14 | |||
| 875e2eac1b | |||
| c3c73ceb1f | |||
| 6318bf0a2a | |||
| 5e1f252046 | |||
| df3b960505 | |||
| 26bc108bf1 | |||
| a5cff32743 | |||
| d418dd8eec | |||
| 61702fe346 | |||
| 43f0c780c3 | |||
| 30ebf2bfa9 | |||
| 7e3027b5f7 | |||
| b3acf83090 | |||
| 36c3d6e48a | |||
| f782ac6b3c | |||
| feef2dd1fa | |||
| a716d8789d | |||
| 6816f89189 | |||
| bfcac64a9d | |||
| 664eb601a2 | |||
| 8e5cc4e0aa | |||
| 9f28575903 | |||
| 4b9a26a5e6 | |||
| 7b85adf1cc | |||
| 917d362a58 | |||
| 3c27a90eb9 | |||
| c964708ebe | |||
| 883eb498c0 | |||
| b85af2ec47 | |||
| 2f0f97aa66 | |||
| a6e03c6735 | |||
| e7cbfb89d6 | |||
| 6c2decfbfb | |||
| b33d6d1d4a | |||
| 1b32e70dc5 | |||
| 4d3738d225 | |||
| b5e90e77aa | |||
| dd0dee739d | |||
| 4d19914fcb | |||
| 887c7710e9 | |||
| 7a722773c7 | |||
| a763aff58b | |||
| c1011f4e5c | |||
| f7afa103a5 | |||
| d0bd5b473b | |||
| 08b28b4029 | |||
| 269bf883c2 |
@ -1,73 +1,93 @@
|
||||
---
|
||||
name: frontend-code-review
|
||||
description: "Trigger when the user requests a review of frontend files (e.g., `.tsx`, `.ts`, `.js`). Support both pending-change reviews and focused file reviews while applying the checklist rules."
|
||||
description: Review Dify frontend code for correctness, accessibility, component design, dify-ui usage, data/query boundaries, performance, and tests. Trigger for `.tsx`, `.ts`, `.js`, UI, React, Next.js, pending-change, or focused frontend review requests.
|
||||
---
|
||||
|
||||
# Frontend Code Review
|
||||
|
||||
## Intent
|
||||
Use this skill whenever the user asks to review frontend code (especially `.tsx`, `.ts`, or `.js` files). Support two review modes:
|
||||
## When To Use
|
||||
|
||||
1. **Pending-change review** – inspect staged/working-tree files slated for commit and flag checklist violations before submission.
|
||||
2. **File-targeted review** – review the specific file(s) the user names and report the relevant checklist findings.
|
||||
Use this skill when the user asks to review, audit, analyze, or sanity-check frontend code under `web/`, `packages/dify-ui/`, or frontend-adjacent TypeScript files.
|
||||
|
||||
Stick to the checklist below for every applicable file and mode.
|
||||
Supported modes:
|
||||
|
||||
## Checklist
|
||||
See [references/code-quality.md](references/code-quality.md), [references/performance.md](references/performance.md), [references/business-logic.md](references/business-logic.md) for the living checklist split by category—treat it as the canonical set of rules to follow.
|
||||
- **Pending-change review**: inspect staged and working-tree changes.
|
||||
- **File-focused review**: inspect explicitly named files or paths.
|
||||
- **Diff/snippet review**: review pasted diffs or snippets using best-effort references.
|
||||
|
||||
Flag each rule violation with urgency metadata so future reviewers can prioritize fixes.
|
||||
Do not use this skill for backend-only code under `api/`; use `backend-code-review` instead.
|
||||
|
||||
## Required Context
|
||||
|
||||
Before reviewing, read the relevant local contracts:
|
||||
|
||||
- `web/AGENTS.md` for Dify frontend workflow, overlays, design tokens, state, and tests.
|
||||
- `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md` when code uses or changes `@langgenius/dify-ui/*`.
|
||||
- `web/docs/overlay.md` when reviewing dialogs, drawers, popovers, tooltips, menus, selects, comboboxes, or other floating UI.
|
||||
- `web/docs/test.md` and the `frontend-testing` skill when reviewing tests or testability.
|
||||
- `how-to-write-component` when reviewing React component structure, ownership, effects, query/mutation contracts, or memoization.
|
||||
|
||||
For any UI, UX, or accessibility review, fetch the latest Web Interface Guidelines before finalizing findings. Treat them as a required baseline, not the complete source of accessibility truth:
|
||||
|
||||
```text
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
If the review depends on a current framework, SDK, browser API, or accessibility behavior and local code does not settle it, check the current official docs first. For browser compatibility, deprecation, or behavior-sensitive frontend APIs, verify MDN or the relevant standard.
|
||||
|
||||
## Rule Packs
|
||||
|
||||
Apply every relevant rule pack:
|
||||
|
||||
- [references/accessibility-ui.md](references/accessibility-ui.md) — accessibility, semantic HTML, focus, forms, keyboard, disabled states, copy, and long-content behavior. Combines Web Interface Guidelines with Dify UI, Base UI, MDN, and local primitive contracts.
|
||||
- [references/dify-ui.md](references/dify-ui.md) — Dify UI primitive usage, Base UI semantics, overlays, forms, tokens, radius mapping, and primitive boundaries.
|
||||
- [references/component-architecture.md](references/component-architecture.md) — component ownership, props, state, effects, exports, wrappers, and feature organization.
|
||||
- [references/data-query-contracts.md](references/data-query-contracts.md) — generated contracts, TanStack Query, mutations, workspace/auth/SSR boundaries, URL/local storage state.
|
||||
- [references/performance.md](references/performance.md) — React/Next performance review rules from Vercel guidance, scoped to real risk.
|
||||
- [references/testing.md](references/testing.md) — frontend test review rules.
|
||||
- [references/dify-invariants.md](references/dify-invariants.md) — stable Dify-specific runtime invariants that generic React/a11y rules will not catch.
|
||||
- [references/code-quality.md](references/code-quality.md) — general TypeScript, styling, naming, and maintainability rules.
|
||||
|
||||
## Review Process
|
||||
1. Open the relevant component/module. Gather lines that relate to class names, React Flow hooks, prop memoization, and styling.
|
||||
2. For each rule in the review point, note where the code deviates and capture a representative snippet.
|
||||
3. Compose the review section per the template below. Group violations first by **Urgent** flag, then by category order (Code Quality, Performance, Business Logic).
|
||||
|
||||
## Required output
|
||||
When invoked, the response must exactly follow one of the two templates:
|
||||
1. Identify the review scope. For pending changes, inspect `git diff --stat`, `git diff`, and staged diff if relevant. For file-focused reviews, stay within the named files unless a referenced owner/contract must be read.
|
||||
2. Read code around the changed lines and the owning module. Do not review by isolated snippets when nearby ownership, labels, query inputs, or overlay structure decide correctness.
|
||||
3. Check user-visible regressions first: accessibility, broken interaction, auth/permission leaks, query/hydration errors, data loss, navigation mistakes, and impossible states.
|
||||
4. Then check maintainability and performance: ownership, effects, wrappers, memoization, bundle/waterfall risks, tests, and design-system drift.
|
||||
5. Report only actionable findings. Do not list speculative risks, style preferences, or broad refactors unless they are directly tied to a reproducible issue in scope.
|
||||
|
||||
### Template A (any findings)
|
||||
```
|
||||
# Code review
|
||||
Found <N> urgent issues need to be fixed:
|
||||
## Severity
|
||||
|
||||
## 1 <brief description of bug>
|
||||
FilePath: <path> line <line>
|
||||
<relevant code snippet or pointer>
|
||||
- **P0**: security/privacy/auth leak, data loss, production crash, inaccessible critical flow, or broken primary workflow.
|
||||
- **P1**: user-visible regression, hydration/SSR failure, invalid API/query contract, broken keyboard/focus behavior, or serious design-system/a11y violation.
|
||||
- **P2**: maintainability or performance issue likely to cause bugs, duplicated state, incorrect ownership, missing tests for risky behavior, or non-critical a11y issue.
|
||||
- **P3**: minor cleanup with clear value. Omit unless the user asked for a thorough audit.
|
||||
|
||||
## Output Format
|
||||
|
||||
### Suggested fix
|
||||
<brief description of suggested fix>
|
||||
Lead with findings, ordered by severity. Use this structure:
|
||||
|
||||
---
|
||||
... (repeat for each urgent issue) ...
|
||||
```markdown
|
||||
## Findings
|
||||
|
||||
Found <M> suggestions for improvement:
|
||||
- [P1] Short issue title
|
||||
File: `path/to/file.tsx:123`
|
||||
Why it matters and how to reproduce or reason about it.
|
||||
Suggested fix: concrete fix direction.
|
||||
|
||||
## 1 <brief description of suggestion>
|
||||
FilePath: <path> line <line>
|
||||
<relevant code snippet or pointer>
|
||||
## Open Questions
|
||||
|
||||
- Question or assumption, if any.
|
||||
|
||||
### Suggested fix
|
||||
<brief description of suggested fix>
|
||||
## Summary
|
||||
|
||||
---
|
||||
|
||||
... (repeat for each suggestion) ...
|
||||
Brief secondary context. Mention tests not run or residual risk.
|
||||
```
|
||||
|
||||
If there are no urgent issues, omit that section. If there are no suggestions, omit that section.
|
||||
|
||||
If the issue number is more than 10, summarize as "10+ urgent issues" or "10+ suggestions" and just output the first 10 issues.
|
||||
|
||||
Don't compress the blank lines between sections; keep them as-is for readability.
|
||||
|
||||
If you use Template A (i.e., there are issues to fix) and at least one issue requires code changes, append a brief follow-up question after the structured output asking whether the user wants you to apply the suggested fix(es). For example: "Would you like me to use the Suggested fix section to address these issues?"
|
||||
|
||||
### Template B (no issues)
|
||||
```
|
||||
## Code review
|
||||
No issues found.
|
||||
```
|
||||
Rules:
|
||||
|
||||
- If there are no findings, say `No issues found.` and mention any test gaps or residual risk.
|
||||
- Always include file and line when available.
|
||||
- Keep findings concrete and reproducible.
|
||||
- Do not include praise sections by default.
|
||||
- Do not ask to apply fixes unless the user explicitly wants review plus implementation.
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
# Accessibility And UI Rules
|
||||
|
||||
Accessibility findings are first-class review findings. Treat broken keyboard access, missing accessible names, focus loss, and unreachable popup content as correctness bugs, not polish.
|
||||
|
||||
Before finalizing UI or accessibility findings, fetch the latest Web Interface Guidelines as a required baseline:
|
||||
|
||||
```text
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Do not treat that document as the complete accessibility rule set. Combine it with:
|
||||
|
||||
- `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, and the relevant primitive implementation when code uses `@langgenius/dify-ui/*`.
|
||||
- Base UI docs and local `.d.ts` contracts when primitive semantics, focus target, labels, or popup reachability are unclear.
|
||||
- MDN or relevant WAI-ARIA/browser standards when behavior, compatibility, or deprecation status matters.
|
||||
- The current feature's product semantics, because an accessible primitive can still be used in an inaccessible workflow.
|
||||
|
||||
## Semantic HTML
|
||||
|
||||
Flag:
|
||||
|
||||
- Clickable `div` or `span` used for actions.
|
||||
- Router navigation implemented with button or `onClick` when a `Link` / `<a>` is the real semantic element.
|
||||
- Icon-only buttons without `aria-label` or `aria-labelledby`.
|
||||
- Decorative icons missing `aria-hidden="true"`.
|
||||
- Images without `alt`; use `alt=""` only when truly decorative.
|
||||
- Heading levels that skip hierarchy in page-level content.
|
||||
|
||||
Prefer semantic HTML before ARIA.
|
||||
|
||||
## Keyboard And Focus
|
||||
|
||||
Flag:
|
||||
|
||||
- Interactive elements without visible `focus-visible` treatment.
|
||||
- `outline-none` / `outline-hidden` without an equivalent focus-visible ring or state.
|
||||
- Custom interactive elements missing keyboard handling.
|
||||
- Focus trapped, lost, or sent to the wrong surface after dialog/popover/menu close.
|
||||
- Focus ring applied to the wrong DOM node. Verify the actual focus target, especially with Base UI controls such as Slider.
|
||||
|
||||
Use `focus-visible` for keyboard focus. Use `focus-within` or `has-[:focus-visible]` when the visual wrapper is not the focused element.
|
||||
|
||||
## Forms
|
||||
|
||||
Flag:
|
||||
|
||||
- Inputs, selects, switches, checkboxes, radios, comboboxes, or sliders without a label relationship.
|
||||
- Missing stable `name` on form fields that submit or validate.
|
||||
- Incorrect input `type`, `inputMode`, `autoComplete`, or `spellCheck` for email, token, URL, number, search, code, or username fields.
|
||||
- Labels that are not clickable.
|
||||
- Submit buttons disabled before a request starts, preventing normal submit behavior.
|
||||
- Non-submit buttons inside forms missing `type="button"`.
|
||||
- Errors not associated with fields or not reachable by screen readers.
|
||||
- Error recovery that does not focus or expose the first invalid field.
|
||||
- `onPaste` blocking paste.
|
||||
- Placeholder text used as the only label.
|
||||
- Password managers accidentally triggered on non-auth fields because autocomplete is missing or wrong.
|
||||
|
||||
Prefer visible labels. If visible surrounding text already labels the control, use a visually hidden label or a precise `aria-label`.
|
||||
|
||||
## Disabled, Loading, And Async States
|
||||
|
||||
Flag:
|
||||
|
||||
- Loading state without `aria-busy`, `role="status"`, or another accessible update path when it changes user interaction.
|
||||
- Spinner or decorative loading icon exposed to screen readers.
|
||||
- Disabled controls that hide the reason users cannot proceed.
|
||||
- `aria-disabled` used without manually blocking click, Space, and Enter.
|
||||
- Toasts, inline validation, or async status changes that are not announced when users need the update to continue.
|
||||
- Icon-only loading/error affordances without text or accessible status where the state matters.
|
||||
|
||||
Use native `disabled` when the control must not be interactive. Use `aria-disabled` only when the element must remain focusable and the code handles all blocked interactions.
|
||||
|
||||
For repeated shared disabled reasons, prefer a visible group message or badge plus native disabled controls. Use per-control popover/info only when the reason is item-specific.
|
||||
|
||||
## Overlays And Popup Reachability
|
||||
|
||||
Flag:
|
||||
|
||||
- Tooltip used for long, structured, interactive, or unique information.
|
||||
- Tooltip content required to understand or complete a flow.
|
||||
- PreviewCard content that touch or screen-reader users cannot reach through the trigger's click destination.
|
||||
- Popover/dialog/menu triggers without accessible names.
|
||||
- Popup content without title/description where the primitive requires them.
|
||||
|
||||
Use Popover for explanatory content, rich help, and infotips. Use Tooltip only as a short visual label for a trigger that already has an accessible name.
|
||||
|
||||
## Long Content And Layout
|
||||
|
||||
Flag:
|
||||
|
||||
- Text in flex/grid children without `min-w-0` when it can overflow.
|
||||
- Names, labels, file names, model names, workspace names, or user content lacking `truncate`, `line-clamp`, or `break-words`.
|
||||
- Right-side icons, badges, checks, or actions that shrink before the text area.
|
||||
- Empty arrays or empty strings rendering broken layout instead of an empty state.
|
||||
- Button, tab, badge, chip, menu item, or card text that can overlap sibling controls at common viewport widths.
|
||||
|
||||
The usual Dify layout chain is: container has width constraints, text region uses `min-w-0 flex-1 truncate`, adornments use `shrink-0`.
|
||||
|
||||
## Motion, Images, And Copy
|
||||
|
||||
Flag:
|
||||
|
||||
- `transition-all`.
|
||||
- Animations that do not respect reduced motion.
|
||||
- Layout-affecting animation where transform/opacity would work.
|
||||
- Images without dimensions.
|
||||
- Loading copy using `...` instead of `…`.
|
||||
- Hardcoded dates, times, numbers, or currency formats instead of `Intl.*`.
|
||||
@ -1,15 +0,0 @@
|
||||
# Rule Catalog — Business Logic
|
||||
|
||||
## Can't use workflowStore in Node components
|
||||
|
||||
IsUrgent: True
|
||||
|
||||
### Description
|
||||
|
||||
File path pattern of node components: `web/app/components/workflow/nodes/[nodeName]/node.tsx`
|
||||
|
||||
Node components are also used when creating a RAG Pipe from a template, but in that context there is no workflowStore Provider, which results in a blank screen. [This Issue](https://github.com/langgenius/dify/issues/29168) was caused by exactly this reason.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
Use `import { useNodes } from 'reactflow'` instead of `import useNodes from '@/app/components/workflow/store/workflow/use-nodes'`.
|
||||
@ -1,44 +1,66 @@
|
||||
# Rule Catalog — Code Quality
|
||||
# Code Quality Rules
|
||||
|
||||
## Conditional class names use utility function
|
||||
## Scope Control
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
Flag changes that expand beyond the requested feature or review scope:
|
||||
|
||||
### Description
|
||||
- Repo-wide cleanup mixed into a targeted fix.
|
||||
- Compatibility exports, aliases, shims, or wrapper layers added without an explicit migration requirement.
|
||||
- Shared abstractions created before there is stable cross-feature reuse.
|
||||
- Business components moved into generic shared locations without a clear ownership boundary.
|
||||
|
||||
Ensure conditional CSS is handled via the shared `classNames` instead of custom ternaries, string concatenation, or template strings. Centralizing class logic keeps components consistent and easier to maintain.
|
||||
## TypeScript
|
||||
|
||||
### Suggested Fix
|
||||
Flag:
|
||||
|
||||
```ts
|
||||
import { cn } from '@/utils/classnames'
|
||||
const classNames = cn(isActive ? 'text-primary-600' : 'text-gray-500')
|
||||
```
|
||||
- `any` or broad `Record<string, any>` where generated/API types or local domain types exist.
|
||||
- Re-declared API shapes instead of importing generated or returned types.
|
||||
- Weak route/query param typing that leaks `string | string[] | undefined` deep into components.
|
||||
- Runtime wrappers added only to satisfy TypeScript when a narrower type boundary would preserve the existing runtime shape.
|
||||
|
||||
## Tailwind-first styling
|
||||
Prefer:
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
- Explicit domain names that match the API contract.
|
||||
- Type narrowing at route/API boundaries.
|
||||
- Small conversion helpers colocated with the component that needs them.
|
||||
|
||||
### Description
|
||||
## Styling
|
||||
|
||||
Favor Tailwind CSS utility classes instead of adding new `.module.css` files unless a Tailwind combination cannot achieve the required styling. Keeping styles in Tailwind improves consistency and reduces maintenance overhead.
|
||||
Flag:
|
||||
|
||||
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
|
||||
- New CSS modules or ad hoc CSS when Tailwind utilities and Dify tokens cover the need.
|
||||
- Generic color utilities where Dify semantic tokens exist.
|
||||
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or documented radius mappings exist.
|
||||
- `!` important modifiers or important CSS overrides without a narrow, documented reason.
|
||||
- Manual string concatenation for conditional classes.
|
||||
- JS conditional class branches for primitive visual states already exposed by Dify UI/Base UI `data-*` selectors.
|
||||
- Incoming `className` placed before default classes in `cn(...)`, preventing call-site overrides.
|
||||
- Arbitrary z-index or one-off layering fixes on overlays.
|
||||
|
||||
## Classname ordering for easy overrides
|
||||
Use:
|
||||
|
||||
### Description
|
||||
- `cn(...)` from the local package or utility already used by the file.
|
||||
- Dify semantic tokens and Tailwind v4 utilities.
|
||||
- Existing component variants before one-off class forks.
|
||||
- Primitive selectors such as `data-disabled:*`, `data-checked:*`, `data-highlighted:*`, `group-data-*`, `peer-data-*`, and `has-[:focus-visible]` before adding React state or boolean props solely for styling.
|
||||
- Component-level variants, semantic tokens, and normal cascade/order before `!` overrides. Use `!` only for a contained compatibility override that cannot be expressed through the component API or local selector structure.
|
||||
|
||||
When writing components, always place the incoming `className` prop after the component’s own class values so that downstream consumers can override or extend the styling. This keeps your component’s defaults but still lets external callers change or remove specific styles.
|
||||
## Imports
|
||||
|
||||
Example:
|
||||
Flag:
|
||||
|
||||
```tsx
|
||||
import { cn } from '@/utils/classnames'
|
||||
- Barrel imports from `@langgenius/dify-ui`; consumers must use subpath exports.
|
||||
- New overlay imports from legacy `@/app/components/base/modal`, `dialog`, or `drawer`.
|
||||
- Cross-feature imports that bypass explicit top-level public files.
|
||||
- Direct imports from generated/internal implementation files when a feature contract already exposes the intended surface.
|
||||
|
||||
const Button = ({ className }) => {
|
||||
return <div className={cn('bg-primary-600', className)}></div>
|
||||
}
|
||||
```
|
||||
## Copy And i18n
|
||||
|
||||
Flag:
|
||||
|
||||
- User-facing hardcoded strings in `web/`.
|
||||
- Translation namespace drift, especially using unrelated module namespaces for local feature copy.
|
||||
- Generic button labels like `Continue` where the action is specific.
|
||||
- Error messages that state only the failure and not the next step.
|
||||
|
||||
Use feature-local translation keys by default. Alias only when crossing namespaces.
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
# Component Architecture Rules
|
||||
|
||||
Use these rules for React component structure, ownership, state, props, effects, and module organization.
|
||||
|
||||
## Ownership
|
||||
|
||||
Flag:
|
||||
|
||||
- State, query, mutation, or handlers hoisted above the lowest component that actually uses them.
|
||||
- Parent components owning row/item actions that do not coordinate a workflow.
|
||||
- Prop drilling through multiple pass-through layers.
|
||||
- A page/tab-level section component becoming the data owner without needing a shared snapshot or shared loading/error/empty UI.
|
||||
- Feature code promoted to shared only because it appears once or might be reused later.
|
||||
|
||||
Accept repeated TanStack Query calls in siblings when each component independently consumes the data. Cache deduplication is not a reason to hoist by itself.
|
||||
|
||||
## Component Boundaries
|
||||
|
||||
Flag:
|
||||
|
||||
- Shallow wrappers that only rename props or hide the real primitive.
|
||||
- Extra DOM wrappers that do not provide layout, semantics, accessibility, state ownership, or library integration.
|
||||
- Dialog/dropdown/popover hidden surfaces that obscure the parent flow when they should be extracted into a small local component.
|
||||
- Business forms, menu bodies, or one-off helpers moved away from their owner without reuse or semantic value.
|
||||
|
||||
Prefer colocated components split by actual data and state needs.
|
||||
|
||||
## Bad Component Design Patterns
|
||||
|
||||
Flag:
|
||||
|
||||
- Components that mix data fetching, mutation side effects, popup state, form validation, layout, and row rendering without a clear owner.
|
||||
- Generic components with many boolean props that encode one feature's workflow.
|
||||
- A shared component that imports feature-specific copy, routes, or API contracts.
|
||||
- A feature component that accepts pre-rendered fragments only to avoid placing ownership correctly.
|
||||
- A child component that receives both raw server data and separately derived flags for the same concept.
|
||||
- A wrapper that changes accessible semantics of the primitive it wraps.
|
||||
- A component that exposes controlled props but still keeps a competing private state for the same value.
|
||||
- A component that cannot render empty, loading, or missing optional API fields without caller-side preprocessing.
|
||||
|
||||
## Props And Types
|
||||
|
||||
Flag:
|
||||
|
||||
- `React.FC` / `FC`.
|
||||
- Default exports outside framework-required files.
|
||||
- Named `Props` types for trivial one-off props where inline typing is clearer.
|
||||
- Props named by UI implementation instead of domain/API role.
|
||||
- API data converted too early or under a generic name that breaks traceability.
|
||||
- Callers duplicating fallback checks that the lowest rendering component already handles.
|
||||
|
||||
Prefer top-level `function` declarations for components and module helpers. Use arrow functions for callbacks and local lambdas.
|
||||
|
||||
## Effects
|
||||
|
||||
Flag effects that:
|
||||
|
||||
- Transform props/state for rendering.
|
||||
- Copy one state value into another representing the same concept.
|
||||
- Handle user actions that belong in event handlers.
|
||||
- Reset state from props when a keyed reset, stable ID, or render-time derivation would work.
|
||||
- Fetch data that belongs in framework APIs or TanStack Query.
|
||||
|
||||
If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration.
|
||||
|
||||
## State Modeling
|
||||
|
||||
Flag:
|
||||
|
||||
- Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state.
|
||||
- Local state used to fake server data or generated contract fields.
|
||||
- UI state persisted to localStorage when it is live app state.
|
||||
- Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed.
|
||||
|
||||
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled popups, and feature UI state that has no server source.
|
||||
|
||||
## Navigation
|
||||
|
||||
Flag:
|
||||
|
||||
- Imperative router navigation for ordinary links.
|
||||
- Button semantics used for navigation.
|
||||
- Navigation state hidden in component state when URL state is required for shareable filters, tabs, or pagination.
|
||||
|
||||
Use `Link` for normal navigation. Use router APIs for mutation success, guarded redirects, command flows, or form submission side effects.
|
||||
@ -0,0 +1,74 @@
|
||||
# Data, Query, And Contract Rules
|
||||
|
||||
Use these rules for generated contracts, TanStack Query, mutations, auth/SSR boundaries, URL state, and client persistence.
|
||||
|
||||
## Generated Contracts
|
||||
|
||||
Flag:
|
||||
|
||||
- New legacy service/helper wrappers around generated `queryOptions()` or `mutationOptions()`.
|
||||
- Continuing to use deprecated contract operations when a ready generated contract exists.
|
||||
- Assuming a generated file means an operation is ready without checking deprecated markers, schema shape, and the actual UI consumer.
|
||||
- Re-declaring API DTOs in components.
|
||||
- Adding compatibility layers instead of migrating the pointed line and deleting the old layer.
|
||||
|
||||
Use `web/contract/*` as the API shape source of truth. Follow existing `{ params, query?, body? }` input shape.
|
||||
|
||||
## Queries
|
||||
|
||||
Flag:
|
||||
|
||||
- `enabled` used to hide missing required input instead of `input: skipToken`.
|
||||
- Fake fallback IDs or placeholder inputs used to force a query to run.
|
||||
- Query results copied into local state for rendering.
|
||||
- Shared query behavior such as invalidation, stale defaults, or retry rules reimplemented at call sites.
|
||||
- `prefetchQuery` treated as a hard gate or as returning data/errors to the caller.
|
||||
|
||||
Use `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))` directly unless a feature hook performs real orchestration.
|
||||
|
||||
## Mutations
|
||||
|
||||
Flag:
|
||||
|
||||
- Deprecated `useInvalid` or `useReset`.
|
||||
- `mutateAsync` used without a need for Promise semantics.
|
||||
- Awaited mutations without `try/catch`.
|
||||
- Components owning shared cache invalidation that belongs in query defaults.
|
||||
- Optimistic updates that do not match current list/detail ownership.
|
||||
|
||||
Use generated `mutationOptions()` directly when possible. Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`.
|
||||
|
||||
## SSR, Auth, And Route Boundaries
|
||||
|
||||
Flag:
|
||||
|
||||
- Request-time auth, setup, workspace role, or tenant decisions moved into static `next.config redirects()`.
|
||||
- Dynamic role gates depending on `workspaces.current` implemented as static path redirects.
|
||||
- Authorization logic depending on soft `prefetchQuery`.
|
||||
- Removing a client fallback before server API unavailable behavior is defined.
|
||||
- Global placeholder query contracts introduced to solve a route-local Suspense issue.
|
||||
- Branding-sensitive UI reading placeholder defaults without checking pending/placeholder state.
|
||||
|
||||
Separate hard gates from soft prefetches. `fetchQuery` can be a server decision boundary; `prefetchQuery` is cache warmup.
|
||||
|
||||
## Workspace And Tenant
|
||||
|
||||
Flag:
|
||||
|
||||
- Treating workspace switch as ordinary CRUD invalidation when the current app flow performs server switch plus full reload.
|
||||
- Query keys that omit workspace/tenant identity when the query truly varies by workspace and no full reload boundary applies.
|
||||
- Mixing `workspace_id` and `tenant_id` without tracing the current backend/API contract.
|
||||
|
||||
Current Dify workspace switch should be reviewed as a tenant cache boundary first.
|
||||
|
||||
## URL State And Local Storage
|
||||
|
||||
Flag:
|
||||
|
||||
- Shareable filters, tabs, pagination, selected panels, or search state hidden only in component state.
|
||||
- One-shot navigation signals modeled as subscribed persistent state.
|
||||
- Live app state stored in localStorage.
|
||||
- Direct `window.localStorage`, `globalThis.localStorage`, or raw storage calls in app code.
|
||||
- High-frequency interaction state persisted on every change instead of on commit/settle.
|
||||
|
||||
Use URL state for shareable UI state, feature/Jotai/store state for live UI state, and `@/hooks/use-local-storage` only for low-frequency client-only preferences, dismissed notices, and UI defaults.
|
||||
@ -0,0 +1,22 @@
|
||||
# Dify Invariants
|
||||
|
||||
Use these stable Dify-specific runtime rules in addition to the generic review packs.
|
||||
|
||||
This file is not a place for active feature notes. Do not add rules for one branch, one PR, or a short-lived product decision such as a specific agent-v2, plugin, model-provider, or onboarding task. Keep a rule here only when all of these are true:
|
||||
|
||||
- It is a stable Dify runtime invariant.
|
||||
- Generic React, TypeScript, accessibility, dify-ui, query, or performance rules would not catch it.
|
||||
- The failure mode is concrete enough to produce a file-line review finding.
|
||||
- The rule is likely to remain valid across normal feature work.
|
||||
|
||||
## Workflow Nodes And RAG Pipe
|
||||
|
||||
Flag:
|
||||
|
||||
- Node components under `web/app/components/workflow/nodes/[nodeName]/node.tsx` importing workflow store hooks that are unavailable in RAG Pipe template rendering.
|
||||
- Node UI relying on provider context that is not mounted in every rendering surface.
|
||||
- Store reads in render where React Flow `useNodes` / `useEdges` provide the actual node/edge source.
|
||||
|
||||
Known failure mode: workflow node components can also render while creating a RAG Pipe from a template. In that context there may be no workflowStore provider, causing a blank screen.
|
||||
|
||||
Prefer React Flow hooks for node/edge UI consumption. Use store APIs only where the provider is guaranteed and the code path is workflow-only.
|
||||
123
.agents/skills/frontend-code-review/references/dify-ui.md
Normal file
123
.agents/skills/frontend-code-review/references/dify-ui.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Dify UI Rules
|
||||
|
||||
Use these rules whenever a review touches `packages/dify-ui/` or code consuming `@langgenius/dify-ui/*`.
|
||||
|
||||
Before finalizing findings for those files, read the current local docs that apply:
|
||||
|
||||
- `packages/dify-ui/README.md`
|
||||
- `packages/dify-ui/AGENTS.md`
|
||||
- `web/docs/overlay.md` for floating UI
|
||||
- `packages/dify-ui/src/<primitive>/index.tsx` for the primitive being changed or consumed
|
||||
|
||||
## Package Boundary
|
||||
|
||||
Flag in `packages/dify-ui`:
|
||||
|
||||
- Imports from `web/`.
|
||||
- Dependencies on Next.js, i18n, ky, Jotai, Zustand, TanStack Query, oRPC, or business APIs.
|
||||
- Business-specific component behavior that belongs in `web/`.
|
||||
- Multiple unrelated primitives in one component folder.
|
||||
|
||||
`packages/dify-ui` is a primitive layer: Base UI headless components + `cva` + `cn` + Dify design tokens.
|
||||
|
||||
## Imports And Exports
|
||||
|
||||
Flag:
|
||||
|
||||
- Consumer imports from `@langgenius/dify-ui` without a subpath.
|
||||
- Missing `package.json#exports` entry for a new primitive.
|
||||
- Internal package imports using workspace subpaths instead of relative paths.
|
||||
- Exported props using internal-only types that consumers cannot import from the component subpath.
|
||||
|
||||
Consumers use subpath exports such as `@langgenius/dify-ui/button`.
|
||||
|
||||
## Props And State
|
||||
|
||||
Flag:
|
||||
|
||||
- Flattened props where related values need a discriminated union, such as `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`.
|
||||
- React state used only to mirror Base UI state for class names.
|
||||
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
|
||||
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
|
||||
- Thin wrappers that rename Base UI parts without adding semantics.
|
||||
|
||||
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
|
||||
|
||||
## Forms
|
||||
|
||||
Flag:
|
||||
|
||||
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
|
||||
- Text-like fields not composed through `FieldRoot`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
|
||||
- Select fields using `FieldLabel` instead of `SelectLabel`.
|
||||
- Slider fields using a generic label instead of `SliderLabel`.
|
||||
- Checkbox/radio groups missing `FieldsetRoot` and `FieldsetLegend`.
|
||||
- Field errors or descriptions rendered without `FieldDescription` / `FieldError` relationships.
|
||||
|
||||
`Form` is the submit boundary. Dify UI form primitives are not a form state-management framework; business validation and schema-driven behavior belong in `web/`.
|
||||
|
||||
## Overlay Contract
|
||||
|
||||
Flag:
|
||||
|
||||
- Legacy web overlay imports in new or modified code.
|
||||
- Manual portals around Dify UI overlay primitives.
|
||||
- Call-site `z-*` overrides on overlays.
|
||||
- Missing root `isolation: isolate` assumptions when debugging overlay stacking.
|
||||
- Repeated backdrop, z-index, or portal chrome at call sites.
|
||||
- Tooltip used for infotips, long text, or interactive content.
|
||||
|
||||
All Dify UI body-portalled overlays use `z-50`. Toast uses `z-60`. DOM order handles stacking between overlays.
|
||||
|
||||
## Primitive Selection
|
||||
|
||||
Flag:
|
||||
|
||||
- `Tabs` used for simple mode/filter/view selection where `SegmentedControl` is the semantic primitive.
|
||||
- `SegmentedControl` used where `tablist` / `tabpanel` semantics are required.
|
||||
- `Select` used for searchable or free-form input.
|
||||
- `Combobox` used for unrestricted search text where no selected option is remembered.
|
||||
- `Autocomplete` used for closed-list selection.
|
||||
- Tooltip or PreviewCard used for content that must be reachable on touch or by screen readers.
|
||||
|
||||
Use:
|
||||
|
||||
- `Autocomplete` for free-form text with optional suggestions.
|
||||
- `Combobox` for searchable selected values from a collection.
|
||||
- `Select` for closed, scannable option sets.
|
||||
- `Popover` for infotips, help text, rich content, or interactions.
|
||||
|
||||
## Bad Usage Patterns To Flag
|
||||
|
||||
Flag:
|
||||
|
||||
- Styling a raw Base UI primitive directly in `web/` when a Dify UI primitive exists.
|
||||
- Wrapping a Dify UI primitive in a feature component that hides its label, error, disabled, or focus contract.
|
||||
- Replacing a semantic primitive with a generic `div` plus classes to match a screenshot.
|
||||
- Using `Tooltip` because it is visually convenient when the content is actually help text or needs touch access.
|
||||
- Adding a `z-*` override to make a child popup appear over a parent dialog.
|
||||
- Adding a new app-level wrapper around Dialog, Drawer, Popover, Select, or Combobox that repeats portal/backdrop/positioner logic.
|
||||
- Using dify-ui `Input` as a drop-in replacement for legacy inputs that include search, clear, copy, unit, localized placeholder, or number normalization behavior.
|
||||
- Building a form row from loose text and controls instead of the matching Field/Form primitives.
|
||||
- Adding component state only to style `data-open`, `data-checked`, `data-disabled`, or highlighted states that Base UI already exposes.
|
||||
- Passing booleans down only so children can toggle classes already expressible with primitive `data-*` selectors.
|
||||
|
||||
## Tokens, Radius, And Styling
|
||||
|
||||
Flag:
|
||||
|
||||
- `radius-*` class names.
|
||||
- Custom Tailwind `borderRadius` extension for Figma radius values.
|
||||
- Generic colors where semantic Dify tokens exist.
|
||||
- Hardcoded design values where Dify tokens, component variants, or documented Figma radius mappings exist.
|
||||
- `!` important modifiers used to fight primitive styles instead of fixing the variant, selector, or component composition.
|
||||
- Manual class strings that duplicate primitive variants.
|
||||
- `min-w-(--anchor-width)` on picker popups when it defeats viewport clamping.
|
||||
|
||||
Use the Figma radius mapping from `packages/dify-ui/AGENTS.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`.
|
||||
|
||||
Use `!` only for a tightly scoped compatibility override after confirming the primitive API, data attributes, and selector structure cannot express the state.
|
||||
|
||||
## Focus Details
|
||||
|
||||
Flag focus rings attached to the wrong element. For example, Base UI `Slider.Thumb` focuses an internal `input[type=range]`, so the visible thumb wrapper needs `has-[:focus-visible]` rather than direct wrapper `focus-visible`.
|
||||
@ -1,45 +1,78 @@
|
||||
# Rule Catalog — Performance
|
||||
# Performance Rules
|
||||
|
||||
## React Flow data usage
|
||||
Review performance only where there is realistic impact. Do not request `memo`, `useMemo`, `useCallback`, virtualization, or caching as style preferences.
|
||||
|
||||
IsUrgent: True
|
||||
Category: Performance
|
||||
## Async Waterfalls
|
||||
|
||||
### Description
|
||||
Flag:
|
||||
|
||||
When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks.
|
||||
- Awaiting remote feature flags or fetches before checking cheap synchronous conditions.
|
||||
- Sequential awaits for independent operations.
|
||||
- API routes or server components starting requests late when they could start early.
|
||||
- Nested per-item fetches running serially when each item can fetch in parallel.
|
||||
- Suspense boundaries that force the whole page to wait when a lower boundary could stream or isolate loading.
|
||||
|
||||
## Complex prop stability
|
||||
Prefer `Promise.all` for independent work and branch-local awaits for conditionally needed data.
|
||||
|
||||
IsUrgent: False
|
||||
Category: Performance
|
||||
## Bundle Size
|
||||
|
||||
### Description
|
||||
Flag:
|
||||
|
||||
Only require stable object, array, or map props when there is a clear reason: the child is memoized, the value participates in effect/query dependencies, the value is part of a stable-reference API contract, or profiling/local behavior shows avoidable re-renders. Do not request `useMemo` for every inline object by default; `how-to-write-component` treats memoization as a targeted optimization.
|
||||
- Barrel imports from heavy libraries or `@langgenius/dify-ui`.
|
||||
- Dynamic paths that prevent static trace analysis.
|
||||
- Heavy components loaded eagerly when hidden behind a dialog, tab, command, or feature activation.
|
||||
- Analytics, logging, editor, visualization, or third-party SDK code loaded before it is needed.
|
||||
- Feature-local optional modules imported at top level only for rare flows.
|
||||
|
||||
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
|
||||
Use direct imports and `next/dynamic` where the user-visible path benefits.
|
||||
|
||||
Risky:
|
||||
## Server Rendering
|
||||
|
||||
```tsx
|
||||
<HeavyComp
|
||||
config={{
|
||||
provider: ...,
|
||||
detail: ...
|
||||
}}
|
||||
/>
|
||||
```
|
||||
Flag:
|
||||
|
||||
Better when stable identity matters:
|
||||
- Request-specific mutable state stored at module scope in SSR/RSC paths.
|
||||
- Large duplicate data serialized across RSC/client boundaries.
|
||||
- Static I/O repeated per request when it could be hoisted safely.
|
||||
- Cross-request cache without a bounded invalidation strategy.
|
||||
- Server actions lacking API-route-equivalent auth checks.
|
||||
|
||||
```tsx
|
||||
const config = useMemo(() => ({
|
||||
provider: ...,
|
||||
detail: ...
|
||||
}), [provider, detail]);
|
||||
Use request-scoped deduplication such as `React.cache()` when repeated server reads in one request are the problem.
|
||||
|
||||
<HeavyComp
|
||||
config={config}
|
||||
/>
|
||||
```
|
||||
## Re-rendering
|
||||
|
||||
Flag:
|
||||
|
||||
- Effects or subscriptions reading broad state when a derived boolean or narrower selector is enough.
|
||||
- Components defined inside components.
|
||||
- Derived rendering state stored in state/effects.
|
||||
- Non-primitive default props recreated for memoized children.
|
||||
- Expensive work recalculated on every render where it affects real interaction cost.
|
||||
- High-frequency transient values stored in state when refs or CSS variables would avoid render loops.
|
||||
|
||||
Do not flag simple primitive expressions wrapped or not wrapped in `useMemo`; prefer no memo for simple work.
|
||||
|
||||
Require stable object/array/function identity only when:
|
||||
|
||||
- The child is memoized and identity affects renders.
|
||||
- The value is an effect/query dependency.
|
||||
- A library API requires stable references.
|
||||
- Profiling or local behavior shows avoidable re-rendering.
|
||||
|
||||
## DOM, Lists, And Rendering
|
||||
|
||||
Flag:
|
||||
|
||||
- Layout reads in render (`getBoundingClientRect`, `offset*`, `scrollTop`).
|
||||
- Interleaved DOM reads/writes that can cause layout thrashing.
|
||||
- Large lists rendering without virtualization, pagination, or `content-visibility`.
|
||||
- SVG/animation code animating expensive properties when transform/opacity would work.
|
||||
- `transition-all`.
|
||||
- Long-running non-critical browser work performed immediately instead of idle/deferred scheduling.
|
||||
|
||||
## React Flow
|
||||
|
||||
For workflow React Flow components, keep this Dify-specific rule:
|
||||
|
||||
- UI consumption should use React Flow hooks such as `useNodes` / `useEdges`.
|
||||
- Callback-only reads or mutations can use `useStoreApi`.
|
||||
- Node components under `web/app/components/workflow/nodes/[nodeName]/node.tsx` must not depend on workflow stores that are absent in RAG Pipe template rendering.
|
||||
|
||||
72
.agents/skills/frontend-code-review/references/testing.md
Normal file
72
.agents/skills/frontend-code-review/references/testing.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Testing Review Rules
|
||||
|
||||
Use these rules when reviewing test files, testability of changed code, or risky frontend changes that should have tests.
|
||||
|
||||
## Missing Coverage
|
||||
|
||||
Flag missing tests when the change affects:
|
||||
|
||||
- User-visible behavior, navigation, form submission, validation, permissions, or loading/error/empty states.
|
||||
- Query/mutation cache behavior.
|
||||
- Accessibility-critical behavior such as labels, keyboard flow, focus, disabled state, or popup reachability.
|
||||
- URL state parsing/serialization.
|
||||
- Storage persistence or one-shot signals.
|
||||
- Regression-prone workflow or generated contract migration paths.
|
||||
|
||||
Do not request tests for purely mechanical renames or styling-only changes unless the styling affects layout, focus, or interaction.
|
||||
|
||||
## Selectors
|
||||
|
||||
Flag:
|
||||
|
||||
- `getByTestId` used where role, label, text, placeholder, landmark, or scoped dialog/menu queries are available.
|
||||
- Production `data-testid` added only to satisfy tests.
|
||||
- Assertions against decorative icons rather than the named control.
|
||||
- Tests that cannot find controls semantically but leave broken markup unchanged.
|
||||
|
||||
Prefer `getByRole` with accessible name, then `getByLabelText`, `getByPlaceholderText`, `getByText`, and `within(...)`.
|
||||
|
||||
## Mocking
|
||||
|
||||
Flag:
|
||||
|
||||
- Mocking `@langgenius/dify-ui/*` primitives.
|
||||
- Mocking `@/app/components/base/*` components when the real component is practical.
|
||||
- Mocking sibling or child components in the same directory for integration behavior.
|
||||
- Mocks that do not match the real component's conditional rendering.
|
||||
- Module-level mock state not reset in `beforeEach`.
|
||||
- `vi.clearAllMocks()` in `afterEach` instead of `beforeEach`.
|
||||
|
||||
Use real project components for integration behavior. Mock APIs, `next/navigation`, browser shims, or complex providers only when setup would dominate the test.
|
||||
|
||||
## Behavior
|
||||
|
||||
Flag:
|
||||
|
||||
- Tests inspecting implementation details instead of user-observable behavior.
|
||||
- Assertions that hardcode brittle copy when pattern matching or semantic roles would express behavior better.
|
||||
- Fake timers used without real timing behavior.
|
||||
- Async assertions missing `await`, `findBy*`, or `waitFor`.
|
||||
- Test data missing required fields because inline partial objects bypass real types.
|
||||
|
||||
Use typed factory functions with complete defaults and partial overrides.
|
||||
|
||||
## URL State
|
||||
|
||||
For `nuqs` or query-state hooks, flag tests that:
|
||||
|
||||
- Mock URL state when URL synchronization is the behavior under review.
|
||||
- Do not test parser serialize/parse round trips for custom parsers.
|
||||
- Do not assert default-clearing behavior when defaults should be removed from the URL.
|
||||
|
||||
Prefer shared `NuqsTestingAdapter` helpers when available.
|
||||
|
||||
## Organization
|
||||
|
||||
Flag:
|
||||
|
||||
- Component/hook/util tests outside sibling `__tests__/` directories.
|
||||
- Directory-level reviews that test only `index.tsx` while other files in scope contain behavior.
|
||||
- Large test files with repeated setup that should use local builders.
|
||||
|
||||
When a component is very complex, prefer a refactor finding before asking for exhaustive tests.
|
||||
@ -97,6 +97,7 @@ RUN \
|
||||
# Copy Python environment and packages
|
||||
ENV VIRTUAL_ENV=/app/api/.venv
|
||||
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
COPY --from=packages --chown=dify:dify /app/dify-agent /app/dify-agent
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
# Download nltk data
|
||||
|
||||
@ -949,6 +949,11 @@ class AuthConfig(BaseSettings):
|
||||
default=60,
|
||||
)
|
||||
|
||||
DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR: PositiveInt = Field(
|
||||
description="Max device-flow approve requests per session per hour on /openapi/oauth/device/approve.",
|
||||
default=10,
|
||||
)
|
||||
|
||||
|
||||
class ModerationConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@ -122,6 +122,7 @@ from .explore import (
|
||||
saved_message,
|
||||
trial,
|
||||
)
|
||||
from .snippets import snippet_workflow, snippet_workflow_draft_variable
|
||||
from .socketio import workflow as socketio_workflow
|
||||
|
||||
# Import tag controllers
|
||||
@ -137,6 +138,7 @@ from .workspace import (
|
||||
model_providers,
|
||||
models,
|
||||
plugin,
|
||||
snippets,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
workspace,
|
||||
@ -212,6 +214,9 @@ __all__ = [
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
"snippet_workflow",
|
||||
"snippet_workflow_draft_variable",
|
||||
"snippets",
|
||||
"socketio_workflow",
|
||||
"spec",
|
||||
"statistic",
|
||||
|
||||
164
api/controllers/console/snippets/payloads.py
Normal file
164
api/controllers/console/snippets/payloads.py
Normal file
@ -0,0 +1,164 @@
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class SnippetListQuery(BaseModel):
|
||||
"""Query parameters for listing snippets."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=99999)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
keyword: str | None = None
|
||||
is_published: bool | None = Field(default=None, description="Filter by published status")
|
||||
creators: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Filter by creator account IDs",
|
||||
validation_alias=AliasChoices("creators", "creator_id"),
|
||||
)
|
||||
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
|
||||
|
||||
@field_validator("creators", mode="before")
|
||||
@classmethod
|
||||
def parse_creators(cls, value: object) -> list[str] | None:
|
||||
"""Normalize creators filter from query string or list input."""
|
||||
return cls._normalize_string_list(value)
|
||||
|
||||
@field_validator("tag_ids", mode="before")
|
||||
@classmethod
|
||||
def parse_tag_ids(cls, value: object) -> list[str] | None:
|
||||
"""Normalize and validate tag IDs from query string or list input."""
|
||||
items = cls._normalize_string_list(value)
|
||||
if not items:
|
||||
return None
|
||||
try:
|
||||
return [str(uuid.UUID(item)) for item in items]
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid UUID format in tag_ids.") from exc
|
||||
|
||||
@staticmethod
|
||||
def _normalize_string_list(value: object) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()] or None
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()] or None
|
||||
return None
|
||||
|
||||
|
||||
class IconInfo(BaseModel):
|
||||
"""Icon information model."""
|
||||
|
||||
icon: str | None = None
|
||||
icon_type: Literal["emoji", "image"] | None = None
|
||||
icon_background: str | None = None
|
||||
icon_url: str | None = None
|
||||
|
||||
|
||||
class InputFieldDefinition(BaseModel):
|
||||
"""Input field definition for snippet parameters."""
|
||||
|
||||
default: str | None = None
|
||||
hint: bool | None = None
|
||||
label: str | None = None
|
||||
max_length: int | None = None
|
||||
options: list[str] | None = None
|
||||
placeholder: str | None = None
|
||||
required: bool | None = None
|
||||
type: str | None = None # e.g., "text-input"
|
||||
|
||||
|
||||
class CreateSnippetPayload(BaseModel):
|
||||
"""Payload for creating a new snippet."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
type: Literal["node", "group"] = "node"
|
||||
icon_info: IconInfo | None = None
|
||||
graph: dict[str, Any] | None = None
|
||||
input_fields: list[InputFieldDefinition] | None = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateSnippetPayload(BaseModel):
|
||||
"""Payload for updating a snippet."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
icon_info: IconInfo | None = None
|
||||
|
||||
|
||||
class SnippetDraftSyncPayload(BaseModel):
|
||||
"""Payload for syncing snippet draft workflow."""
|
||||
|
||||
graph: dict[str, Any]
|
||||
hash: str | None = None
|
||||
conversation_variables: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="Ignored. Snippet workflows do not persist conversation variables.",
|
||||
)
|
||||
input_fields: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class SnippetWorkflowListQuery(BaseModel):
|
||||
"""Query parameters for listing snippet published workflows."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=99999)
|
||||
limit: int = Field(default=10, ge=1, le=100)
|
||||
|
||||
|
||||
class WorkflowRunQuery(BaseModel):
|
||||
"""Query parameters for workflow runs."""
|
||||
|
||||
last_id: str | None = None
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class SnippetDraftRunPayload(BaseModel):
|
||||
"""Payload for running snippet draft workflow."""
|
||||
|
||||
inputs: dict[str, Any]
|
||||
files: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class SnippetDraftNodeRunPayload(BaseModel):
|
||||
"""Payload for running a single node in snippet draft workflow."""
|
||||
|
||||
inputs: dict[str, Any]
|
||||
query: str = ""
|
||||
files: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class SnippetIterationNodeRunPayload(BaseModel):
|
||||
"""Payload for running an iteration node in snippet draft workflow."""
|
||||
|
||||
inputs: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SnippetLoopNodeRunPayload(BaseModel):
|
||||
"""Payload for running a loop node in snippet draft workflow."""
|
||||
|
||||
inputs: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PublishWorkflowPayload(BaseModel):
|
||||
"""Payload for publishing snippet workflow."""
|
||||
|
||||
knowledge_base_setting: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SnippetImportPayload(BaseModel):
|
||||
"""Payload for importing snippet from DSL."""
|
||||
|
||||
mode: str = Field(..., description="Import mode: yaml-content or yaml-url")
|
||||
yaml_content: str | None = Field(default=None, description="YAML content (required for yaml-content mode)")
|
||||
yaml_url: str | None = Field(default=None, description="YAML URL (required for yaml-url mode)")
|
||||
name: str | None = Field(default=None, description="Override snippet name")
|
||||
description: str | None = Field(default=None, description="Override snippet description")
|
||||
snippet_id: str | None = Field(default=None, description="Snippet ID to update (optional)")
|
||||
|
||||
|
||||
class IncludeSecretQuery(BaseModel):
|
||||
"""Query parameter for including secret variables in export."""
|
||||
|
||||
include_secret: str = Field(default="false", description="Whether to include secret variables")
|
||||
638
api/controllers/console/snippets/snippet_workflow.py
Normal file
638
api/controllers/console/snippets/snippet_workflow.py
Normal file
@ -0,0 +1,638 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import ParamSpec, TypeVar
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync
|
||||
from controllers.console.app.workflow import (
|
||||
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE,
|
||||
WorkflowPaginationResponse,
|
||||
WorkflowResponse,
|
||||
)
|
||||
from controllers.console.snippets.payloads import (
|
||||
PublishWorkflowPayload,
|
||||
SnippetDraftNodeRunPayload,
|
||||
SnippetDraftRunPayload,
|
||||
SnippetDraftSyncPayload,
|
||||
SnippetIterationNodeRunPayload,
|
||||
SnippetLoopNodeRunPayload,
|
||||
SnippetWorkflowListQuery,
|
||||
WorkflowRunQuery,
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.workflow_run_fields import (
|
||||
WorkflowRunDetailResponse,
|
||||
WorkflowRunNodeExecutionListResponse,
|
||||
WorkflowRunNodeExecutionResponse,
|
||||
WorkflowRunPaginationResponse,
|
||||
)
|
||||
from graphon.graph_engine.manager import GraphEngineManager
|
||||
from libs import helper
|
||||
from libs.helper import TimestampField
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
from services.snippet_service import SnippetService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
# Register Pydantic models with Swagger
|
||||
class SnippetWorkflowResponse(WorkflowResponse):
|
||||
input_fields: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
SnippetDraftSyncPayload,
|
||||
SnippetDraftNodeRunPayload,
|
||||
SnippetDraftRunPayload,
|
||||
SnippetIterationNodeRunPayload,
|
||||
SnippetLoopNodeRunPayload,
|
||||
SnippetWorkflowListQuery,
|
||||
WorkflowRunQuery,
|
||||
PublishWorkflowPayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
SnippetWorkflowResponse,
|
||||
WorkflowPaginationResponse,
|
||||
WorkflowRunPaginationResponse,
|
||||
WorkflowRunDetailResponse,
|
||||
WorkflowRunNodeExecutionListResponse,
|
||||
WorkflowRunNodeExecutionResponse,
|
||||
)
|
||||
|
||||
|
||||
class SnippetNotFoundError(Exception):
|
||||
"""Snippet not found error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_snippet(view_func: Callable[P, R]):
|
||||
"""Decorator to fetch and validate snippet access."""
|
||||
|
||||
@wraps(view_func)
|
||||
def decorated_view(*args: P.args, **kwargs: P.kwargs):
|
||||
if not kwargs.get("snippet_id"):
|
||||
raise ValueError("missing snippet_id in path parameters")
|
||||
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet_id = str(kwargs.get("snippet_id"))
|
||||
del kwargs["snippet_id"]
|
||||
|
||||
snippet = SnippetService.get_snippet_by_id(
|
||||
snippet_id=snippet_id,
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
kwargs["snippet"] = snippet
|
||||
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
return decorated_view
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft")
|
||||
class SnippetDraftWorkflowApi(Resource):
|
||||
@console_ns.doc("get_snippet_draft_workflow")
|
||||
@console_ns.response(200, "Draft workflow retrieved successfully", console_ns.models[SnippetWorkflowResponse.__name__])
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get draft workflow for snippet."""
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
|
||||
if not workflow:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
db.session.expunge(workflow)
|
||||
workflow.conversation_variables = []
|
||||
workflow.input_fields = snippet.input_fields_list
|
||||
return SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
@console_ns.doc("sync_snippet_draft_workflow")
|
||||
@console_ns.expect(console_ns.models.get(SnippetDraftSyncPayload.__name__))
|
||||
@console_ns.response(200, "Draft workflow synced successfully")
|
||||
@console_ns.response(400, "Hash mismatch")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet):
|
||||
"""Sync draft workflow for snippet."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.sync_draft_workflow(
|
||||
snippet=snippet,
|
||||
graph=payload.graph,
|
||||
unique_hash=payload.hash,
|
||||
account=current_user,
|
||||
input_fields=payload.input_fields,
|
||||
)
|
||||
except WorkflowHashNotEqualError:
|
||||
raise DraftWorkflowNotSync()
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"hash": workflow.unique_hash,
|
||||
"updated_at": TimestampField().format(workflow.updated_at or workflow.created_at),
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/config")
|
||||
class SnippetDraftConfigApi(Resource):
|
||||
@console_ns.doc("get_snippet_draft_config")
|
||||
@console_ns.response(200, "Draft config retrieved successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get snippet draft workflow configuration limits."""
|
||||
return {
|
||||
"parallel_depth_limit": 3,
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/publish")
|
||||
class SnippetPublishedWorkflowApi(Resource):
|
||||
@console_ns.doc("get_snippet_published_workflow")
|
||||
@console_ns.response(200, "Published workflow retrieved successfully", console_ns.models[SnippetWorkflowResponse.__name__])
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get published workflow for snippet."""
|
||||
if not snippet.is_published:
|
||||
return None
|
||||
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_published_workflow(snippet=snippet)
|
||||
|
||||
if not workflow:
|
||||
return None
|
||||
|
||||
workflow.input_fields = snippet.input_fields_list
|
||||
return SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
@console_ns.doc("publish_snippet_workflow")
|
||||
@console_ns.expect(console_ns.models.get(PublishWorkflowPayload.__name__))
|
||||
@console_ns.response(200, "Workflow published successfully")
|
||||
@console_ns.response(400, "No draft workflow found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet):
|
||||
"""Publish snippet workflow."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
snippet_service = SnippetService()
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
try:
|
||||
workflow = snippet_service.publish_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account=current_user,
|
||||
)
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
session.commit()
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/default-workflow-block-configs")
|
||||
class SnippetDefaultBlockConfigsApi(Resource):
|
||||
@console_ns.doc("get_snippet_default_block_configs")
|
||||
@console_ns.response(200, "Default block configs retrieved successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get default block configurations for snippet workflow."""
|
||||
snippet_service = SnippetService()
|
||||
return snippet_service.get_default_block_configs()
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows")
|
||||
class SnippetPublishedAllWorkflowApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SnippetWorkflowListQuery.__name__])
|
||||
@console_ns.doc("get_all_snippet_published_workflows")
|
||||
@console_ns.doc(description="Get all published workflows for a snippet")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID"})
|
||||
@console_ns.response(200, "Published workflows retrieved successfully", console_ns.models[WorkflowPaginationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get all published workflow versions for snippet."""
|
||||
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
snippet_service = SnippetService()
|
||||
with Session(db.engine) as session:
|
||||
workflows, has_more = snippet_service.get_all_published_workflows(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
return WorkflowPaginationResponse.model_validate(
|
||||
{
|
||||
"items": workflows,
|
||||
"page": args.page,
|
||||
"limit": args.limit,
|
||||
"has_more": has_more,
|
||||
},
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>/restore")
|
||||
class SnippetDraftWorkflowRestoreApi(Resource):
|
||||
@console_ns.doc("restore_snippet_workflow_to_draft")
|
||||
@console_ns.doc(description="Restore a published snippet workflow version into the draft workflow")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Published workflow ID"})
|
||||
@console_ns.response(200, "Workflow restored successfully")
|
||||
@console_ns.response(400, "Source workflow must be published")
|
||||
@console_ns.response(404, "Workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, workflow_id: str):
|
||||
"""Restore a published snippet workflow version into the draft workflow."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
snippet_service = SnippetService()
|
||||
|
||||
try:
|
||||
workflow = snippet_service.restore_published_workflow_to_draft(
|
||||
snippet=snippet,
|
||||
workflow_id=workflow_id,
|
||||
account=current_user,
|
||||
)
|
||||
except IsDraftWorkflowError as exc:
|
||||
raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc
|
||||
except WorkflowNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"hash": workflow.unique_hash,
|
||||
"updated_at": TimestampField().format(workflow.updated_at or workflow.created_at),
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
|
||||
class SnippetWorkflowRunsApi(Resource):
|
||||
@console_ns.doc("list_snippet_workflow_runs")
|
||||
@console_ns.response(200, "Workflow runs retrieved successfully", console_ns.models[WorkflowRunPaginationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""List workflow runs for snippet."""
|
||||
query = WorkflowRunQuery.model_validate(
|
||||
{
|
||||
"last_id": request.args.get("last_id"),
|
||||
"limit": request.args.get("limit", type=int, default=20),
|
||||
}
|
||||
)
|
||||
args = {
|
||||
"last_id": query.last_id,
|
||||
"limit": query.limit,
|
||||
}
|
||||
|
||||
snippet_service = SnippetService()
|
||||
result = snippet_service.get_snippet_workflow_runs(snippet=snippet, args=args)
|
||||
|
||||
return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/<uuid:run_id>")
|
||||
class SnippetWorkflowRunDetailApi(Resource):
|
||||
@console_ns.doc("get_snippet_workflow_run_detail")
|
||||
@console_ns.response(200, "Workflow run detail retrieved successfully", console_ns.models[WorkflowRunDetailResponse.__name__])
|
||||
@console_ns.response(404, "Workflow run not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
def get(self, snippet: CustomizedSnippet, run_id):
|
||||
"""Get workflow run detail for snippet."""
|
||||
run_id = str(run_id)
|
||||
|
||||
snippet_service = SnippetService()
|
||||
workflow_run = snippet_service.get_snippet_workflow_run(snippet=snippet, run_id=run_id)
|
||||
|
||||
if not workflow_run:
|
||||
raise NotFound("Workflow run not found")
|
||||
|
||||
return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/<uuid:run_id>/node-executions")
|
||||
class SnippetWorkflowRunNodeExecutionsApi(Resource):
|
||||
@console_ns.doc("list_snippet_workflow_run_node_executions")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Node executions retrieved successfully",
|
||||
console_ns.models[WorkflowRunNodeExecutionListResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
def get(self, snippet: CustomizedSnippet, run_id):
|
||||
"""List node executions for a workflow run."""
|
||||
run_id = str(run_id)
|
||||
|
||||
snippet_service = SnippetService()
|
||||
node_executions = snippet_service.get_snippet_workflow_run_node_executions(
|
||||
snippet=snippet,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
return WorkflowRunNodeExecutionListResponse.model_validate(
|
||||
{"data": node_executions}, from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/run")
|
||||
class SnippetDraftNodeRunApi(Resource):
|
||||
@console_ns.doc("run_snippet_draft_node")
|
||||
@console_ns.doc(description="Run a single node in snippet draft workflow (single-step debugging)")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models.get(SnippetDraftNodeRunPayload.__name__))
|
||||
@console_ns.response(
|
||||
200, "Node run completed successfully", console_ns.models[WorkflowRunNodeExecutionResponse.__name__]
|
||||
)
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a single node in snippet draft workflow.
|
||||
|
||||
Executes a specific node with provided inputs for single-step debugging.
|
||||
Returns the node execution result including status, outputs, and timing.
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = SnippetDraftNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
user_inputs = payload.inputs
|
||||
|
||||
# Get draft workflow for file parsing
|
||||
snippet_service = SnippetService()
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not draft_workflow:
|
||||
raise NotFound("Draft workflow not found")
|
||||
|
||||
files = SnippetGenerateService.parse_files(draft_workflow, payload.files)
|
||||
|
||||
workflow_node_execution = SnippetGenerateService.run_draft_node(
|
||||
snippet=snippet,
|
||||
node_id=node_id,
|
||||
user_inputs=user_inputs,
|
||||
account=current_user,
|
||||
query=payload.query,
|
||||
files=files,
|
||||
)
|
||||
|
||||
return WorkflowRunNodeExecutionResponse.model_validate(
|
||||
workflow_node_execution, from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/last-run")
|
||||
class SnippetDraftNodeLastRunApi(Resource):
|
||||
@console_ns.doc("get_snippet_draft_node_last_run")
|
||||
@console_ns.doc(description="Get last run result for a node in snippet draft workflow")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
|
||||
@console_ns.response(
|
||||
200, "Node last run retrieved successfully", console_ns.models[WorkflowRunNodeExecutionResponse.__name__]
|
||||
)
|
||||
@console_ns.response(404, "Snippet, draft workflow, or node last run not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
def get(self, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Get the last run result for a specific node in snippet draft workflow.
|
||||
|
||||
Returns the most recent execution record for the given node,
|
||||
including status, inputs, outputs, and timing information.
|
||||
"""
|
||||
snippet_service = SnippetService()
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not draft_workflow:
|
||||
raise NotFound("Draft workflow not found")
|
||||
|
||||
node_exec = snippet_service.get_snippet_node_last_run(
|
||||
snippet=snippet,
|
||||
workflow=draft_workflow,
|
||||
node_id=node_id,
|
||||
)
|
||||
if node_exec is None:
|
||||
raise NotFound("Node last run not found")
|
||||
|
||||
return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/iteration/nodes/<string:node_id>/run")
|
||||
class SnippetDraftRunIterationNodeApi(Resource):
|
||||
@console_ns.doc("run_snippet_draft_iteration_node")
|
||||
@console_ns.doc(description="Run draft workflow iteration node for snippet")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models.get(SnippetIterationNodeRunPayload.__name__))
|
||||
@console_ns.response(200, "Iteration node run started successfully (SSE stream)")
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a draft workflow iteration node for snippet.
|
||||
|
||||
Iteration nodes execute their internal sub-graph multiple times over an input list.
|
||||
Returns an SSE event stream with iteration progress and results.
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = SnippetIterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
response = SnippetGenerateService.generate_single_iteration(
|
||||
snippet=snippet, user=current_user, node_id=node_id, args=args, streaming=True
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/loop/nodes/<string:node_id>/run")
|
||||
class SnippetDraftRunLoopNodeApi(Resource):
|
||||
@console_ns.doc("run_snippet_draft_loop_node")
|
||||
@console_ns.doc(description="Run draft workflow loop node for snippet")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models.get(SnippetLoopNodeRunPayload.__name__))
|
||||
@console_ns.response(200, "Loop node run started successfully (SSE stream)")
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a draft workflow loop node for snippet.
|
||||
|
||||
Loop nodes execute their internal sub-graph repeatedly until a condition is met.
|
||||
Returns an SSE event stream with loop progress and results.
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = SnippetLoopNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
response = SnippetGenerateService.generate_single_loop(
|
||||
snippet=snippet, user=current_user, node_id=node_id, args=args, streaming=True
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/run")
|
||||
class SnippetDraftWorkflowRunApi(Resource):
|
||||
@console_ns.doc("run_snippet_draft_workflow")
|
||||
@console_ns.expect(console_ns.models.get(SnippetDraftRunPayload.__name__))
|
||||
@console_ns.response(200, "Draft workflow run started successfully (SSE stream)")
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet):
|
||||
"""
|
||||
Run draft workflow for snippet.
|
||||
|
||||
Executes the snippet's draft workflow with the provided inputs
|
||||
and returns an SSE event stream with execution progress and results.
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
payload = SnippetDraftRunPayload.model_validate(console_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
response = SnippetGenerateService.generate(
|
||||
snippet=snippet,
|
||||
user=current_user,
|
||||
args=args,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/tasks/<string:task_id>/stop")
|
||||
class SnippetWorkflowTaskStopApi(Resource):
|
||||
@console_ns.doc("stop_snippet_workflow_task")
|
||||
@console_ns.response(200, "Task stopped successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, task_id: str):
|
||||
"""
|
||||
Stop a running snippet workflow task.
|
||||
|
||||
Uses both the legacy stop flag mechanism and the graph engine
|
||||
command channel for backward compatibility.
|
||||
"""
|
||||
# Stop using both mechanisms for backward compatibility
|
||||
# Legacy stop flag mechanism (without user check)
|
||||
AppQueueManager.set_stop_flag_no_user_check(task_id)
|
||||
|
||||
# New graph engine command channel mechanism
|
||||
GraphEngineManager(redis_client).send_stop_command(task_id)
|
||||
|
||||
return {"result": "success"}
|
||||
@ -0,0 +1,319 @@
|
||||
"""
|
||||
Snippet draft workflow variable APIs.
|
||||
|
||||
Mirrors console app routes under /apps/.../workflows/draft/variables for snippet scope,
|
||||
using CustomizedSnippet.id as WorkflowDraftVariable.app_id (same invariant as snippet execution).
|
||||
|
||||
Snippet workflows do not expose system variables (`node_id == sys`) or conversation variables
|
||||
(`node_id == conversation`): paginated list queries exclude those rows; single-variable GET/PATCH/DELETE/reset
|
||||
reject them; `GET .../system-variables` and `GET .../conversation-variables` return empty lists for API parity.
|
||||
Other routes mirror `workflow_draft_variable` app APIs under `/snippets/...`.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource, marshal, marshal_with
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import DraftWorkflowNotExist
|
||||
from controllers.console.app.workflow_draft_variable import (
|
||||
WorkflowDraftVariableListQuery,
|
||||
WorkflowDraftVariableUpdatePayload,
|
||||
_ensure_variable_access,
|
||||
_file_access_controller,
|
||||
validate_node_id,
|
||||
workflow_draft_variable_list_model,
|
||||
workflow_draft_variable_list_without_value_model,
|
||||
workflow_draft_variable_model,
|
||||
)
|
||||
from controllers.console.snippets.snippet_workflow import get_snippet
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from controllers.web.error import InvalidArgumentError, NotFoundError
|
||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
from extensions.ext_database import db
|
||||
from factories.file_factory import build_from_mapping, build_from_mappings
|
||||
from factories.variable_factory import build_segment_with_type
|
||||
from graphon.variables.types import SegmentType
|
||||
from libs.login import current_user, login_required
|
||||
from models.snippet import CustomizedSnippet
|
||||
from models.workflow import WorkflowDraftVariable
|
||||
from services.snippet_service import SnippetService
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: frozenset[str] = frozenset(
|
||||
{SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
|
||||
)
|
||||
|
||||
|
||||
def _ensure_snippet_draft_variable_row_allowed(
|
||||
*,
|
||||
variable: WorkflowDraftVariable,
|
||||
variable_id: str,
|
||||
) -> None:
|
||||
"""Snippet scope only supports canvas-node draft variables; treat sys/conversation rows as not found."""
|
||||
if variable.node_id in _SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id}")
|
||||
|
||||
|
||||
def _snippet_draft_var_prerequisite(f: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Setup, auth, snippet resolution, and tenant edit permission (same stack as snippet workflow APIs)."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@wraps(f)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/variables")
|
||||
class SnippetWorkflowVariableCollectionApi(Resource):
|
||||
@console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__])
|
||||
@console_ns.doc("get_snippet_workflow_variables")
|
||||
@console_ns.doc(description="List draft workflow variables without values (paginated, snippet scope)")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow variables retrieved successfully",
|
||||
workflow_draft_variable_list_without_value_model,
|
||||
)
|
||||
@_snippet_draft_var_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_without_value_model)
|
||||
def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
|
||||
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
snippet_service = SnippetService()
|
||||
if snippet_service.get_draft_workflow(snippet=snippet) is None:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=session)
|
||||
workflow_vars = draft_var_srv.list_variables_without_values(
|
||||
app_id=snippet.id,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
user_id=current_user.id,
|
||||
exclude_node_ids=_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS,
|
||||
)
|
||||
|
||||
return workflow_vars
|
||||
|
||||
@console_ns.doc("delete_snippet_workflow_variables")
|
||||
@console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)")
|
||||
@console_ns.response(204, "Workflow variables deleted successfully")
|
||||
@_snippet_draft_var_prerequisite
|
||||
def delete(self, snippet: CustomizedSnippet) -> Response:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=db.session())
|
||||
draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id)
|
||||
db.session.commit()
|
||||
return Response("", 204)
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/variables")
|
||||
class SnippetNodeVariableCollectionApi(Resource):
|
||||
@console_ns.doc("get_snippet_node_variables")
|
||||
@console_ns.doc(description="Get variables for a specific node (snippet draft workflow)")
|
||||
@console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model)
|
||||
@_snippet_draft_var_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_model)
|
||||
def get(self, snippet: CustomizedSnippet, node_id: str) -> WorkflowDraftVariableList:
|
||||
validate_node_id(node_id)
|
||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=session)
|
||||
node_vars = draft_var_srv.list_node_variables(snippet.id, node_id, user_id=current_user.id)
|
||||
|
||||
return node_vars
|
||||
|
||||
@console_ns.doc("delete_snippet_node_variables")
|
||||
@console_ns.doc(description="Delete all variables for a specific node (snippet draft workflow)")
|
||||
@console_ns.response(204, "Node variables deleted successfully")
|
||||
@_snippet_draft_var_prerequisite
|
||||
def delete(self, snippet: CustomizedSnippet, node_id: str) -> Response:
|
||||
validate_node_id(node_id)
|
||||
srv = WorkflowDraftVariableService(db.session())
|
||||
srv.delete_node_variables(snippet.id, node_id, user_id=current_user.id)
|
||||
db.session.commit()
|
||||
return Response("", 204)
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/variables/<uuid:variable_id>")
|
||||
class SnippetVariableApi(Resource):
|
||||
@console_ns.doc("get_snippet_workflow_variable")
|
||||
@console_ns.doc(description="Get a specific draft workflow variable (snippet scope)")
|
||||
@console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model)
|
||||
@console_ns.response(404, "Variable not found")
|
||||
@_snippet_draft_var_prerequisite
|
||||
@marshal_with(workflow_draft_variable_model)
|
||||
def get(self, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=db.session())
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id),
|
||||
app_id=snippet.id,
|
||||
variable_id=variable_id,
|
||||
)
|
||||
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
|
||||
return variable
|
||||
|
||||
@console_ns.doc("update_snippet_workflow_variable")
|
||||
@console_ns.doc(description="Update a draft workflow variable (snippet scope)")
|
||||
@console_ns.expect(console_ns.models[WorkflowDraftVariableUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model)
|
||||
@console_ns.response(404, "Variable not found")
|
||||
@_snippet_draft_var_prerequisite
|
||||
@marshal_with(workflow_draft_variable_model)
|
||||
def patch(self, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=db.session())
|
||||
args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id),
|
||||
app_id=snippet.id,
|
||||
variable_id=variable_id,
|
||||
)
|
||||
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
|
||||
|
||||
new_name = args_model.name
|
||||
raw_value = args_model.value
|
||||
if new_name is None and raw_value is None:
|
||||
return variable
|
||||
|
||||
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=snippet.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=snippet.tenant_id,
|
||||
access_controller=_file_access_controller,
|
||||
)
|
||||
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()
|
||||
return variable
|
||||
|
||||
@console_ns.doc("delete_snippet_workflow_variable")
|
||||
@console_ns.doc(description="Delete a draft workflow variable (snippet scope)")
|
||||
@console_ns.response(204, "Variable deleted successfully")
|
||||
@console_ns.response(404, "Variable not found")
|
||||
@_snippet_draft_var_prerequisite
|
||||
def delete(self, snippet: CustomizedSnippet, variable_id: str) -> Response:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=db.session())
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id),
|
||||
app_id=snippet.id,
|
||||
variable_id=variable_id,
|
||||
)
|
||||
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
|
||||
draft_var_srv.delete_variable(variable)
|
||||
db.session.commit()
|
||||
return Response("", 204)
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/variables/<uuid:variable_id>/reset")
|
||||
class SnippetVariableResetApi(Resource):
|
||||
@console_ns.doc("reset_snippet_workflow_variable")
|
||||
@console_ns.doc(description="Reset a draft workflow variable to its default value (snippet scope)")
|
||||
@console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model)
|
||||
@console_ns.response(204, "Variable reset (no content)")
|
||||
@console_ns.response(404, "Variable not found")
|
||||
@_snippet_draft_var_prerequisite
|
||||
def put(self, snippet: CustomizedSnippet, variable_id: str) -> Response | Any:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=db.session())
|
||||
snippet_service = SnippetService()
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if draft_workflow is None:
|
||||
raise NotFoundError(
|
||||
f"Draft workflow not found, snippet_id={snippet.id}",
|
||||
)
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id),
|
||||
app_id=snippet.id,
|
||||
variable_id=variable_id,
|
||||
)
|
||||
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
|
||||
|
||||
resetted = draft_var_srv.reset_variable(draft_workflow, variable)
|
||||
db.session.commit()
|
||||
if resetted is None:
|
||||
return Response("", 204)
|
||||
return marshal(resetted, workflow_draft_variable_model)
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/conversation-variables")
|
||||
class SnippetConversationVariableCollectionApi(Resource):
|
||||
@console_ns.doc("get_snippet_conversation_variables")
|
||||
@console_ns.doc(
|
||||
description="Conversation variables are not used in snippet workflows; returns an empty list for API parity"
|
||||
)
|
||||
@console_ns.response(200, "Conversation variables retrieved successfully", workflow_draft_variable_list_model)
|
||||
@_snippet_draft_var_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_model)
|
||||
def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
|
||||
return WorkflowDraftVariableList(variables=[])
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/system-variables")
|
||||
class SnippetSystemVariableCollectionApi(Resource):
|
||||
@console_ns.doc("get_snippet_system_variables")
|
||||
@console_ns.doc(
|
||||
description="System variables are not used in snippet workflows; returns an empty list for API parity"
|
||||
)
|
||||
@console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model)
|
||||
@_snippet_draft_var_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_model)
|
||||
def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
|
||||
return WorkflowDraftVariableList(variables=[])
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/environment-variables")
|
||||
class SnippetEnvironmentVariableCollectionApi(Resource):
|
||||
@console_ns.doc("get_snippet_environment_variables")
|
||||
@console_ns.doc(description="Get environment variables from snippet draft workflow graph")
|
||||
@console_ns.response(200, "Environment variables retrieved successfully")
|
||||
@console_ns.response(404, "Draft workflow not found")
|
||||
@_snippet_draft_var_prerequisite
|
||||
def get(self, snippet: CustomizedSnippet) -> dict[str, list[dict[str, Any]]]:
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if workflow is None:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
env_vars_list: list[dict[str, Any]] = []
|
||||
for v in workflow.environment_variables:
|
||||
env_vars_list.append(
|
||||
{
|
||||
"id": v.id,
|
||||
"type": "env",
|
||||
"name": v.name,
|
||||
"description": v.description,
|
||||
"selector": v.selector,
|
||||
"value_type": v.value_type.exposed_type().value,
|
||||
"value": v.value,
|
||||
"edited": False,
|
||||
"visible": True,
|
||||
"editable": True,
|
||||
}
|
||||
)
|
||||
|
||||
return {"items": env_vars_list}
|
||||
@ -51,7 +51,7 @@ class TagBindingRemovePayload(BaseModel):
|
||||
|
||||
|
||||
class TagListQueryParam(BaseModel):
|
||||
type: Literal["knowledge", "app", ""] = Field("", description="Tag type filter")
|
||||
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
|
||||
keyword: str | None = Field(None, description="Search keyword")
|
||||
|
||||
|
||||
@ -96,7 +96,10 @@ class TagListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc(
|
||||
params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."}
|
||||
params={
|
||||
"type": 'Tag type filter. Can be "knowledge", "app", or "snippet".',
|
||||
"keyword": "Search keyword for tag name.",
|
||||
}
|
||||
)
|
||||
@console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])})
|
||||
@with_current_tenant_id
|
||||
|
||||
414
api/controllers/console/workspace/snippets.py
Normal file
414
api/controllers/console/workspace/snippets.py
Normal file
@ -0,0 +1,414 @@
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource, marshal
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.datastructures import MultiDict
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.snippets.payloads import (
|
||||
CreateSnippetPayload,
|
||||
IncludeSecretQuery,
|
||||
SnippetImportPayload,
|
||||
SnippetListQuery,
|
||||
UpdateSnippetPayload,
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.snippet_fields import snippet_fields, snippet_list_fields, snippet_pagination_fields
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.snippet import SnippetType
|
||||
from services.app_dsl_service import ImportStatus
|
||||
from services.snippet_dsl_service import SnippetDslService
|
||||
from services.snippet_service import SnippetService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
|
||||
_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
|
||||
|
||||
|
||||
def _normalize_snippet_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
|
||||
normalized: dict[str, str | list[str]] = {}
|
||||
indexed_tag_ids: list[tuple[int, str]] = []
|
||||
indexed_creator_ids: list[tuple[int, str]] = []
|
||||
|
||||
for key in query_args:
|
||||
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
|
||||
if match:
|
||||
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
|
||||
continue
|
||||
|
||||
match = _CREATOR_IDS_BRACKET_PATTERN.fullmatch(key)
|
||||
if match:
|
||||
indexed_creator_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
|
||||
continue
|
||||
|
||||
value = query_args.get(key)
|
||||
if value is not None:
|
||||
normalized[key] = value
|
||||
|
||||
if indexed_tag_ids:
|
||||
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
|
||||
if indexed_creator_ids:
|
||||
normalized["creators"] = [value for _, value in sorted(indexed_creator_ids)]
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
# Register Pydantic models with Swagger
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
SnippetListQuery,
|
||||
CreateSnippetPayload,
|
||||
UpdateSnippetPayload,
|
||||
SnippetImportPayload,
|
||||
IncludeSecretQuery,
|
||||
)
|
||||
|
||||
# Create namespace models for marshaling
|
||||
snippet_model = console_ns.model("Snippet", snippet_fields)
|
||||
snippet_list_model = console_ns.model("SnippetList", snippet_list_fields)
|
||||
snippet_pagination_model = console_ns.model("SnippetPagination", snippet_pagination_fields)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets")
|
||||
class CustomizedSnippetsApi(Resource):
|
||||
@console_ns.doc("list_customized_snippets")
|
||||
@console_ns.expect(console_ns.models.get(SnippetListQuery.__name__))
|
||||
@console_ns.response(200, "Snippets retrieved successfully", snippet_pagination_model)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
"""List customized snippets with pagination and search."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
query = SnippetListQuery.model_validate(_normalize_snippet_list_query_args(request.args))
|
||||
|
||||
snippets, total, has_more = SnippetService.get_snippets(
|
||||
tenant_id=current_tenant_id,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword,
|
||||
is_published=query.is_published,
|
||||
creators=query.creators,
|
||||
tag_ids=query.tag_ids,
|
||||
)
|
||||
|
||||
return {
|
||||
"data": marshal(snippets, snippet_list_fields),
|
||||
"page": query.page,
|
||||
"limit": query.limit,
|
||||
"total": total,
|
||||
"has_more": has_more,
|
||||
}, 200
|
||||
|
||||
@console_ns.doc("create_customized_snippet")
|
||||
@console_ns.expect(console_ns.models.get(CreateSnippetPayload.__name__))
|
||||
@console_ns.response(201, "Snippet created successfully", snippet_model)
|
||||
@console_ns.response(400, "Invalid request")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def post(self):
|
||||
"""Create a new customized snippet."""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
payload = CreateSnippetPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
snippet_type = SnippetType(payload.type)
|
||||
except ValueError:
|
||||
snippet_type = SnippetType.NODE
|
||||
|
||||
try:
|
||||
if payload.graph is not None:
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(payload.graph)
|
||||
|
||||
snippet = SnippetService.create_snippet(
|
||||
tenant_id=current_tenant_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
snippet_type=snippet_type,
|
||||
icon_info=payload.icon_info.model_dump() if payload.icon_info else None,
|
||||
input_fields=[f.model_dump() for f in payload.input_fields] if payload.input_fields else None,
|
||||
account=current_user,
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
return marshal(snippet, snippet_fields), 201
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>")
|
||||
class CustomizedSnippetDetailApi(Resource):
|
||||
@console_ns.doc("get_customized_snippet")
|
||||
@console_ns.response(200, "Snippet retrieved successfully", snippet_model)
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, snippet_id: str):
|
||||
"""Get customized snippet details."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet = SnippetService.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
return marshal(snippet, snippet_fields), 200
|
||||
|
||||
@console_ns.doc("update_customized_snippet")
|
||||
@console_ns.expect(console_ns.models.get(UpdateSnippetPayload.__name__))
|
||||
@console_ns.response(200, "Snippet updated successfully", snippet_model)
|
||||
@console_ns.response(400, "Invalid request")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def patch(self, snippet_id: str):
|
||||
"""Update customized snippet."""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet = SnippetService.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
payload = UpdateSnippetPayload.model_validate(console_ns.payload or {})
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "icon_info" in update_data and update_data["icon_info"] is not None:
|
||||
update_data["icon_info"] = payload.icon_info.model_dump() if payload.icon_info else None
|
||||
|
||||
if not update_data:
|
||||
return {"message": "No valid fields to update"}, 400
|
||||
|
||||
try:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
snippet = session.merge(snippet)
|
||||
snippet = SnippetService.update_snippet(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account_id=current_user.id,
|
||||
data=update_data,
|
||||
)
|
||||
session.commit()
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
return marshal(snippet, snippet_fields), 200
|
||||
|
||||
@console_ns.doc("delete_customized_snippet")
|
||||
@console_ns.response(204, "Snippet deleted successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def delete(self, snippet_id: str):
|
||||
"""Delete customized snippet."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet = SnippetService.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
SnippetService.delete_snippet(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/export")
|
||||
class CustomizedSnippetExportApi(Resource):
|
||||
@console_ns.doc("export_customized_snippet")
|
||||
@console_ns.doc(description="Export snippet configuration as DSL")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
|
||||
@console_ns.response(200, "Snippet exported successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def get(self, snippet_id: str):
|
||||
"""Export snippet as DSL."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet = SnippetService.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
# Get include_secret parameter
|
||||
query = IncludeSecretQuery.model_validate(request.args.to_dict())
|
||||
|
||||
with Session(db.engine) as session:
|
||||
export_service = SnippetDslService(session)
|
||||
result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true")
|
||||
|
||||
# Set filename with .snippet extension
|
||||
filename = f"{snippet.name}.snippet"
|
||||
encoded_filename = quote(filename)
|
||||
response = Response(
|
||||
result,
|
||||
mimetype="application/x-yaml",
|
||||
)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
response.headers["Content-Type"] = "application/x-yaml"
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/imports")
|
||||
class CustomizedSnippetImportApi(Resource):
|
||||
@console_ns.doc("import_customized_snippet")
|
||||
@console_ns.doc(description="Import snippet from DSL")
|
||||
@console_ns.expect(console_ns.models.get(SnippetImportPayload.__name__))
|
||||
@console_ns.response(200, "Snippet imported successfully")
|
||||
@console_ns.response(202, "Import pending confirmation")
|
||||
@console_ns.response(400, "Import failed")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def post(self):
|
||||
"""Import snippet from DSL."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = SnippetImportPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
with Session(db.engine) as session:
|
||||
import_service = SnippetDslService(session)
|
||||
result = import_service.import_snippet(
|
||||
account=current_user,
|
||||
import_mode=payload.mode,
|
||||
yaml_content=payload.yaml_content,
|
||||
yaml_url=payload.yaml_url,
|
||||
snippet_id=payload.snippet_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/imports/<string:import_id>/confirm")
|
||||
class CustomizedSnippetImportConfirmApi(Resource):
|
||||
@console_ns.doc("confirm_snippet_import")
|
||||
@console_ns.doc(description="Confirm a pending snippet import")
|
||||
@console_ns.doc(params={"import_id": "Import ID to confirm"})
|
||||
@console_ns.response(200, "Import confirmed successfully")
|
||||
@console_ns.response(400, "Import failed")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def post(self, import_id: str):
|
||||
"""Confirm a pending snippet import."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
with Session(db.engine) as session:
|
||||
import_service = SnippetDslService(session)
|
||||
result = import_service.confirm_import(import_id=import_id, account=current_user)
|
||||
session.commit()
|
||||
|
||||
if result.status == ImportStatus.FAILED:
|
||||
return result.model_dump(mode="json"), 400
|
||||
return result.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/check-dependencies")
|
||||
class CustomizedSnippetCheckDependenciesApi(Resource):
|
||||
@console_ns.doc("check_snippet_dependencies")
|
||||
@console_ns.doc(description="Check dependencies for a snippet")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID"})
|
||||
@console_ns.response(200, "Dependencies checked successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def get(self, snippet_id: str):
|
||||
"""Check dependencies for a snippet."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet = SnippetService.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
import_service = SnippetDslService(session)
|
||||
result = import_service.check_dependencies(snippet=snippet)
|
||||
|
||||
return result.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/use-count/increment")
|
||||
class CustomizedSnippetUseCountIncrementApi(Resource):
|
||||
@console_ns.doc("increment_snippet_use_count")
|
||||
@console_ns.doc(description="Increment snippet use count by 1")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID"})
|
||||
@console_ns.response(200, "Use count incremented successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def post(self, snippet_id: str):
|
||||
"""Increment snippet use count when it is inserted into a workflow."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet = SnippetService.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
SnippetService.increment_use_count(session=session, snippet=snippet)
|
||||
session.commit()
|
||||
session.refresh(snippet)
|
||||
|
||||
return {"result": "success", "use_count": snippet.use_count}, 200
|
||||
@ -49,8 +49,8 @@ from extensions.ext_redis import redis_client
|
||||
from libs.helper import extract_remote_ip
|
||||
from libs.oauth_bearer import MINTABLE_PROFILES, SubjectType, bearer_feature_required
|
||||
from libs.rate_limit import (
|
||||
LIMIT_APPROVE_CONSOLE,
|
||||
LIMIT_DEVICE_CODE_PER_IP,
|
||||
LIMIT_DEVICE_FLOW_APPROVE,
|
||||
LIMIT_LOOKUP_PUBLIC,
|
||||
rate_limit,
|
||||
)
|
||||
@ -210,7 +210,7 @@ class DeviceApproveApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@bearer_feature_required
|
||||
@rate_limit(LIMIT_APPROVE_CONSOLE)
|
||||
@rate_limit(LIMIT_DEVICE_FLOW_APPROVE)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant: str, account: Account):
|
||||
@ -287,7 +287,7 @@ class DeviceDenyApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@bearer_feature_required
|
||||
@rate_limit(LIMIT_APPROVE_CONSOLE)
|
||||
@rate_limit(LIMIT_DEVICE_FLOW_APPROVE)
|
||||
def post(self):
|
||||
payload = _validate_json(DeviceMutateRequest)
|
||||
user_code = payload.user_code.strip().upper()
|
||||
|
||||
@ -4,7 +4,7 @@ from typing import Any, cast
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
from werkzeug.exceptions import BadRequest, Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.common import fields
|
||||
@ -58,6 +58,9 @@ class AppParameterApi(WebApiResource):
|
||||
)
|
||||
def get(self, app_model: App, end_user: EndUser):
|
||||
"""Retrieve app parameters."""
|
||||
if not app_model.enable_site:
|
||||
raise BadRequest("Site is disabled.")
|
||||
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
|
||||
@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
from flask import Flask, current_app
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import contexts
|
||||
from configs import dify_config
|
||||
@ -68,6 +68,25 @@ def _extract_trace_session_id_from_debug_args(args: Mapping[str, Any] | Any) ->
|
||||
|
||||
|
||||
class WorkflowAppGenerator(BaseAppGenerator):
|
||||
@staticmethod
|
||||
def _ensure_snippet_start_node_in_worker(*, session: Session, workflow: Workflow) -> Workflow:
|
||||
"""Re-apply snippet virtual Start injection after worker reloads workflow from DB."""
|
||||
if workflow.kind_or_standard != "snippet":
|
||||
return workflow
|
||||
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
|
||||
snippet = session.scalar(
|
||||
select(CustomizedSnippet).where(
|
||||
CustomizedSnippet.id == workflow.app_id,
|
||||
CustomizedSnippet.tenant_id == workflow.tenant_id,
|
||||
)
|
||||
)
|
||||
if snippet is None:
|
||||
return workflow
|
||||
return SnippetGenerateService.ensure_start_node_for_worker(workflow, snippet)
|
||||
|
||||
@staticmethod
|
||||
def _should_prepare_user_inputs(args: Mapping[str, Any]) -> bool:
|
||||
return not bool(args.get(SKIP_PREPARE_USER_INPUTS_KEY))
|
||||
@ -592,6 +611,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
if workflow is None:
|
||||
raise ValueError("Workflow not found")
|
||||
|
||||
workflow = self._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
|
||||
|
||||
# Determine system_user_id based on invocation source
|
||||
is_external_api_call = application_generate_entity.invoke_from in {
|
||||
InvokeFrom.WEB_APP,
|
||||
|
||||
@ -11,6 +11,7 @@ from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, Workfl
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
from core.workflow.node_factory import get_default_root_node_id
|
||||
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_layer
|
||||
from core.workflow.snippet_start import get_compatible_start_aliases
|
||||
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
|
||||
@ -118,7 +119,15 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
|
||||
),
|
||||
)
|
||||
root_node_id = self._root_node_id or get_default_root_node_id(self._workflow.graph_dict)
|
||||
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs)
|
||||
add_node_inputs_to_pool(
|
||||
variable_pool,
|
||||
node_id=root_node_id,
|
||||
inputs=inputs,
|
||||
aliases=get_compatible_start_aliases(
|
||||
workflow_kind=getattr(self._workflow, "kind_or_standard", None),
|
||||
root_node_id=root_node_id,
|
||||
),
|
||||
)
|
||||
|
||||
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
|
||||
graph = self._init_graph(
|
||||
|
||||
@ -392,7 +392,7 @@ Here is the extra instruction you need to follow:
|
||||
else:
|
||||
if len(messages[-1]) + len(line) < max_tokens * 0.5:
|
||||
messages[-1] += line
|
||||
if get_prompt_tokens(messages[-1] + line) > max_tokens * 0.7:
|
||||
elif get_prompt_tokens(messages[-1] + line) > max_tokens * 0.7:
|
||||
messages.append(line)
|
||||
else:
|
||||
messages[-1] += line
|
||||
|
||||
@ -135,7 +135,7 @@ class BuiltinTool(Tool):
|
||||
else:
|
||||
if len(messages[-1]) + len(j) < max_tokens * 0.5:
|
||||
messages[-1] += j
|
||||
if get_prompt_tokens(messages[-1] + j) > max_tokens * 0.7:
|
||||
elif get_prompt_tokens(messages[-1] + j) > max_tokens * 0.7:
|
||||
messages.append(j)
|
||||
else:
|
||||
messages[-1] += j
|
||||
|
||||
21
api/core/workflow/snippet_start.py
Normal file
21
api/core/workflow/snippet_start.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Shared snippet virtual Start-node identifiers and compatibility helpers.
|
||||
|
||||
Snippet workflows do not persist a real canvas Start node, so the backend
|
||||
injects one at runtime. Existing workflow references commonly use the public
|
||||
selector shape ``#start.<var>#``; keep that contract stable by treating the
|
||||
runtime-only snippet Start node as compatible with the legacy ``start`` id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
LEGACY_START_NODE_ID = "start"
|
||||
SNIPPET_VIRTUAL_START_NODE_ID = "__snippet_virtual_start__"
|
||||
|
||||
|
||||
def get_compatible_start_aliases(*, workflow_kind: str | None, root_node_id: str | None) -> tuple[str, ...]:
|
||||
"""Return additional selector ids that should mirror snippet Start inputs."""
|
||||
if workflow_kind == "snippet" and root_node_id == SNIPPET_VIRTUAL_START_NODE_ID:
|
||||
return (LEGACY_START_NODE_ID,)
|
||||
|
||||
return ()
|
||||
@ -10,6 +10,19 @@ def add_variables_to_pool(variable_pool: VariablePool, variables: Sequence[Varia
|
||||
variable_pool.add(variable.selector, variable)
|
||||
|
||||
|
||||
def add_node_inputs_to_pool(variable_pool: VariablePool, *, node_id: str, inputs: Mapping[str, Any]) -> None:
|
||||
for key, value in inputs.items():
|
||||
variable_pool.add((node_id, key), value)
|
||||
def add_node_inputs_to_pool(
|
||||
variable_pool: VariablePool,
|
||||
*,
|
||||
node_id: str,
|
||||
inputs: Mapping[str, Any],
|
||||
aliases: Sequence[str] = (),
|
||||
) -> None:
|
||||
"""Store node inputs under the primary node id and any compatible aliases."""
|
||||
node_ids: list[str] = [node_id]
|
||||
for alias in aliases:
|
||||
if alias not in node_ids:
|
||||
node_ids.append(alias)
|
||||
|
||||
for current_node_id in node_ids:
|
||||
for key, value in inputs.items():
|
||||
variable_pool.add((current_node_id, key), value)
|
||||
|
||||
52
api/fields/snippet_fields.py
Normal file
52
api/fields/snippet_fields.py
Normal file
@ -0,0 +1,52 @@
|
||||
from flask_restx import fields
|
||||
|
||||
from fields.member_fields import simple_account_fields
|
||||
from libs.helper import TimestampField
|
||||
|
||||
tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String}
|
||||
|
||||
# Snippet list item fields (lightweight for list display)
|
||||
snippet_list_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"description": fields.String,
|
||||
"type": fields.String,
|
||||
"version": fields.Integer,
|
||||
"use_count": fields.Integer,
|
||||
"is_published": fields.Boolean,
|
||||
"icon_info": fields.Raw,
|
||||
"tags": fields.List(fields.Nested(tag_fields)),
|
||||
"created_by": fields.String,
|
||||
"author_name": fields.String,
|
||||
"created_at": TimestampField,
|
||||
"updated_by": fields.String,
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
|
||||
# Full snippet fields (includes creator info and graph data)
|
||||
snippet_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"description": fields.String,
|
||||
"type": fields.String,
|
||||
"version": fields.Integer,
|
||||
"use_count": fields.Integer,
|
||||
"is_published": fields.Boolean,
|
||||
"icon_info": fields.Raw,
|
||||
"graph": fields.Raw(attribute="graph_dict"),
|
||||
"input_fields": fields.Raw(attribute="input_fields_list"),
|
||||
"tags": fields.List(fields.Nested(tag_fields)),
|
||||
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True),
|
||||
"created_at": TimestampField,
|
||||
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
|
||||
# Pagination response fields
|
||||
snippet_pagination_fields = {
|
||||
"data": fields.List(fields.Nested(snippet_list_fields)),
|
||||
"page": fields.Integer,
|
||||
"limit": fields.Integer,
|
||||
"total": fields.Integer,
|
||||
"has_more": fields.Boolean,
|
||||
}
|
||||
@ -40,7 +40,11 @@ class RateLimit:
|
||||
LIMIT_DEVICE_CODE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,))
|
||||
LIMIT_SSO_INITIATE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,))
|
||||
LIMIT_APPROVE_EXT_PER_EMAIL = RateLimit(10, timedelta(hours=1), (RateLimitScope.SUBJECT_EMAIL,))
|
||||
LIMIT_APPROVE_CONSOLE = RateLimit(10, timedelta(hours=1), (RateLimitScope.SESSION,))
|
||||
LIMIT_DEVICE_FLOW_APPROVE = RateLimit(
|
||||
limit=dify_config.DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR,
|
||||
window=timedelta(hours=1),
|
||||
scopes=(RateLimitScope.SESSION,),
|
||||
)
|
||||
LIMIT_LOOKUP_PUBLIC = RateLimit(60, timedelta(minutes=5), (RateLimitScope.IP,))
|
||||
LIMIT_ME_PER_ACCOUNT = RateLimit(60, timedelta(minutes=1), (RateLimitScope.ACCOUNT,))
|
||||
LIMIT_ME_PER_EMAIL = RateLimit(60, timedelta(minutes=1), (RateLimitScope.SUBJECT_EMAIL,))
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
"""add customized snippets
|
||||
|
||||
Revision ID: 2b3c4d5e6f70
|
||||
Revises: 121e7346074d
|
||||
Create Date: 2026-06-03 11:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models.types
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2b3c4d5e6f70"
|
||||
down_revision = "121e7346074d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_pg() -> bool:
|
||||
return op.get_bind().dialect.name == "postgresql"
|
||||
|
||||
|
||||
def _current_timestamp_default():
|
||||
return sa.text("CURRENT_TIMESTAMP(0)") if _is_pg() else sa.func.current_timestamp()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"customized_snippets",
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", models.types.LongText(), nullable=True),
|
||||
sa.Column("type", sa.String(length=50), server_default=sa.text("'node'"), nullable=False),
|
||||
sa.Column("workflow_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("is_published", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False),
|
||||
sa.Column("use_count", sa.Integer(), server_default=sa.text("0"), nullable=False),
|
||||
sa.Column("icon_info", models.types.AdjustedJSON(), nullable=True),
|
||||
sa.Column("input_fields", models.types.LongText(), nullable=True),
|
||||
sa.Column("created_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=_current_timestamp_default(), nullable=False),
|
||||
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=_current_timestamp_default(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="customized_snippet_pkey"),
|
||||
)
|
||||
|
||||
with op.batch_alter_table("customized_snippets", schema=None) as batch_op:
|
||||
batch_op.create_index("customized_snippet_tenant_idx", ["tenant_id"], unique=False)
|
||||
|
||||
with op.batch_alter_table("workflows", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("kind", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("workflows", schema=None) as batch_op:
|
||||
batch_op.drop_column("kind")
|
||||
|
||||
with op.batch_alter_table("customized_snippets", schema=None) as batch_op:
|
||||
batch_op.drop_index("customized_snippet_tenant_idx")
|
||||
|
||||
op.drop_table("customized_snippets")
|
||||
@ -106,6 +106,7 @@ from .provider import (
|
||||
TenantDefaultModel,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from .snippet import CustomizedSnippet, SnippetType
|
||||
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from .task import CeleryTask, CeleryTaskSet
|
||||
from .tools import (
|
||||
@ -131,12 +132,14 @@ from .workflow import (
|
||||
WorkflowAppLog,
|
||||
WorkflowAppLogCreatedFrom,
|
||||
WorkflowArchiveLog,
|
||||
WorkflowKind,
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowNodeExecutionOffload,
|
||||
WorkflowNodeExecutionTriggeredFrom,
|
||||
WorkflowPause,
|
||||
WorkflowRun,
|
||||
WorkflowType,
|
||||
resolve_workflow_kind,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@ -179,6 +182,7 @@ __all__ = [
|
||||
"CreatorUserRole",
|
||||
"CredentialPermission",
|
||||
"CredentialPermissionType",
|
||||
"CustomizedSnippet",
|
||||
"DataSourceApiKeyAuthBinding",
|
||||
"DataSourceOauthBinding",
|
||||
"Dataset",
|
||||
@ -227,6 +231,7 @@ __all__ = [
|
||||
"RecommendedApp",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"SnippetType",
|
||||
"Tag",
|
||||
"TagBinding",
|
||||
"Tenant",
|
||||
@ -256,6 +261,7 @@ __all__ = [
|
||||
"WorkflowAppLog",
|
||||
"WorkflowAppLogCreatedFrom",
|
||||
"WorkflowArchiveLog",
|
||||
"WorkflowKind",
|
||||
"WorkflowComment",
|
||||
"WorkflowCommentMention",
|
||||
"WorkflowCommentReply",
|
||||
@ -269,4 +275,5 @@ __all__ = [
|
||||
"WorkflowToolProvider",
|
||||
"WorkflowTriggerStatus",
|
||||
"WorkflowType",
|
||||
"resolve_workflow_kind",
|
||||
]
|
||||
|
||||
@ -224,6 +224,7 @@ class TagType(StrEnum):
|
||||
|
||||
KNOWLEDGE = "knowledge"
|
||||
APP = "app"
|
||||
SNIPPET = "snippet"
|
||||
|
||||
|
||||
class DatasetMetadataType(StrEnum):
|
||||
|
||||
@ -2499,7 +2499,7 @@ class Tag(TypeBase):
|
||||
sa.Index("tag_name_idx", "name"),
|
||||
)
|
||||
|
||||
TAG_TYPE_LIST = ["knowledge", "app"]
|
||||
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
|
||||
123
api/models/snippet.py
Normal file
123
api/models/snippet.py
Normal file
@ -0,0 +1,123 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from libs.uuid_utils import uuidv7
|
||||
|
||||
from .account import Account
|
||||
from .base import Base
|
||||
from .engine import db
|
||||
from .model import Tag, TagBinding
|
||||
from .types import AdjustedJSON, LongText, StringUUID
|
||||
|
||||
|
||||
class SnippetType(StrEnum):
|
||||
"""Snippet Type Enum"""
|
||||
|
||||
NODE = "node"
|
||||
GROUP = "group"
|
||||
|
||||
|
||||
class CustomizedSnippet(Base):
|
||||
"""
|
||||
Customized Snippet Model
|
||||
|
||||
Stores reusable workflow components (nodes or node groups) that can be
|
||||
shared across applications within a workspace.
|
||||
"""
|
||||
|
||||
__tablename__ = "customized_snippets"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="customized_snippet_pkey"),
|
||||
sa.Index("customized_snippet_tenant_idx", "tenant_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()))
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False, server_default=sa.text("'node'"))
|
||||
|
||||
# Workflow reference for published version
|
||||
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
# State flags
|
||||
is_published: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
version: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("1"))
|
||||
use_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"))
|
||||
|
||||
# Visual customization
|
||||
icon_info: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True)
|
||||
|
||||
# Snippet configuration (stored as JSON text)
|
||||
input_fields: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
)
|
||||
|
||||
@property
|
||||
def graph_dict(self) -> dict[str, Any]:
|
||||
"""Get graph from associated workflow."""
|
||||
if self.workflow_id:
|
||||
from .workflow import Workflow
|
||||
|
||||
workflow = db.session.get(Workflow, self.workflow_id)
|
||||
if workflow:
|
||||
return json.loads(workflow.graph) if workflow.graph else {}
|
||||
return {}
|
||||
|
||||
@property
|
||||
def input_fields_list(self) -> list[dict[str, Any]]:
|
||||
"""Parse input_fields JSON to list."""
|
||||
return json.loads(self.input_fields) if self.input_fields else []
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
"""Get snippet tags."""
|
||||
tags = db.session.scalars(
|
||||
sa.select(Tag)
|
||||
.join(TagBinding, Tag.id == TagBinding.tag_id)
|
||||
.where(
|
||||
TagBinding.target_id == self.id,
|
||||
TagBinding.tenant_id == self.tenant_id,
|
||||
Tag.tenant_id == self.tenant_id,
|
||||
Tag.type == "snippet",
|
||||
)
|
||||
).all()
|
||||
|
||||
return tags or []
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
"""Get the account that created this snippet."""
|
||||
if self.created_by:
|
||||
return db.session.get(Account, self.created_by)
|
||||
return None
|
||||
|
||||
@property
|
||||
def author_name(self) -> str | None:
|
||||
"""Get the creator account name."""
|
||||
account = self.created_by_account
|
||||
return account.name if account else None
|
||||
|
||||
@property
|
||||
def updated_by_account(self) -> Account | None:
|
||||
"""Get the account that last updated this snippet."""
|
||||
if self.updated_by:
|
||||
return db.session.get(Account, self.updated_by)
|
||||
return None
|
||||
|
||||
@property
|
||||
def version_str(self) -> str:
|
||||
"""Get version as string for API response."""
|
||||
return str(self.version)
|
||||
@ -112,6 +112,7 @@ class WorkflowType(StrEnum):
|
||||
WORKFLOW = "workflow"
|
||||
CHAT = "chat"
|
||||
RAG_PIPELINE = "rag-pipeline"
|
||||
SNIPPET = "snippet"
|
||||
|
||||
@classmethod
|
||||
def value_of(cls, value: str) -> "WorkflowType":
|
||||
@ -140,6 +141,26 @@ class WorkflowType(StrEnum):
|
||||
return cls.WORKFLOW if app_mode == AppMode.WORKFLOW else cls.CHAT
|
||||
|
||||
|
||||
class WorkflowKind(StrEnum):
|
||||
STANDARD = "standard"
|
||||
SNIPPET = "snippet"
|
||||
|
||||
@classmethod
|
||||
def value_of(cls, value: str) -> "WorkflowKind":
|
||||
for kind in cls:
|
||||
if kind.value == value:
|
||||
return kind
|
||||
raise ValueError(f"invalid workflow kind value {value}")
|
||||
|
||||
|
||||
def resolve_workflow_kind(kind: str | WorkflowKind | None) -> WorkflowKind:
|
||||
if kind is None:
|
||||
return WorkflowKind.STANDARD
|
||||
if isinstance(kind, WorkflowKind):
|
||||
return kind
|
||||
return WorkflowKind.value_of(kind)
|
||||
|
||||
|
||||
class _InvalidGraphDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
@ -187,6 +208,12 @@ class Workflow(Base): # bug
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
type: Mapped[WorkflowType] = mapped_column(EnumText(WorkflowType, length=255), nullable=False)
|
||||
kind: Mapped[WorkflowKind | None] = mapped_column(
|
||||
EnumText(WorkflowKind, length=255),
|
||||
nullable=True,
|
||||
default=WorkflowKind.STANDARD,
|
||||
server_default=sa.text("'standard'"),
|
||||
)
|
||||
version: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
marked_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
marked_comment: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
@ -228,12 +255,14 @@ class Workflow(Base): # bug
|
||||
rag_pipeline_variables: list[dict],
|
||||
marked_name: str = "",
|
||||
marked_comment: str = "",
|
||||
kind: str | None = WorkflowKind.STANDARD.value,
|
||||
) -> "Workflow":
|
||||
workflow = Workflow()
|
||||
workflow.id = str(uuid4())
|
||||
workflow.tenant_id = tenant_id
|
||||
workflow.app_id = app_id
|
||||
workflow.type = WorkflowType(type)
|
||||
workflow.kind = resolve_workflow_kind(kind)
|
||||
workflow.version = version
|
||||
workflow.graph = graph
|
||||
workflow.features = features
|
||||
@ -255,6 +284,14 @@ class Workflow(Base): # bug
|
||||
def updated_by_account(self):
|
||||
return db.session.get(Account, self.updated_by) if self.updated_by else None
|
||||
|
||||
@property
|
||||
def kind_or_standard(self) -> str:
|
||||
return self.resolved_kind.value
|
||||
|
||||
@property
|
||||
def resolved_kind(self) -> WorkflowKind:
|
||||
return resolve_workflow_kind(self.kind)
|
||||
|
||||
@property
|
||||
def graph_dict(self) -> Mapping[str, Any]:
|
||||
# TODO(QuantumGhost): Consider caching `graph_dict` to avoid repeated JSON decoding.
|
||||
|
||||
583
api/services/snippet_dsl_service.py
Normal file
583
api/services/snippet_dsl_service.py
Normal file
@ -0,0 +1,583 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml # type: ignore
|
||||
from packaging import version
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.helper import ssrf_proxy
|
||||
from core.plugin.entities.plugin import PluginDependency
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from models import Account
|
||||
from models.snippet import CustomizedSnippet, SnippetType
|
||||
from models.workflow import Workflow
|
||||
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
||||
from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMPORT_INFO_REDIS_KEY_PREFIX = "snippet_import_info:"
|
||||
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "snippet_check_dependencies:"
|
||||
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
|
||||
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 SnippetImportInfo(BaseModel):
|
||||
id: str
|
||||
status: ImportStatus
|
||||
snippet_id: str | None = None
|
||||
current_dsl_version: str = CURRENT_DSL_VERSION
|
||||
imported_dsl_version: str = ""
|
||||
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:
|
||||
current_ver = version.parse(CURRENT_DSL_VERSION)
|
||||
imported_ver = version.parse(imported_version)
|
||||
except version.InvalidVersion:
|
||||
return ImportStatus.FAILED
|
||||
|
||||
# If imported version is newer than current, always return PENDING
|
||||
if imported_ver > current_ver:
|
||||
return ImportStatus.PENDING
|
||||
|
||||
# If imported version is older than current's major, return PENDING
|
||||
if imported_ver.major < current_ver.major:
|
||||
return ImportStatus.PENDING
|
||||
|
||||
# If imported version is older than current's minor, return COMPLETED_WITH_WARNINGS
|
||||
if imported_ver.minor < current_ver.minor:
|
||||
return ImportStatus.COMPLETED_WITH_WARNINGS
|
||||
|
||||
# If imported version equals or is older than current's micro, return COMPLETED
|
||||
return ImportStatus.COMPLETED
|
||||
|
||||
|
||||
class SnippetPendingData(BaseModel):
|
||||
import_mode: str
|
||||
yaml_content: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
snippet_id: str | None
|
||||
|
||||
|
||||
class CheckDependenciesPendingData(BaseModel):
|
||||
dependencies: list[PluginDependency]
|
||||
snippet_id: str | None
|
||||
|
||||
|
||||
class SnippetDslService:
|
||||
def __init__(self, session: Session):
|
||||
self._session = session
|
||||
|
||||
def import_snippet(
|
||||
self,
|
||||
*,
|
||||
account: Account,
|
||||
import_mode: str,
|
||||
yaml_content: str | None = None,
|
||||
yaml_url: str | None = None,
|
||||
snippet_id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> SnippetImportInfo:
|
||||
"""Import a snippet from YAML content or URL."""
|
||||
import_id = str(uuid.uuid4())
|
||||
|
||||
# Validate import mode
|
||||
try:
|
||||
mode = ImportMode(import_mode)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid import_mode: {import_mode}")
|
||||
|
||||
# Get YAML content
|
||||
content: str = ""
|
||||
if mode == ImportMode.YAML_URL:
|
||||
if not yaml_url:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="yaml_url is required when import_mode is yaml-url",
|
||||
)
|
||||
try:
|
||||
parsed_url = urlparse(yaml_url)
|
||||
if parsed_url.scheme not in ["http", "https"]:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Invalid URL scheme, only http and https are allowed",
|
||||
)
|
||||
response = ssrf_proxy.get(yaml_url, timeout=(10, 30))
|
||||
if response.status_code != 200:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Failed to fetch YAML from URL: {response.status_code}",
|
||||
)
|
||||
content = response.text
|
||||
if len(content) > DSL_MAX_SIZE:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch YAML from URL")
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Failed to fetch YAML from URL: {str(e)}",
|
||||
)
|
||||
elif mode == ImportMode.YAML_CONTENT:
|
||||
if not yaml_content:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="yaml_content is required when import_mode is yaml-content",
|
||||
)
|
||||
content = yaml_content
|
||||
if len(content) > DSL_MAX_SIZE:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes",
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse YAML
|
||||
data = yaml.safe_load(content)
|
||||
if not isinstance(data, dict):
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Invalid YAML format: expected a dictionary",
|
||||
)
|
||||
|
||||
# Validate and fix DSL version
|
||||
if not data.get("version"):
|
||||
data["version"] = "0.1.0"
|
||||
|
||||
# Strictly validate kind field
|
||||
kind = data.get("kind")
|
||||
if not kind:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Missing 'kind' field in DSL. Expected 'kind: snippet'.",
|
||||
)
|
||||
if kind != "snippet":
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Invalid DSL kind: expected 'snippet', got '{kind}'. This DSL is for {kind}, not snippet.",
|
||||
)
|
||||
|
||||
imported_version = data.get("version", "0.1.0")
|
||||
if not isinstance(imported_version, str):
|
||||
raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}")
|
||||
status = _check_version_compatibility(imported_version)
|
||||
|
||||
# Extract snippet data
|
||||
snippet_data = data.get("snippet")
|
||||
if not snippet_data:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Missing snippet data in YAML content",
|
||||
)
|
||||
|
||||
# Validate workflow nodes - check for forbidden node types
|
||||
workflow_data = data.get("workflow", {})
|
||||
if workflow_data:
|
||||
graph = workflow_data.get("graph", {})
|
||||
nodes = graph.get("nodes", [])
|
||||
forbidden_nodes_found = []
|
||||
for node in nodes:
|
||||
node_data = node.get("data", {})
|
||||
if not node_data:
|
||||
continue
|
||||
node_type = node_data.get("type", "")
|
||||
if node_type in SNIPPET_FORBIDDEN_NODE_TYPES:
|
||||
forbidden_nodes_found.append(node_type)
|
||||
if forbidden_nodes_found:
|
||||
forbidden_types_str = ", ".join(set(forbidden_nodes_found))
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Snippet cannot contain the following node types: {forbidden_types_str}",
|
||||
)
|
||||
|
||||
# If snippet_id is provided, check if it exists
|
||||
snippet = None
|
||||
if snippet_id:
|
||||
stmt = select(CustomizedSnippet).where(
|
||||
CustomizedSnippet.id == snippet_id,
|
||||
CustomizedSnippet.tenant_id == account.current_tenant_id,
|
||||
)
|
||||
snippet = self._session.scalar(stmt)
|
||||
|
||||
if not snippet:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Snippet not found",
|
||||
)
|
||||
|
||||
# If major version mismatch, store import info in Redis
|
||||
if status == ImportStatus.PENDING:
|
||||
pending_data = SnippetPendingData(
|
||||
import_mode=import_mode,
|
||||
yaml_content=content,
|
||||
name=name,
|
||||
description=description,
|
||||
snippet_id=snippet_id,
|
||||
)
|
||||
redis_client.setex(
|
||||
f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}",
|
||||
IMPORT_INFO_REDIS_EXPIRY,
|
||||
pending_data.model_dump_json(),
|
||||
)
|
||||
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=status,
|
||||
snippet_id=snippet_id,
|
||||
imported_dsl_version=imported_version,
|
||||
)
|
||||
|
||||
# Extract dependencies
|
||||
dependencies = data.get("dependencies", [])
|
||||
check_dependencies_pending_data = None
|
||||
if dependencies:
|
||||
check_dependencies_pending_data = [PluginDependency.model_validate(d) for d in dependencies]
|
||||
|
||||
# Create or update snippet
|
||||
snippet = self._create_or_update_snippet(
|
||||
snippet=snippet,
|
||||
data=data,
|
||||
account=account,
|
||||
name=name,
|
||||
description=description,
|
||||
dependencies=check_dependencies_pending_data,
|
||||
)
|
||||
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=status,
|
||||
snippet_id=snippet.id,
|
||||
imported_dsl_version=imported_version,
|
||||
)
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Invalid YAML format: {str(e)}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to import snippet")
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def confirm_import(self, *, import_id: str, account: Account) -> SnippetImportInfo:
|
||||
"""
|
||||
Confirm an import that requires confirmation
|
||||
"""
|
||||
redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}"
|
||||
pending_data = redis_client.get(redis_key)
|
||||
|
||||
if not pending_data:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Import information expired or does not exist",
|
||||
)
|
||||
|
||||
try:
|
||||
if not isinstance(pending_data, str | bytes):
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Invalid import information",
|
||||
)
|
||||
|
||||
pending_data_str = pending_data.decode("utf-8") if isinstance(pending_data, bytes) else pending_data
|
||||
pending = SnippetPendingData.model_validate_json(pending_data_str)
|
||||
|
||||
data = yaml.safe_load(pending.yaml_content)
|
||||
if not isinstance(data, dict):
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Invalid YAML format: expected a dictionary",
|
||||
)
|
||||
|
||||
snippet = None
|
||||
if pending.snippet_id:
|
||||
stmt = select(CustomizedSnippet).where(
|
||||
CustomizedSnippet.id == pending.snippet_id,
|
||||
CustomizedSnippet.tenant_id == account.current_tenant_id,
|
||||
)
|
||||
snippet = self._session.scalar(stmt)
|
||||
|
||||
snippet = self._create_or_update_snippet(
|
||||
snippet=snippet,
|
||||
data=data,
|
||||
account=account,
|
||||
name=pending.name,
|
||||
description=pending.description,
|
||||
)
|
||||
|
||||
redis_client.delete(redis_key)
|
||||
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.COMPLETED,
|
||||
snippet_id=snippet.id,
|
||||
imported_dsl_version=data.get("version", "0.1.0"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to confirm import")
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def check_dependencies(self, snippet: CustomizedSnippet) -> CheckDependenciesResult:
|
||||
"""
|
||||
Check dependencies for a snippet
|
||||
"""
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
return CheckDependenciesResult(leaked_dependencies=[])
|
||||
|
||||
dependencies = self._extract_dependencies_from_workflow(workflow)
|
||||
leaked_dependencies = DependenciesAnalysisService.generate_dependencies(
|
||||
tenant_id=snippet.tenant_id, dependencies=dependencies
|
||||
)
|
||||
|
||||
return CheckDependenciesResult(leaked_dependencies=leaked_dependencies)
|
||||
|
||||
def _create_or_update_snippet(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet | None,
|
||||
data: dict,
|
||||
account: Account,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
dependencies: list[PluginDependency] | None = None,
|
||||
) -> CustomizedSnippet:
|
||||
"""
|
||||
Create or update snippet from DSL data
|
||||
"""
|
||||
snippet_data = data.get("snippet", {})
|
||||
workflow_data = data.get("workflow", {})
|
||||
|
||||
# Extract snippet info
|
||||
snippet_name = name or snippet_data.get("name") or "Untitled Snippet"
|
||||
snippet_description = description or snippet_data.get("description") or ""
|
||||
snippet_type_str = snippet_data.get("type", "node")
|
||||
try:
|
||||
snippet_type = SnippetType(snippet_type_str)
|
||||
except ValueError:
|
||||
snippet_type = SnippetType.NODE
|
||||
|
||||
icon_info = snippet_data.get("icon_info", {})
|
||||
input_fields = snippet_data.get("input_fields", [])
|
||||
|
||||
# Create or update snippet
|
||||
if snippet:
|
||||
# Update existing snippet
|
||||
snippet.name = snippet_name
|
||||
snippet.description = snippet_description
|
||||
snippet.type = snippet_type.value
|
||||
snippet.icon_info = icon_info or None
|
||||
snippet.input_fields = json.dumps(input_fields) if input_fields else None
|
||||
snippet.updated_by = account.id
|
||||
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
else:
|
||||
# Create new snippet
|
||||
snippet = CustomizedSnippet(
|
||||
tenant_id=account.current_tenant_id,
|
||||
name=snippet_name,
|
||||
description=snippet_description,
|
||||
type=snippet_type.value,
|
||||
icon_info=icon_info or None,
|
||||
input_fields=json.dumps(input_fields) if input_fields else None,
|
||||
created_by=account.id,
|
||||
)
|
||||
self._session.add(snippet)
|
||||
self._session.flush()
|
||||
|
||||
# Create or update draft workflow
|
||||
if workflow_data:
|
||||
graph = workflow_data.get("graph", {})
|
||||
|
||||
snippet_service = SnippetService()
|
||||
# Get existing workflow hash if exists
|
||||
existing_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
unique_hash = existing_workflow.unique_hash if existing_workflow else None
|
||||
|
||||
snippet_service.sync_draft_workflow(
|
||||
snippet=snippet,
|
||||
graph=graph,
|
||||
unique_hash=unique_hash,
|
||||
account=account,
|
||||
input_fields=input_fields,
|
||||
)
|
||||
|
||||
self._session.commit()
|
||||
return snippet
|
||||
|
||||
def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str:
|
||||
"""
|
||||
Export snippet as DSL
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param include_secret: Whether include secret variable
|
||||
:return: YAML string
|
||||
"""
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Missing draft workflow configuration, please check.")
|
||||
|
||||
icon_info = snippet.icon_info or {}
|
||||
export_data = {
|
||||
"version": CURRENT_DSL_VERSION,
|
||||
"kind": "snippet",
|
||||
"snippet": {
|
||||
"name": snippet.name,
|
||||
"description": snippet.description or "",
|
||||
"type": snippet.type,
|
||||
"icon_info": icon_info,
|
||||
"input_fields": snippet.input_fields_list,
|
||||
},
|
||||
}
|
||||
|
||||
self._append_workflow_export_data(
|
||||
export_data=export_data, snippet=snippet, workflow=workflow, include_secret=include_secret
|
||||
)
|
||||
|
||||
return yaml.dump(export_data, allow_unicode=True) # type: ignore
|
||||
|
||||
def _append_workflow_export_data(
|
||||
self, *, export_data: dict, snippet: CustomizedSnippet, workflow: Workflow, include_secret: bool
|
||||
) -> None:
|
||||
"""
|
||||
Append workflow export data
|
||||
"""
|
||||
workflow_dict = workflow.to_dict(include_secret=include_secret)
|
||||
# Filter workspace related data from nodes
|
||||
workflow_dict["environment_variables"] = []
|
||||
workflow_dict["conversation_variables"] = []
|
||||
|
||||
for node in workflow_dict.get("graph", {}).get("nodes", []):
|
||||
node_data = node.get("data", {})
|
||||
if not node_data:
|
||||
continue
|
||||
data_type = node_data.get("type", "")
|
||||
if data_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
|
||||
dataset_ids = node_data.get("dataset_ids", [])
|
||||
node["data"]["dataset_ids"] = [
|
||||
self._encrypt_dataset_id(dataset_id=dataset_id, tenant_id=snippet.tenant_id)
|
||||
for dataset_id in dataset_ids
|
||||
]
|
||||
# filter credential id from tool node
|
||||
if not include_secret and data_type == BuiltinNodeTypes.TOOL:
|
||||
node_data.pop("credential_id", None)
|
||||
# filter credential id from agent node
|
||||
if not include_secret and data_type == BuiltinNodeTypes.AGENT:
|
||||
for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []):
|
||||
tool.pop("credential_id", None)
|
||||
|
||||
export_data["workflow"] = workflow_dict
|
||||
dependencies = self._extract_dependencies_from_workflow(workflow)
|
||||
export_data["dependencies"] = [
|
||||
jsonable_encoder(d.model_dump())
|
||||
for d in DependenciesAnalysisService.generate_dependencies(
|
||||
tenant_id=snippet.tenant_id, dependencies=dependencies
|
||||
)
|
||||
]
|
||||
|
||||
def _encrypt_dataset_id(self, *, dataset_id: str, tenant_id: str) -> str:
|
||||
"""
|
||||
Encrypt dataset ID for export
|
||||
"""
|
||||
# For now, just return the dataset_id as-is
|
||||
# In the future, we might want to encrypt it
|
||||
return dataset_id
|
||||
|
||||
def _extract_dependencies_from_workflow(self, workflow: Workflow) -> list[str]:
|
||||
"""
|
||||
Extract dependencies from workflow
|
||||
:param workflow: Workflow instance
|
||||
:return: dependencies list format like ["langgenius/google"]
|
||||
"""
|
||||
graph = workflow.graph_dict
|
||||
dependencies = self._extract_dependencies_from_workflow_graph(graph)
|
||||
return dependencies
|
||||
|
||||
def _extract_dependencies_from_workflow_graph(self, graph: Mapping) -> list[str]:
|
||||
"""
|
||||
Extract dependencies from workflow graph
|
||||
:param graph: Workflow graph
|
||||
:return: dependencies list format like ["langgenius/google"]
|
||||
"""
|
||||
dependencies = []
|
||||
for node in graph.get("nodes", []):
|
||||
node_data = node.get("data", {})
|
||||
if not node_data:
|
||||
continue
|
||||
data_type = node_data.get("type", "")
|
||||
if data_type == BuiltinNodeTypes.TOOL:
|
||||
tool_config = node_data.get("tool_configurations", {})
|
||||
provider_type = tool_config.get("provider_type")
|
||||
provider_name = tool_config.get("provider")
|
||||
if provider_type and provider_name:
|
||||
dependencies.append(f"{provider_name}/{provider_name}")
|
||||
elif data_type == BuiltinNodeTypes.AGENT:
|
||||
agent_parameters = node_data.get("agent_parameters", {})
|
||||
tools = agent_parameters.get("tools", {}).get("value", [])
|
||||
for tool in tools:
|
||||
provider_type = tool.get("provider_type")
|
||||
provider_name = tool.get("provider")
|
||||
if provider_type and provider_name:
|
||||
dependencies.append(f"{provider_name}/{provider_name}")
|
||||
|
||||
return dependencies
|
||||
468
api/services/snippet_generate_service.py
Normal file
468
api/services/snippet_generate_service.py
Normal file
@ -0,0 +1,468 @@
|
||||
"""
|
||||
Service for generating snippet workflow executions.
|
||||
|
||||
Uses an adapter pattern to bridge CustomizedSnippet with the App-based
|
||||
WorkflowAppGenerator. The adapter (_SnippetAsApp) provides the minimal App-like
|
||||
interface needed by the generator, avoiding modifications to core workflow
|
||||
infrastructure.
|
||||
|
||||
Key invariants:
|
||||
- Snippets always run as WORKFLOW mode (not CHAT or ADVANCED_CHAT).
|
||||
- The adapter maps snippet.id to app_id in workflow execution records.
|
||||
- Snippet debugging has no rate limiting (max_active_requests = 0).
|
||||
|
||||
Supported execution modes:
|
||||
- Full workflow run (generate): Runs the entire draft workflow as SSE stream.
|
||||
- Single node run (run_draft_node): Synchronous single-step debugging for regular nodes.
|
||||
- Single iteration run (generate_single_iteration): SSE stream for iteration container nodes.
|
||||
- Single loop run (generate_single_loop): SSE stream for loop container nodes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import Any, Union
|
||||
|
||||
from sqlalchemy.orm import make_transient
|
||||
|
||||
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
|
||||
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.workflow.snippet_start import SNIPPET_VIRTUAL_START_NODE_ID
|
||||
from factories import file_factory
|
||||
from graphon.file.models import File
|
||||
from models import Account
|
||||
from models.model import AppMode, EndUser
|
||||
from models.snippet import CustomizedSnippet
|
||||
from models.workflow import Workflow, WorkflowNodeExecutionModel
|
||||
from services.snippet_service import SnippetService
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _SnippetAsApp:
|
||||
"""
|
||||
Minimal adapter that wraps a CustomizedSnippet to satisfy the App-like
|
||||
interface required by WorkflowAppGenerator, WorkflowAppConfigManager,
|
||||
and WorkflowService.run_draft_workflow_node.
|
||||
|
||||
Used properties:
|
||||
- id: maps to snippet.id (stored as app_id in workflows table)
|
||||
- tenant_id: maps to snippet.tenant_id
|
||||
- mode: hardcoded to AppMode.WORKFLOW since snippets always run as workflows
|
||||
- max_active_requests: defaults to 0 (no limit) for snippet debugging
|
||||
- app_model_config_id: None (snippets don't have app model configs)
|
||||
"""
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
mode: str
|
||||
max_active_requests: int
|
||||
app_model_config_id: str | None
|
||||
|
||||
def __init__(self, snippet: CustomizedSnippet) -> None:
|
||||
self.id = snippet.id
|
||||
self.tenant_id = snippet.tenant_id
|
||||
self.mode = AppMode.WORKFLOW.value
|
||||
self.max_active_requests = 0
|
||||
self.app_model_config_id = None
|
||||
|
||||
|
||||
class SnippetGenerateService:
|
||||
"""
|
||||
Service for running snippet workflow executions.
|
||||
|
||||
Adapts CustomizedSnippet to work with the existing App-based
|
||||
WorkflowAppGenerator infrastructure, avoiding duplication of the
|
||||
complex workflow execution pipeline.
|
||||
"""
|
||||
|
||||
# Specific ID for the injected virtual Start node so it can be recognised
|
||||
_VIRTUAL_START_NODE_ID = SNIPPET_VIRTUAL_START_NODE_ID
|
||||
|
||||
@classmethod
|
||||
def _is_virtual_start_event(cls, message: Mapping[str, Any] | str) -> bool:
|
||||
"""
|
||||
Return True when *message* is a snippet-only virtual Start node event.
|
||||
|
||||
The virtual Start node is injected purely for snippet execution and is
|
||||
not part of the persisted draft graph. Filter its node lifecycle events
|
||||
out of the SSE stream so the frontend only receives nodes that exist on
|
||||
the canvas.
|
||||
"""
|
||||
if not isinstance(message, Mapping):
|
||||
return False
|
||||
|
||||
if message.get("event") not in {"node_started", "node_finished"}:
|
||||
return False
|
||||
|
||||
data = message.get("data")
|
||||
if not isinstance(data, Mapping):
|
||||
return False
|
||||
|
||||
return data.get("node_id") == cls._VIRTUAL_START_NODE_ID
|
||||
|
||||
@classmethod
|
||||
def _filter_virtual_start_events(
|
||||
cls,
|
||||
response: Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None],
|
||||
) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None]:
|
||||
"""
|
||||
Drop snippet virtual Start node lifecycle events from stream responses.
|
||||
|
||||
Blocking responses are returned unchanged because they never expose the
|
||||
injected node as a standalone event payload.
|
||||
"""
|
||||
if isinstance(response, Mapping):
|
||||
return response
|
||||
|
||||
def _stream() -> Generator[Mapping[str, Any] | str, None, None]:
|
||||
for message in response:
|
||||
if cls._is_virtual_start_event(message):
|
||||
continue
|
||||
yield message
|
||||
|
||||
return _stream()
|
||||
|
||||
@classmethod
|
||||
def generate(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
user: Union[Account, EndUser],
|
||||
args: Mapping[str, Any],
|
||||
invoke_from: InvokeFrom,
|
||||
streaming: bool = True,
|
||||
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]:
|
||||
"""
|
||||
Run a snippet's draft workflow.
|
||||
|
||||
Retrieves the draft workflow, adapts the snippet to an App-like proxy,
|
||||
then delegates execution to WorkflowAppGenerator.
|
||||
|
||||
If the workflow graph has no Start node, a virtual Start node is injected
|
||||
in-memory so that:
|
||||
1. Graph validation passes (root node must have execution_type=ROOT).
|
||||
2. User inputs are processed into the variable pool by the StartNode logic.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param user: Account or EndUser initiating the run
|
||||
:param args: Workflow inputs (must include "inputs" key)
|
||||
:param invoke_from: Source of invocation (typically DEBUGGER)
|
||||
:param streaming: Whether to stream the response
|
||||
:return: Blocking response mapping or SSE streaming generator
|
||||
:raises ValueError: If the snippet has no draft workflow
|
||||
"""
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
# Inject a virtual Start node when the graph doesn't have one.
|
||||
workflow = cls._ensure_start_node(workflow, snippet)
|
||||
|
||||
# Adapt snippet to App-like interface for WorkflowAppGenerator
|
||||
app_proxy = _SnippetAsApp(snippet)
|
||||
|
||||
response = WorkflowAppGenerator().generate(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=streaming,
|
||||
call_depth=0,
|
||||
)
|
||||
|
||||
return WorkflowAppGenerator.convert_to_event_stream(
|
||||
cls._filter_virtual_start_events(response)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def run_published(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
user: Union[Account, EndUser],
|
||||
args: Mapping[str, Any],
|
||||
invoke_from: InvokeFrom,
|
||||
) -> Mapping[str, Any]:
|
||||
"""
|
||||
Run a snippet's published workflow in non-streaming (blocking) mode.
|
||||
|
||||
Similar to :meth:`generate` but targets the published workflow instead
|
||||
of the draft, and returns the raw blocking response without SSE
|
||||
wrapping. Designed for programmatic callers that need direct workflow outputs.
|
||||
|
||||
:param snippet: CustomizedSnippet instance (must be published)
|
||||
:param user: Account or EndUser initiating the run
|
||||
:param args: Workflow inputs (must include "inputs" key)
|
||||
:param invoke_from: Source of invocation
|
||||
:return: Blocking response mapping with workflow outputs
|
||||
:raises ValueError: If the snippet has no published workflow
|
||||
"""
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_published_workflow(snippet)
|
||||
if not workflow:
|
||||
raise ValueError("No published workflow found for snippet")
|
||||
|
||||
# Inject a virtual Start node when the graph doesn't have one.
|
||||
workflow = cls._ensure_start_node(workflow, snippet)
|
||||
|
||||
app_proxy = _SnippetAsApp(snippet)
|
||||
|
||||
response: Mapping[str, Any] = WorkflowAppGenerator().generate(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=False,
|
||||
)
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def ensure_start_node_for_worker(cls, workflow: Workflow, snippet: CustomizedSnippet) -> Workflow:
|
||||
"""Public wrapper for worker-thread start-node injection."""
|
||||
return cls._ensure_start_node(workflow, snippet)
|
||||
|
||||
@classmethod
|
||||
def _ensure_start_node(cls, workflow: Workflow, snippet: CustomizedSnippet) -> Workflow:
|
||||
"""
|
||||
Return *workflow* with a Start node.
|
||||
|
||||
If the graph already contains a Start node, the original workflow is
|
||||
returned unchanged. Otherwise a virtual Start node is injected and the
|
||||
workflow object is detached from the SQLAlchemy session so the in-memory
|
||||
change is never flushed to the database.
|
||||
"""
|
||||
graph_dict = workflow.graph_dict
|
||||
nodes: list[dict[str, Any]] = graph_dict.get("nodes", [])
|
||||
|
||||
has_start = any(node.get("data", {}).get("type") == "start" for node in nodes)
|
||||
if has_start:
|
||||
return workflow
|
||||
|
||||
modified_graph = cls._inject_virtual_start_node(
|
||||
graph_dict=graph_dict,
|
||||
input_fields=snippet.input_fields_list,
|
||||
)
|
||||
|
||||
# Detach from session to prevent accidental DB persistence of the
|
||||
# modified graph. All attributes remain accessible for read.
|
||||
make_transient(workflow)
|
||||
workflow.graph = json.dumps(modified_graph)
|
||||
return workflow
|
||||
|
||||
@classmethod
|
||||
def _inject_virtual_start_node(
|
||||
cls,
|
||||
graph_dict: Mapping[str, Any],
|
||||
input_fields: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a new graph dict with a virtual Start node prepended.
|
||||
|
||||
The virtual Start node is wired to every existing node that has no
|
||||
incoming edges (i.e. the current root candidates). This guarantees:
|
||||
|
||||
:param graph_dict: Original graph configuration.
|
||||
:param input_fields: Snippet input field definitions from
|
||||
``CustomizedSnippet.input_fields_list``.
|
||||
:return: New graph dict containing the virtual Start node and edges.
|
||||
"""
|
||||
nodes: list[dict[str, Any]] = list(graph_dict.get("nodes", []))
|
||||
edges: list[dict[str, Any]] = list(graph_dict.get("edges", []))
|
||||
|
||||
# Identify nodes with no incoming edges.
|
||||
nodes_with_incoming: set[str] = set()
|
||||
for edge in edges:
|
||||
target = edge.get("target")
|
||||
if isinstance(target, str):
|
||||
nodes_with_incoming.add(target)
|
||||
root_candidate_ids = [n["id"] for n in nodes if n["id"] not in nodes_with_incoming]
|
||||
|
||||
# Build Start node ``variables`` from snippet input fields.
|
||||
start_variables: list[dict[str, Any]] = []
|
||||
for field in input_fields:
|
||||
var: dict[str, Any] = {
|
||||
"variable": field.get("variable", ""),
|
||||
"label": field.get("label", field.get("variable", "")),
|
||||
"type": field.get("type", "text-input"),
|
||||
"required": field.get("required", False),
|
||||
"options": field.get("options", []),
|
||||
}
|
||||
if field.get("max_length") is not None:
|
||||
var["max_length"] = field["max_length"]
|
||||
start_variables.append(var)
|
||||
|
||||
virtual_start_node: dict[str, Any] = {
|
||||
"id": cls._VIRTUAL_START_NODE_ID,
|
||||
"data": {
|
||||
"type": "start",
|
||||
"title": "Start",
|
||||
"variables": start_variables,
|
||||
},
|
||||
}
|
||||
|
||||
# Create edges from virtual Start to each root candidate.
|
||||
new_edges: list[dict[str, Any]] = [
|
||||
{
|
||||
"source": cls._VIRTUAL_START_NODE_ID,
|
||||
"sourceHandle": "source",
|
||||
"target": root_id,
|
||||
"targetHandle": "target",
|
||||
}
|
||||
for root_id in root_candidate_ids
|
||||
]
|
||||
|
||||
return {
|
||||
**graph_dict,
|
||||
"nodes": [virtual_start_node, *nodes],
|
||||
"edges": [*edges, *new_edges],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def run_draft_node(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
node_id: str,
|
||||
user_inputs: Mapping[str, Any],
|
||||
account: Account,
|
||||
query: str = "",
|
||||
files: Sequence[File] | None = None,
|
||||
) -> WorkflowNodeExecutionModel:
|
||||
"""
|
||||
Run a single node in a snippet's draft workflow (single-step debugging).
|
||||
|
||||
Retrieves the draft workflow, adapts the snippet to an App-like proxy,
|
||||
parses file inputs, then delegates to WorkflowService.run_draft_workflow_node.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param node_id: ID of the node to run
|
||||
:param user_inputs: User input values for the node
|
||||
:param account: Account initiating the run
|
||||
:param query: Optional query string
|
||||
:param files: Optional parsed file objects
|
||||
:return: WorkflowNodeExecutionModel with execution results
|
||||
:raises ValueError: If the snippet has no draft workflow
|
||||
"""
|
||||
snippet_service = SnippetService()
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not draft_workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = _SnippetAsApp(snippet)
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
return workflow_service.run_draft_workflow_node(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
draft_workflow=draft_workflow,
|
||||
node_id=node_id,
|
||||
user_inputs=user_inputs,
|
||||
account=account,
|
||||
query=query,
|
||||
files=files,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def generate_single_iteration(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
user: Union[Account, EndUser],
|
||||
node_id: str,
|
||||
args: Mapping[str, Any],
|
||||
streaming: bool = True,
|
||||
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]:
|
||||
"""
|
||||
Run a single iteration node in a snippet's draft workflow.
|
||||
|
||||
Iteration nodes are container nodes that execute their sub-graph multiple
|
||||
times, producing many events. Therefore, this uses the full WorkflowAppGenerator
|
||||
pipeline with SSE streaming (unlike regular single-step node run).
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param user: Account or EndUser initiating the run
|
||||
:param node_id: ID of the iteration node to run
|
||||
:param args: Dict containing 'inputs' key with iteration input data
|
||||
:param streaming: Whether to stream the response (should be True)
|
||||
:return: SSE streaming generator
|
||||
:raises ValueError: If the snippet has no draft workflow
|
||||
"""
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = _SnippetAsApp(snippet)
|
||||
|
||||
return WorkflowAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_iteration_generate(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
workflow=workflow,
|
||||
node_id=node_id,
|
||||
user=user,
|
||||
args=args,
|
||||
streaming=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def generate_single_loop(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
user: Union[Account, EndUser],
|
||||
node_id: str,
|
||||
args: Any,
|
||||
streaming: bool = True,
|
||||
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]:
|
||||
"""
|
||||
Run a single loop node in a snippet's draft workflow.
|
||||
|
||||
Loop nodes are container nodes that execute their sub-graph repeatedly,
|
||||
producing many events. Therefore, this uses the full WorkflowAppGenerator
|
||||
pipeline with SSE streaming (unlike regular single-step node run).
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param user: Account or EndUser initiating the run
|
||||
:param node_id: ID of the loop node to run
|
||||
:param args: Pydantic model with 'inputs' attribute containing loop input data
|
||||
:param streaming: Whether to stream the response (should be True)
|
||||
:return: SSE streaming generator
|
||||
:raises ValueError: If the snippet has no draft workflow
|
||||
"""
|
||||
snippet_service = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = _SnippetAsApp(snippet)
|
||||
|
||||
return WorkflowAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_loop_generate(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
workflow=workflow,
|
||||
node_id=node_id,
|
||||
user=user,
|
||||
args=args, # type: ignore[arg-type]
|
||||
streaming=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_files(workflow: Workflow, files: list[dict] | None = None) -> Sequence[File]:
|
||||
"""
|
||||
Parse file mappings into File objects based on workflow configuration.
|
||||
|
||||
:param workflow: Workflow instance for file upload config
|
||||
:param files: Raw file mapping dicts
|
||||
:return: Parsed File objects
|
||||
"""
|
||||
files = files or []
|
||||
file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
|
||||
if file_extra_config is None:
|
||||
return []
|
||||
return file_factory.build_from_mappings(
|
||||
mappings=files,
|
||||
tenant_id=workflow.tenant_id,
|
||||
config=file_extra_config,
|
||||
)
|
||||
675
api/services/snippet_service.py
Normal file
675
api/services/snippet_service.py
Normal file
@ -0,0 +1,675 @@
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
|
||||
from extensions.ext_database import db
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account, TagBinding
|
||||
from models.enums import WorkflowRunTriggeredFrom
|
||||
from models.snippet import CustomizedSnippet, SnippetType
|
||||
from models.workflow import (
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowRun,
|
||||
WorkflowType,
|
||||
)
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.tag_service import TagService
|
||||
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Node types not allowed in snippet workflows (sync, publish, DSL import).
|
||||
SNIPPET_FORBIDDEN_NODE_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
BuiltinNodeTypes.START,
|
||||
BuiltinNodeTypes.HUMAN_INPUT,
|
||||
BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SnippetService:
|
||||
"""Service for managing customized snippets."""
|
||||
|
||||
def __init__(self, session_maker: sessionmaker | None = None):
|
||||
"""Initialize SnippetService with repository dependencies."""
|
||||
if session_maker is None:
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
|
||||
session_maker
|
||||
)
|
||||
self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
|
||||
|
||||
@staticmethod
|
||||
def _snippet_kind_filter():
|
||||
"""Match snippet workflows by business kind."""
|
||||
return Workflow.kind == WorkflowKind.SNIPPET.value
|
||||
|
||||
@staticmethod
|
||||
def validate_snippet_graph_forbidden_nodes(graph: Mapping[str, Any]) -> None:
|
||||
"""Reject graphs that contain node types not allowed in snippets."""
|
||||
nodes = graph.get("nodes") or []
|
||||
disallowed: list[tuple[str, str]] = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_data = node.get("data") or {}
|
||||
node_type = node_data.get("type")
|
||||
if not isinstance(node_type, str):
|
||||
continue
|
||||
if node_type in SNIPPET_FORBIDDEN_NODE_TYPES:
|
||||
node_id = node.get("id")
|
||||
disallowed.append((str(node_id) if node_id is not None else "?", node_type))
|
||||
if not disallowed:
|
||||
return
|
||||
detail = ", ".join(f"{nid}:{t}" for nid, t in disallowed)
|
||||
raise ValueError(
|
||||
"Snippet workflow cannot contain start, human-input, or knowledge-retrieval nodes. "
|
||||
f"Found: {detail}"
|
||||
)
|
||||
|
||||
# --- CRUD Operations ---
|
||||
|
||||
@staticmethod
|
||||
def get_snippets(
|
||||
*,
|
||||
tenant_id: str,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
keyword: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
creators: list[str] | None = None,
|
||||
tag_ids: list[str] | None = None,
|
||||
) -> tuple[Sequence[CustomizedSnippet], int, bool]:
|
||||
"""
|
||||
Get paginated list of snippets with optional search.
|
||||
|
||||
:param tenant_id: Tenant ID
|
||||
:param page: Page number (1-indexed)
|
||||
:param limit: Number of items per page
|
||||
:param keyword: Optional search keyword for name/description
|
||||
:param is_published: Optional filter by published status (True/False/None for all)
|
||||
:param creators: Optional filter by creator account IDs
|
||||
:param tag_ids: Optional filter by tag IDs
|
||||
:return: Tuple of (snippets list, total count, has_more flag)
|
||||
"""
|
||||
stmt = (
|
||||
select(CustomizedSnippet)
|
||||
.where(CustomizedSnippet.tenant_id == tenant_id)
|
||||
.order_by(CustomizedSnippet.created_at.desc())
|
||||
)
|
||||
|
||||
if keyword:
|
||||
stmt = stmt.where(
|
||||
CustomizedSnippet.name.ilike(f"%{keyword}%") | CustomizedSnippet.description.ilike(f"%{keyword}%")
|
||||
)
|
||||
|
||||
if is_published is not None:
|
||||
stmt = stmt.where(CustomizedSnippet.is_published == is_published)
|
||||
|
||||
if creators:
|
||||
stmt = stmt.where(CustomizedSnippet.created_by.in_(creators))
|
||||
|
||||
if tag_ids:
|
||||
target_ids = TagService.get_target_ids_by_tag_ids("snippet", tenant_id, tag_ids)
|
||||
if target_ids:
|
||||
stmt = stmt.where(CustomizedSnippet.id.in_(target_ids))
|
||||
else:
|
||||
return [], 0, False
|
||||
|
||||
# Get total count
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = db.session.scalar(count_stmt) or 0
|
||||
|
||||
# Apply pagination
|
||||
stmt = stmt.limit(limit + 1).offset((page - 1) * limit)
|
||||
snippets = list(db.session.scalars(stmt).all())
|
||||
|
||||
has_more = len(snippets) > limit
|
||||
if has_more:
|
||||
snippets = snippets[:-1]
|
||||
|
||||
return snippets, total, has_more
|
||||
|
||||
@staticmethod
|
||||
def get_snippet_by_id(
|
||||
*,
|
||||
snippet_id: str,
|
||||
tenant_id: str,
|
||||
) -> CustomizedSnippet | None:
|
||||
"""
|
||||
Get snippet by ID with tenant isolation.
|
||||
|
||||
:param snippet_id: Snippet ID
|
||||
:param tenant_id: Tenant ID
|
||||
:return: CustomizedSnippet or None
|
||||
"""
|
||||
return (
|
||||
db.session.query(CustomizedSnippet)
|
||||
.where(
|
||||
CustomizedSnippet.id == snippet_id,
|
||||
CustomizedSnippet.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_snippet(
|
||||
*,
|
||||
tenant_id: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
snippet_type: SnippetType,
|
||||
icon_info: dict | None,
|
||||
input_fields: list[dict] | None,
|
||||
account: Account,
|
||||
) -> CustomizedSnippet:
|
||||
"""
|
||||
Create a new snippet.
|
||||
|
||||
:param tenant_id: Tenant ID
|
||||
:param name: Snippet name
|
||||
:param description: Snippet description
|
||||
:param snippet_type: Type of snippet (node or group)
|
||||
:param icon_info: Icon information
|
||||
:param input_fields: Input field definitions
|
||||
:param account: Creator account
|
||||
:return: Created CustomizedSnippet
|
||||
"""
|
||||
snippet = CustomizedSnippet(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
description=description or "",
|
||||
type=snippet_type.value,
|
||||
icon_info=icon_info,
|
||||
input_fields=json.dumps(input_fields) if input_fields else None,
|
||||
created_by=account.id,
|
||||
)
|
||||
|
||||
db.session.add(snippet)
|
||||
db.session.commit()
|
||||
|
||||
return snippet
|
||||
|
||||
@staticmethod
|
||||
def update_snippet(
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
account_id: str,
|
||||
data: dict,
|
||||
) -> CustomizedSnippet:
|
||||
"""
|
||||
Update snippet attributes.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: Snippet to update
|
||||
:param account_id: ID of account making the update
|
||||
:param data: Dictionary of fields to update
|
||||
:return: Updated CustomizedSnippet
|
||||
"""
|
||||
if "name" in data:
|
||||
snippet.name = data["name"]
|
||||
|
||||
if "description" in data:
|
||||
snippet.description = data["description"]
|
||||
|
||||
if "icon_info" in data:
|
||||
snippet.icon_info = data["icon_info"]
|
||||
|
||||
snippet.updated_by = account_id
|
||||
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
session.add(snippet)
|
||||
return snippet
|
||||
|
||||
@staticmethod
|
||||
def delete_snippet(
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a snippet.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: Snippet to delete
|
||||
:return: True if deleted successfully
|
||||
"""
|
||||
session.execute(
|
||||
delete(TagBinding).where(
|
||||
TagBinding.tenant_id == snippet.tenant_id,
|
||||
TagBinding.target_id == snippet.id,
|
||||
)
|
||||
)
|
||||
session.delete(snippet)
|
||||
return True
|
||||
|
||||
# --- Workflow Operations ---
|
||||
|
||||
def get_draft_workflow(self, snippet: CustomizedSnippet) -> Workflow | None:
|
||||
"""
|
||||
Get draft workflow for snippet.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:return: Draft Workflow or None
|
||||
"""
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.version == "draft",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return workflow
|
||||
|
||||
def get_published_workflow(self, snippet: CustomizedSnippet) -> Workflow | None:
|
||||
"""
|
||||
Get published workflow for snippet.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:return: Published Workflow or None
|
||||
"""
|
||||
if not snippet.workflow_id:
|
||||
return None
|
||||
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.id == snippet.workflow_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return workflow
|
||||
|
||||
def get_published_workflow_by_id(self, snippet: CustomizedSnippet, workflow_id: str) -> Workflow | None:
|
||||
"""
|
||||
Get a published workflow snapshot by ID for snippet history restore.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param workflow_id: Workflow ID
|
||||
:return: Published Workflow or None
|
||||
:raises IsDraftWorkflowError: If the workflow ID points to a draft workflow
|
||||
"""
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.id == workflow_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not workflow:
|
||||
return None
|
||||
if workflow.version == Workflow.VERSION_DRAFT:
|
||||
raise IsDraftWorkflowError("source workflow must be published")
|
||||
return workflow
|
||||
|
||||
def sync_draft_workflow(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
graph: dict,
|
||||
unique_hash: str | None,
|
||||
account: Account,
|
||||
input_fields: list[dict] | None = None,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Sync draft workflow for snippet.
|
||||
|
||||
Snippet workflows do not persist environment variables (always empty) or
|
||||
conversation variables (always empty).
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param graph: Workflow graph configuration
|
||||
:param unique_hash: Hash for conflict detection
|
||||
:param account: Account making the change
|
||||
:param input_fields: Input fields for snippet
|
||||
:return: Synced Workflow
|
||||
:raises WorkflowHashNotEqualError: If hash mismatch
|
||||
"""
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(graph)
|
||||
|
||||
workflow = self.get_draft_workflow(snippet=snippet)
|
||||
|
||||
if workflow and workflow.unique_hash != unique_hash:
|
||||
raise WorkflowHashNotEqualError()
|
||||
|
||||
# Create draft workflow if not found
|
||||
if not workflow:
|
||||
workflow = Workflow(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
features="{}",
|
||||
type=WorkflowType.WORKFLOW.value,
|
||||
kind=WorkflowKind.SNIPPET.value,
|
||||
version="draft",
|
||||
graph=json.dumps(graph),
|
||||
created_by=account.id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
)
|
||||
db.session.add(workflow)
|
||||
db.session.flush()
|
||||
else:
|
||||
# Update existing draft workflow
|
||||
workflow.graph = json.dumps(graph)
|
||||
workflow.type = WorkflowType.WORKFLOW.value
|
||||
workflow.kind = WorkflowKind.SNIPPET
|
||||
workflow.updated_by = account.id
|
||||
workflow.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
workflow.environment_variables = []
|
||||
workflow.conversation_variables = []
|
||||
|
||||
# Update snippet's input_fields if provided
|
||||
if input_fields is not None:
|
||||
snippet.input_fields = json.dumps(input_fields)
|
||||
snippet.updated_by = account.id
|
||||
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
db.session.commit()
|
||||
return workflow
|
||||
|
||||
def restore_published_workflow_to_draft(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
workflow_id: str,
|
||||
account: Account,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Restore a published snippet workflow snapshot into the draft workflow.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param workflow_id: Published workflow ID
|
||||
:param account: Account making the change
|
||||
:return: Restored draft Workflow
|
||||
:raises WorkflowNotFoundError: If the source workflow does not exist
|
||||
:raises IsDraftWorkflowError: If the source workflow is a draft
|
||||
:raises ValueError: If the restored graph is invalid for snippets
|
||||
"""
|
||||
source_workflow = self.get_published_workflow_by_id(snippet=snippet, workflow_id=workflow_id)
|
||||
if not source_workflow:
|
||||
raise WorkflowNotFoundError("Workflow not found.")
|
||||
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(source_workflow.graph_dict)
|
||||
|
||||
draft_workflow = self.get_draft_workflow(snippet=snippet)
|
||||
draft_workflow, is_new_draft = apply_published_workflow_snapshot_to_draft(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
source_workflow=source_workflow,
|
||||
draft_workflow=draft_workflow,
|
||||
account=account,
|
||||
updated_at_factory=lambda: datetime.now(UTC).replace(tzinfo=None),
|
||||
)
|
||||
|
||||
if is_new_draft:
|
||||
db.session.add(draft_workflow)
|
||||
|
||||
db.session.commit()
|
||||
return draft_workflow
|
||||
|
||||
def publish_workflow(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
account: Account,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Publish the draft workflow as a new version.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param account: Account making the change
|
||||
:return: Published Workflow
|
||||
:raises ValueError: If no draft workflow exists
|
||||
"""
|
||||
draft_workflow_stmt = select(Workflow).where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.version == "draft",
|
||||
)
|
||||
draft_workflow = session.scalar(draft_workflow_stmt)
|
||||
if not draft_workflow:
|
||||
raise ValueError("No valid workflow found.")
|
||||
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(draft_workflow.graph_dict)
|
||||
|
||||
# Create new published workflow
|
||||
workflow = Workflow.new(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
type=WorkflowType.WORKFLOW.value,
|
||||
version=str(datetime.now(UTC).replace(tzinfo=None)),
|
||||
graph=draft_workflow.graph,
|
||||
features=draft_workflow.features,
|
||||
created_by=account.id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=draft_workflow.rag_pipeline_variables,
|
||||
kind=WorkflowKind.SNIPPET.value,
|
||||
marked_name="",
|
||||
marked_comment="",
|
||||
)
|
||||
session.add(workflow)
|
||||
|
||||
# Update snippet version
|
||||
snippet.version += 1
|
||||
snippet.is_published = True
|
||||
snippet.workflow_id = workflow.id
|
||||
snippet.updated_by = account.id
|
||||
session.add(snippet)
|
||||
|
||||
return workflow
|
||||
|
||||
def get_all_published_workflows(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> tuple[Sequence[Workflow], bool]:
|
||||
"""
|
||||
Get all published workflow versions for snippet.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param page: Page number
|
||||
:param limit: Items per page
|
||||
:return: Tuple of (workflows list, has_more flag)
|
||||
"""
|
||||
if not snippet.workflow_id:
|
||||
return [], False
|
||||
|
||||
stmt = (
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.version != "draft",
|
||||
)
|
||||
.order_by(Workflow.version.desc())
|
||||
.limit(limit + 1)
|
||||
.offset((page - 1) * limit)
|
||||
)
|
||||
|
||||
workflows = list(session.scalars(stmt).all())
|
||||
has_more = len(workflows) > limit
|
||||
if has_more:
|
||||
workflows = workflows[:-1]
|
||||
|
||||
return workflows, has_more
|
||||
|
||||
# --- Default Block Configs ---
|
||||
|
||||
def get_default_block_configs(self) -> list[dict]:
|
||||
"""
|
||||
Get default block configurations for all node types.
|
||||
|
||||
:return: List of default configurations
|
||||
"""
|
||||
default_block_configs: list[dict[str, Any]] = []
|
||||
for node_class_mapping in NODE_TYPE_CLASSES_MAPPING.values():
|
||||
node_class = node_class_mapping[LATEST_VERSION]
|
||||
default_config = node_class.get_default_config()
|
||||
if default_config:
|
||||
default_block_configs.append(dict(default_config))
|
||||
|
||||
return default_block_configs
|
||||
|
||||
def get_default_block_config(self, node_type: str, filters: dict | None = None) -> Mapping[str, object] | None:
|
||||
"""
|
||||
Get default config for specific node type.
|
||||
|
||||
:param node_type: Node type string
|
||||
:param filters: Optional filters
|
||||
:return: Default configuration or None
|
||||
"""
|
||||
node_type_enum = NodeType(node_type)
|
||||
|
||||
if node_type_enum not in NODE_TYPE_CLASSES_MAPPING:
|
||||
return None
|
||||
|
||||
node_class = NODE_TYPE_CLASSES_MAPPING[node_type_enum][LATEST_VERSION]
|
||||
default_config = node_class.get_default_config(filters=filters)
|
||||
if not default_config:
|
||||
return None
|
||||
|
||||
return default_config
|
||||
|
||||
# --- Workflow Run Operations ---
|
||||
|
||||
def get_snippet_workflow_runs(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
args: dict,
|
||||
) -> InfiniteScrollPagination:
|
||||
"""
|
||||
Get paginated workflow runs for snippet.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param args: Request arguments (last_id, limit)
|
||||
:return: InfiniteScrollPagination result
|
||||
"""
|
||||
limit = int(args.get("limit", 20))
|
||||
last_id = args.get("last_id")
|
||||
|
||||
triggered_from_values = [
|
||||
WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
]
|
||||
|
||||
return self._workflow_run_repo.get_paginated_workflow_runs(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
triggered_from=triggered_from_values,
|
||||
limit=limit,
|
||||
last_id=last_id,
|
||||
)
|
||||
|
||||
def get_snippet_workflow_run(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
run_id: str,
|
||||
) -> WorkflowRun | None:
|
||||
"""
|
||||
Get workflow run details.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param run_id: Workflow run ID
|
||||
:return: WorkflowRun or None
|
||||
"""
|
||||
return self._workflow_run_repo.get_workflow_run_by_id(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
def get_snippet_workflow_run_node_executions(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
run_id: str,
|
||||
) -> Sequence[WorkflowNodeExecutionModel]:
|
||||
"""
|
||||
Get workflow run node execution list.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param run_id: Workflow run ID
|
||||
:return: List of WorkflowNodeExecutionModel
|
||||
"""
|
||||
workflow_run = self.get_snippet_workflow_run(snippet=snippet, run_id=run_id)
|
||||
if not workflow_run:
|
||||
return []
|
||||
|
||||
node_executions = self._node_execution_service_repo.get_executions_by_workflow_run(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
)
|
||||
|
||||
return node_executions
|
||||
|
||||
# --- Node Execution Operations ---
|
||||
|
||||
def get_snippet_node_last_run(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
workflow: Workflow,
|
||||
node_id: str,
|
||||
) -> WorkflowNodeExecutionModel | None:
|
||||
"""
|
||||
Get the most recent execution for a specific node in a snippet workflow.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param workflow: Workflow instance
|
||||
:param node_id: Node identifier
|
||||
:return: WorkflowNodeExecutionModel or None
|
||||
"""
|
||||
return self._node_execution_service_repo.get_node_last_execution(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
workflow_id=workflow.id,
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
# --- Use Count ---
|
||||
|
||||
@staticmethod
|
||||
def increment_use_count(
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
) -> None:
|
||||
"""
|
||||
Increment the use_count when snippet is used.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: CustomizedSnippet instance
|
||||
"""
|
||||
snippet.use_count += 1
|
||||
session.add(snippet)
|
||||
@ -12,6 +12,7 @@ from extensions.ext_database import db
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from models.snippet import CustomizedSnippet
|
||||
|
||||
|
||||
class SaveTagPayload(BaseModel):
|
||||
@ -159,7 +160,14 @@ class TagService:
|
||||
@staticmethod
|
||||
def save_tag_binding(payload: TagBindingCreatePayload):
|
||||
TagService.check_target_exists(payload.type, payload.target_id)
|
||||
for tag_id in payload.tag_ids:
|
||||
valid_tag_ids = db.session.scalars(
|
||||
select(Tag.id).where(
|
||||
Tag.id.in_(payload.tag_ids),
|
||||
Tag.tenant_id == current_user.current_tenant_id,
|
||||
Tag.type == payload.type,
|
||||
)
|
||||
).all()
|
||||
for tag_id in valid_tag_ids:
|
||||
tag_binding = db.session.scalar(
|
||||
select(TagBinding)
|
||||
.where(TagBinding.tag_id == tag_id, TagBinding.target_id == payload.target_id)
|
||||
@ -186,6 +194,12 @@ class TagService:
|
||||
TagBinding.target_id == payload.target_id,
|
||||
TagBinding.tag_id.in_(payload.tag_ids),
|
||||
TagBinding.tenant_id == current_user.current_tenant_id,
|
||||
TagBinding.tag_id.in_(
|
||||
select(Tag.id).where(
|
||||
Tag.tenant_id == current_user.current_tenant_id,
|
||||
Tag.type == payload.type,
|
||||
)
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
@ -209,5 +223,13 @@ class TagService:
|
||||
)
|
||||
if not app:
|
||||
raise NotFound("App not found")
|
||||
elif type == "snippet":
|
||||
snippet = db.session.scalar(
|
||||
select(CustomizedSnippet)
|
||||
.where(CustomizedSnippet.tenant_id == current_user.current_tenant_id, CustomizedSnippet.id == target_id)
|
||||
.limit(1)
|
||||
)
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
else:
|
||||
raise NotFound("Invalid binding type")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Mapping, Sequence, Set
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
@ -271,12 +271,20 @@ class WorkflowDraftVariableService:
|
||||
)
|
||||
|
||||
def list_variables_without_values(
|
||||
self, app_id: str, page: int, limit: int, user_id: str
|
||||
self,
|
||||
app_id: str,
|
||||
page: int,
|
||||
limit: int,
|
||||
user_id: str,
|
||||
*,
|
||||
exclude_node_ids: Set[str] | None = None,
|
||||
) -> WorkflowDraftVariableList:
|
||||
criteria = [
|
||||
WorkflowDraftVariable.app_id == app_id,
|
||||
WorkflowDraftVariable.user_id == user_id,
|
||||
]
|
||||
if exclude_node_ids:
|
||||
criteria.append(WorkflowDraftVariable.node_id.notin_(list(exclude_node_ids)))
|
||||
total = None
|
||||
base_stmt = select(WorkflowDraftVariable).where(*criteria)
|
||||
if page == 1:
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
from werkzeug.datastructures import MultiDict
|
||||
|
||||
from controllers.console.snippets.payloads import SnippetListQuery
|
||||
from controllers.console.workspace.snippets import _normalize_snippet_list_query_args
|
||||
|
||||
|
||||
def test_snippet_list_query_accepts_comma_separated_tag_ids() -> None:
|
||||
first = "11111111-1111-1111-1111-111111111111"
|
||||
second = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
query = SnippetListQuery.model_validate({"tag_ids": f"{first},{second}"})
|
||||
|
||||
assert query.tag_ids == [first, second]
|
||||
|
||||
|
||||
def test_snippet_list_query_accepts_creator_id_alias() -> None:
|
||||
creator_id = "1886f96a-5bf0-42bf-961d-8d2129049076"
|
||||
|
||||
query = SnippetListQuery.model_validate({"creator_id": creator_id})
|
||||
|
||||
assert query.creators == [creator_id]
|
||||
|
||||
|
||||
def test_normalize_snippet_list_query_accepts_indexed_creator_ids() -> None:
|
||||
first = "9e8959cf-a67b-4d34-9906-1d687517b248"
|
||||
second = "1886f96a-5bf0-42bf-961d-8d2129049076"
|
||||
|
||||
normalized = _normalize_snippet_list_query_args(
|
||||
MultiDict(
|
||||
[
|
||||
("creator_ids[1]", second),
|
||||
("creator_ids[0]", first),
|
||||
("keyword", "search"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert normalized == {"keyword": "search", "creators": [first, second]}
|
||||
|
||||
|
||||
def test_normalize_snippet_list_query_accepts_indexed_tag_ids() -> None:
|
||||
first = "11111111-1111-1111-1111-111111111111"
|
||||
second = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
normalized = _normalize_snippet_list_query_args(
|
||||
MultiDict(
|
||||
[
|
||||
("tag_ids[1]", second),
|
||||
("tag_ids[0]", first),
|
||||
("keyword", "search"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert normalized == {"keyword": "search", "tag_ids": [first, second]}
|
||||
@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import HTTPException, NotFound
|
||||
|
||||
from controllers.console.snippets import snippet_workflow as snippet_workflow_module
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
return func
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_success(
|
||||
app, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workflow = SimpleNamespace(
|
||||
unique_hash="restored-hash",
|
||||
updated_at=None,
|
||||
created_at=datetime(2024, 1, 1),
|
||||
)
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(restore_published_workflow_to_draft=lambda **_kwargs: workflow),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/published-workflow/restore",
|
||||
method="POST",
|
||||
):
|
||||
response = handler(api, snippet=snippet, workflow_id="published-workflow")
|
||||
|
||||
assert response["result"] == "success"
|
||||
assert response["hash"] == "restored-hash"
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_not_found(
|
||||
app, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(
|
||||
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
|
||||
snippet_workflow_module.WorkflowNotFoundError("Workflow not found")
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/published-workflow/restore",
|
||||
method="POST",
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
handler(api, snippet=snippet, workflow_id="published-workflow")
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_returns_400_for_draft_source(
|
||||
app, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(
|
||||
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
|
||||
snippet_workflow_module.IsDraftWorkflowError("source workflow must be published")
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/draft-workflow/restore",
|
||||
method="POST",
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
handler(api, snippet=snippet, workflow_id="draft-workflow")
|
||||
|
||||
assert exc.value.code == 400
|
||||
assert exc.value.description == snippet_workflow_module.RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_graph(
|
||||
app, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(
|
||||
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
|
||||
ValueError("invalid snippet workflow graph")
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/published-workflow/restore",
|
||||
method="POST",
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
handler(api, snippet=snippet, workflow_id="published-workflow")
|
||||
|
||||
assert exc.value.code == 400
|
||||
assert exc.value.description == "invalid snippet workflow graph"
|
||||
@ -105,6 +105,34 @@ class TestTagListApi:
|
||||
assert status == 200
|
||||
assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}]
|
||||
|
||||
def test_get_snippet_tags(self, app: Flask):
|
||||
api = TagListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/?type=snippet"):
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.tag.tags.current_account_with_tenant",
|
||||
return_value=(MagicMock(), "tenant-1"),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.tag.tags.TagService.get_tags",
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
id="1",
|
||||
name="snippet-tag",
|
||||
type=TagType.SNIPPET,
|
||||
binding_count=1,
|
||||
)
|
||||
],
|
||||
) as get_tags_mock,
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
get_tags_mock.assert_called_once_with("snippet", "tenant-1", None)
|
||||
assert status == 200
|
||||
assert result == [{"id": "1", "name": "snippet-tag", "type": "snippet", "binding_count": "1"}]
|
||||
|
||||
def test_post_success(self, app: Flask, admin_user, tag, payload_patch):
|
||||
api = TagListApi()
|
||||
method = unwrap(api.post)
|
||||
@ -215,6 +243,34 @@ class TestTagBindingCollectionApi:
|
||||
assert status == 200
|
||||
assert result["result"] == "success"
|
||||
|
||||
def test_create_snippet_binding_success(self, app: Flask, admin_user, payload_patch):
|
||||
api = TagBindingCollectionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
"tag_ids": ["tag-1"],
|
||||
"target_id": "snippet-1",
|
||||
"type": "snippet",
|
||||
}
|
||||
|
||||
with app.test_request_context("/", json=payload):
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.tag.tags.current_account_with_tenant",
|
||||
return_value=(admin_user, None),
|
||||
),
|
||||
payload_patch(payload),
|
||||
patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock,
|
||||
):
|
||||
result, status = method(api)
|
||||
|
||||
save_mock.assert_called_once()
|
||||
binding_payload = save_mock.call_args.args[0]
|
||||
assert binding_payload.type == TagType.SNIPPET
|
||||
assert binding_payload.target_id == "snippet-1"
|
||||
assert status == 200
|
||||
assert result["result"] == "success"
|
||||
|
||||
def test_create_forbidden(self, app: Flask, readonly_user, payload_patch):
|
||||
api = TagBindingCollectionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from controllers.console.workspace import snippets as snippets_module
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
return func
|
||||
|
||||
|
||||
def test_create_snippet_rejects_forbidden_nodes(app, monkeypatch):
|
||||
user = SimpleNamespace(id="account-1")
|
||||
create_snippet = Mock()
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "create_snippet", create_snippet)
|
||||
|
||||
api = snippets_module.CustomizedSnippetsApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets",
|
||||
method="POST",
|
||||
json={
|
||||
"name": "snippet with invalid node",
|
||||
"type": "node",
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{"id": "knowledge-1", "data": {"type": "knowledge-retrieval"}},
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
},
|
||||
):
|
||||
response, status_code = handler(api)
|
||||
|
||||
assert status_code == 400
|
||||
assert "knowledge-retrieval" in response["message"]
|
||||
create_snippet.assert_not_called()
|
||||
@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
|
||||
from controllers.web.error import AppUnavailableError
|
||||
@ -16,13 +17,20 @@ from controllers.web.error import AppUnavailableError
|
||||
# AppParameterApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppParameterApi:
|
||||
def test_disabled_web_app_raises(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="chat", enable_site=False)
|
||||
|
||||
with app.test_request_context("/parameters"):
|
||||
with pytest.raises(BadRequest, match="Site is disabled"):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
def test_advanced_chat_mode_uses_workflow(self, app: Flask) -> None:
|
||||
features_dict = {"opening_statement": "Hello"}
|
||||
workflow = SimpleNamespace(
|
||||
features_dict=features_dict,
|
||||
user_input_form=lambda to_old_structure=False: [],
|
||||
)
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=workflow)
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=workflow, enable_site=True)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
@ -41,7 +49,7 @@ class TestAppParameterApi:
|
||||
features_dict=features_dict,
|
||||
user_input_form=lambda to_old_structure=False: [{"var": "x"}],
|
||||
)
|
||||
app_model = SimpleNamespace(mode="workflow", workflow=workflow)
|
||||
app_model = SimpleNamespace(mode="workflow", workflow=workflow, enable_site=True)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
@ -54,14 +62,14 @@ class TestAppParameterApi:
|
||||
mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[{"var": "x"}])
|
||||
|
||||
def test_advanced_chat_mode_no_workflow_raises(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=None)
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=None, enable_site=True)
|
||||
with app.test_request_context("/parameters"):
|
||||
with pytest.raises(AppUnavailableError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
def test_standard_mode_uses_app_model_config(self, app: Flask) -> None:
|
||||
config = SimpleNamespace(to_dict=lambda: {"user_input_form": [{"var": "y"}], "key": "val"})
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=config)
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=config, enable_site=True)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
@ -75,7 +83,7 @@ class TestAppParameterApi:
|
||||
assert call_kwargs.kwargs["user_input_form"] == [{"var": "y"}]
|
||||
|
||||
def test_standard_mode_no_config_raises(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=None)
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=None, enable_site=True)
|
||||
with app.test_request_context("/parameters"):
|
||||
with pytest.raises(AppUnavailableError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
@ -129,3 +129,34 @@ def test_builtin_tool_summary_short_and_long_content_paths():
|
||||
|
||||
assert result
|
||||
assert "S" in result
|
||||
|
||||
|
||||
def test_builtin_tool_summary_does_not_duplicate_lines_when_merging_chunks():
|
||||
"""Each line must be placed into exactly one summarization chunk.
|
||||
|
||||
The chunk-merge loop used two adjacent, non-mutually-exclusive ``if`` blocks, so a
|
||||
line that fit the character budget was concatenated onto the current chunk AND then
|
||||
appended a second time. That duplicated content in the text sent to the model.
|
||||
"""
|
||||
tool = _build_tool()
|
||||
|
||||
captured_chunks: list[str] = []
|
||||
|
||||
def _record_invoke(user_id, prompt_messages, stop):
|
||||
captured_chunks.append(prompt_messages[-1].content)
|
||||
return SimpleNamespace(message=SimpleNamespace(content="S"))
|
||||
|
||||
content = "\n".join(["a" * 20, "b" * 20, "c" * 20])
|
||||
|
||||
with patch.object(_BuiltinDummyTool, "get_max_tokens", return_value=100):
|
||||
with patch.object(
|
||||
_BuiltinDummyTool,
|
||||
"get_prompt_tokens",
|
||||
side_effect=lambda prompt_messages: len(prompt_messages[-1].content),
|
||||
):
|
||||
with patch.object(_BuiltinDummyTool, "invoke_model", side_effect=_record_invoke):
|
||||
tool.summary(user_id="u1", content=content)
|
||||
|
||||
combined = "".join(captured_chunks)
|
||||
for line in ("a" * 20, "b" * 20, "c" * 20):
|
||||
assert combined.count(line) == 1, f"line was sent to the model {combined.count(line)} times, expected 1"
|
||||
|
||||
23
api/tests/unit_tests/core/workflow/test_snippet_start.py
Normal file
23
api/tests/unit_tests/core/workflow/test_snippet_start.py
Normal file
@ -0,0 +1,23 @@
|
||||
from core.workflow.snippet_start import (
|
||||
LEGACY_START_NODE_ID,
|
||||
SNIPPET_VIRTUAL_START_NODE_ID,
|
||||
get_compatible_start_aliases,
|
||||
)
|
||||
|
||||
|
||||
def test_get_compatible_start_aliases_returns_legacy_start_for_snippet_virtual_start() -> None:
|
||||
aliases = get_compatible_start_aliases(
|
||||
workflow_kind="snippet",
|
||||
root_node_id=SNIPPET_VIRTUAL_START_NODE_ID,
|
||||
)
|
||||
|
||||
assert aliases == (LEGACY_START_NODE_ID,)
|
||||
|
||||
|
||||
def test_get_compatible_start_aliases_returns_empty_for_non_snippet_roots() -> None:
|
||||
aliases = get_compatible_start_aliases(
|
||||
workflow_kind="workflow",
|
||||
root_node_id=SNIPPET_VIRTUAL_START_NODE_ID,
|
||||
)
|
||||
|
||||
assert aliases == ()
|
||||
@ -5,7 +5,7 @@ from collections import defaultdict
|
||||
import pytest
|
||||
|
||||
from core.workflow.system_variables import build_system_variables, system_variables_to_mapping
|
||||
from core.workflow.variable_pool_initializer import add_variables_to_pool
|
||||
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
|
||||
from core.workflow.variable_prefixes import (
|
||||
CONVERSATION_VARIABLE_NODE_ID,
|
||||
ENVIRONMENT_VARIABLE_NODE_ID,
|
||||
@ -80,6 +80,25 @@ def test_get_file_attribute(pool, file):
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_add_node_inputs_to_pool_stores_inputs_under_aliases():
|
||||
pool = VariablePool()
|
||||
|
||||
add_node_inputs_to_pool(
|
||||
pool,
|
||||
node_id="__snippet_virtual_start__",
|
||||
inputs={"query": "hello"},
|
||||
aliases=("start", "__snippet_virtual_start__"),
|
||||
)
|
||||
|
||||
primary_value = pool.get(["__snippet_virtual_start__", "query"])
|
||||
alias_value = pool.get(["start", "query"])
|
||||
|
||||
assert primary_value is not None
|
||||
assert primary_value.value == "hello"
|
||||
assert alias_value is not None
|
||||
assert alias_value.value == "hello"
|
||||
|
||||
|
||||
class TestVariablePool:
|
||||
def test_constructor(self):
|
||||
pool = VariablePool()
|
||||
|
||||
28
api/tests/unit_tests/fields/test_snippet_fields.py
Normal file
28
api/tests/unit_tests/fields/test_snippet_fields.py
Normal file
@ -0,0 +1,28 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from flask_restx import marshal
|
||||
|
||||
from fields.snippet_fields import snippet_list_fields
|
||||
|
||||
|
||||
def test_snippet_list_fields_include_author_name() -> None:
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
name="Snippet",
|
||||
description="Reusable node",
|
||||
type="node",
|
||||
version=1,
|
||||
use_count=0,
|
||||
is_published=False,
|
||||
icon_info=None,
|
||||
tags=[],
|
||||
created_by="account-1",
|
||||
author_name="Alice",
|
||||
created_at=None,
|
||||
updated_by="account-1",
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
result = marshal(snippet, snippet_list_fields)
|
||||
|
||||
assert result["author_name"] == "Alice"
|
||||
46
api/tests/unit_tests/services/test_snippet_dsl_service.py
Normal file
46
api/tests/unit_tests/services/test_snippet_dsl_service.py
Normal file
@ -0,0 +1,46 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from services.snippet_dsl_service import ImportStatus, SnippetDslService, SnippetPendingData
|
||||
|
||||
|
||||
def test_confirm_import_creates_snippet_from_pending_data(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None)))
|
||||
account = SimpleNamespace(id="account-1", current_tenant_id="tenant-1")
|
||||
snippet = SimpleNamespace(id="snippet-new")
|
||||
yaml_content = """
|
||||
version: 9.0.0
|
||||
kind: snippet
|
||||
snippet:
|
||||
name: From DSL
|
||||
type: node
|
||||
workflow:
|
||||
graph:
|
||||
nodes: []
|
||||
edges: []
|
||||
"""
|
||||
pending = SnippetPendingData(
|
||||
import_mode="yaml-content",
|
||||
yaml_content=yaml_content,
|
||||
name="Override name",
|
||||
description="Override description",
|
||||
snippet_id=None,
|
||||
)
|
||||
create_or_update = Mock(return_value=snippet)
|
||||
monkeypatch.setattr(service, "_create_or_update_snippet", create_or_update)
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json()))
|
||||
redis_delete = Mock()
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.delete", redis_delete)
|
||||
|
||||
result = service.confirm_import(import_id="import-1", account=account)
|
||||
|
||||
assert result.status == ImportStatus.COMPLETED
|
||||
assert result.snippet_id == "snippet-new"
|
||||
assert result.imported_dsl_version == "9.0.0"
|
||||
create_or_update.assert_called_once()
|
||||
_, kwargs = create_or_update.call_args
|
||||
assert kwargs["snippet"] is None
|
||||
assert kwargs["account"] is account
|
||||
assert kwargs["name"] == "Override name"
|
||||
assert kwargs["description"] == "Override description"
|
||||
redis_delete.assert_called_once_with("snippet_import_info:import-1")
|
||||
149
api/tests/unit_tests/services/test_snippet_service.py
Normal file
149
api/tests/unit_tests/services/test_snippet_service.py
Normal file
@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from models.snippet import SnippetType
|
||||
from models.workflow import Workflow, WorkflowKind, WorkflowType
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.snippet_service import SnippetService
|
||||
|
||||
|
||||
class _SessionWithoutNameLookup:
|
||||
def __init__(self) -> None:
|
||||
self.add = Mock()
|
||||
self.commit = Mock()
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
raise AssertionError("snippet name uniqueness lookup should not be used")
|
||||
|
||||
|
||||
def _create_workflow(*, workflow_id: str, version: str, graph: dict, features: dict) -> Workflow:
|
||||
return Workflow(
|
||||
id=workflow_id,
|
||||
tenant_id="tenant-1",
|
||||
app_id="snippet-1",
|
||||
type=WorkflowType.WORKFLOW.value,
|
||||
kind=WorkflowKind.SNIPPET.value,
|
||||
version=version,
|
||||
graph=json.dumps(graph),
|
||||
features=json.dumps(features),
|
||||
created_by="account-1",
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
|
||||
|
||||
def test_create_snippet_allows_duplicate_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = _SessionWithoutNameLookup()
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
monkeypatch.setattr("services.snippet_service.db.session", session)
|
||||
|
||||
snippet = SnippetService.create_snippet(
|
||||
tenant_id="tenant-1",
|
||||
name="shared name",
|
||||
description=None,
|
||||
snippet_type=SnippetType.NODE,
|
||||
icon_info=None,
|
||||
input_fields=None,
|
||||
account=account,
|
||||
)
|
||||
|
||||
assert snippet.name == "shared name"
|
||||
session.add.assert_called_once_with(snippet)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_update_snippet_allows_duplicate_names() -> None:
|
||||
session = _SessionWithoutNameLookup()
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
tenant_id="tenant-1",
|
||||
name="old name",
|
||||
description="",
|
||||
icon_info=None,
|
||||
)
|
||||
|
||||
result = SnippetService.update_snippet(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account_id="account-1",
|
||||
data={"name": "shared name"},
|
||||
)
|
||||
|
||||
assert result is snippet
|
||||
assert snippet.name == "shared name"
|
||||
session.add.assert_called_once_with(snippet)
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_copies_source_snapshot(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-2")
|
||||
source_graph = {"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}
|
||||
source_features = {"opening_statement": "hello"}
|
||||
source_workflow = _create_workflow(
|
||||
workflow_id="published-workflow",
|
||||
version="2026-04-28 00:00:00",
|
||||
graph=source_graph,
|
||||
features=source_features,
|
||||
)
|
||||
draft_workflow = _create_workflow(
|
||||
workflow_id="draft-workflow",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph={"nodes": [], "edges": []},
|
||||
features={},
|
||||
)
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
session = SimpleNamespace(add=Mock(), commit=Mock())
|
||||
|
||||
monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow))
|
||||
monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=draft_workflow))
|
||||
monkeypatch.setattr("services.snippet_service.db.session", session)
|
||||
|
||||
result = service.restore_published_workflow_to_draft(
|
||||
snippet=snippet,
|
||||
workflow_id=source_workflow.id,
|
||||
account=account,
|
||||
)
|
||||
|
||||
assert result is draft_workflow
|
||||
assert draft_workflow.graph_dict == source_graph
|
||||
assert draft_workflow.features_dict == source_features
|
||||
assert draft_workflow.updated_by == account.id
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_raises_when_source_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-2")
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
|
||||
monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=None))
|
||||
|
||||
with pytest.raises(WorkflowNotFoundError):
|
||||
service.restore_published_workflow_to_draft(
|
||||
snippet=snippet,
|
||||
workflow_id="missing-workflow",
|
||||
account=account,
|
||||
)
|
||||
|
||||
|
||||
def test_delete_snippet_removes_tag_bindings() -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
session = SimpleNamespace(execute=Mock(), delete=Mock())
|
||||
|
||||
result = SnippetService.delete_snippet(session=session, snippet=snippet)
|
||||
|
||||
assert result is True
|
||||
session.execute.assert_called_once()
|
||||
session.delete.assert_called_once_with(snippet)
|
||||
6
api/uv.lock
generated
6
api/uv.lock
generated
@ -6284,15 +6284,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.1"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -225,6 +225,7 @@ OPENAPI_ENABLED=false
|
||||
OPENAPI_CORS_ALLOW_ORIGINS=
|
||||
OPENAPI_KNOWN_CLIENT_IDS=difyctl
|
||||
OPENAPI_RATE_LIMIT_PER_TOKEN=60
|
||||
DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR=10
|
||||
ENABLE_OAUTH_BEARER=false
|
||||
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
||||
DATASET_MAX_SEGMENTS_PER_REQUEST=0
|
||||
|
||||
@ -145,6 +145,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(shareLayout)/components/splash.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -250,6 +255,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/nav-link/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -3152,6 +3162,16 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-snippet-run.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/signin/countdown.tsx": {
|
||||
"no-restricted-globals": {
|
||||
"count": 4
|
||||
@ -3327,6 +3347,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/blocks.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/hooks.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -5164,6 +5189,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
@ -5431,6 +5461,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/use-snippet-workflows.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-tools.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 751 B |
@ -0,0 +1,5 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 563 B |
@ -513,12 +513,27 @@
|
||||
"width": 14,
|
||||
"height": 14
|
||||
},
|
||||
"line-others-dhs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-drag-handle": {
|
||||
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"line-others-dvs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-env": {
|
||||
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
|
||||
},
|
||||
"line-others-evaluation": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-global-variable": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
|
||||
},
|
||||
@ -1025,6 +1040,11 @@
|
||||
"workflow-if-else": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"workflow-input-field": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"workflow-iteration": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 277,
|
||||
"total": 281,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -341,16 +341,11 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- Tab navigation --
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render all category tabs', () => {
|
||||
it('should render the app type dropdown trigger', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -381,21 +376,19 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- "Created by me" filter --
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render the "created by me" checkbox', () => {
|
||||
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle the "created by me" filter on click', () => {
|
||||
it('should keep the current layout stable without a "created by me" control', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { redirect, usePathname } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import SnippetList from '@/app/components/snippet-list'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <SnippetList />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
|
||||
|
||||
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
|
||||
const { mockSendGAEvent, mockRememberRegistrationSuccess } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
mockRememberRegistrationSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
@ -17,8 +17,8 @@ vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
vi.mock('../base/amplitude/registration-tracking', () => ({
|
||||
rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args),
|
||||
}))
|
||||
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
@ -48,10 +48,9 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
@ -73,8 +72,9 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||
method: 'oauth',
|
||||
utmInfo: null,
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
@ -87,7 +87,7 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
it('should do nothing without the oauth registration query flag', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -100,7 +100,7 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -168,6 +168,21 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@ -28,12 +28,16 @@ type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
appInfoActions?: AppInfoActions
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
iconType = 'app',
|
||||
appInfoActions,
|
||||
}: IAppDetailNavProps) => {
|
||||
@ -112,18 +116,20 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{renderHeader
|
||||
? renderHeader(appSidebarExpand)
|
||||
: iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@ -152,18 +158,20 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{renderNavigation
|
||||
? renderNavigation(appSidebarExpand)
|
||||
: navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
|
||||
@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
href?: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@ -29,6 +31,8 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@ -39,8 +43,11 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@ -70,13 +77,32 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(isActive
|
||||
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@ -0,0 +1,270 @@
|
||||
import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import SnippetInfoDropdown from '../dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockExportMutateAsync = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
let mockDropdownOpen = false
|
||||
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
mockDropdownOpen = !!open
|
||||
mockDropdownOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
mockDropdownOpen ? <div>{children}</div> : null
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockCreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MockCreateSnippetDialogProps) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="create-snippet-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{confirmText}</div>
|
||||
<div>{initialValue?.name}</div>
|
||||
<div>{initialValue?.description}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})}
|
||||
>
|
||||
submit-edit
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>close-edit</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfoDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDropdownOpen = false
|
||||
mockDropdownOnOpenChange = undefined
|
||||
})
|
||||
|
||||
// Rendering coverage for the menu trigger itself.
|
||||
describe('Rendering', () => {
|
||||
it('should render the dropdown trigger button', () => {
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit flow should seed the dialog with current snippet info and submit updates.
|
||||
describe('Edit Snippet', () => {
|
||||
it('should open the edit dialog and submit snippet updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.editInfo'))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
body: {
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
|
||||
})
|
||||
})
|
||||
|
||||
// Export should call the export hook and download the returned YAML blob.
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
|
||||
})
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: `${mockSnippet.name}.yml`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Delete should require confirmation and redirect after a successful mutation.
|
||||
describe('Delete Snippet', () => {
|
||||
it('should confirm deletion and redirect to the snippets list', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
|
||||
|
||||
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SnippetInfo from '..'
|
||||
|
||||
vi.mock('../dropdown', () => ({
|
||||
default: () => <div data-testid="snippet-info-dropdown" />,
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the collapsed and expanded sidebar header states.
|
||||
describe('Rendering', () => {
|
||||
it('should render the expanded snippet details and dropdown when expand is true', () => {
|
||||
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the expanded-only content when expand is false', () => {
|
||||
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases around optional snippet fields should not break the header layout.
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit the description block when the snippet has no description', () => {
|
||||
render(
|
||||
<SnippetInfo
|
||||
expand={true}
|
||||
snippet={{ ...mockSnippet, description: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
177
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
177
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type SnippetInfoDropdownProps = {
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const initialValue = React.useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
}), [snippet.description, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description }: {
|
||||
name: string
|
||||
description: string
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}, [snippet.id, t, updateSnippetMutation])
|
||||
|
||||
const handleDeleteSnippet = React.useCallback(() => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
replace('/snippets')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}, [deleteSnippetMutation, replace, snippet.id, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="grow">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleEditSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-100">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfoDropdown)
|
||||
46
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
46
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SnippetInfoDropdown from './dropdown'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
if (!expand)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-2 pt-2 pb-1">
|
||||
<div className="flex flex-col gap-2 rounded-xl p-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<SnippetInfoDropdown snippet={snippet} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate system-md-semibold text-text-secondary">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
{snippet.description && (
|
||||
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@ -1,7 +1,6 @@
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type { Features, FileUpload } from '@/app/components/base/features/types'
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -21,9 +20,15 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type PublishedModelConfig = ModelConfig & {
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
|
||||
publishedConfig?: any
|
||||
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
|
||||
publishedConfig: {
|
||||
modelConfig: PublishedModelConfig
|
||||
}
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
@ -71,7 +76,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
|
||||
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
|
||||
return props.onPublish?.(params, features)
|
||||
}, [features, props])
|
||||
|
||||
|
||||
@ -85,8 +85,10 @@ export type AppPublisherProps = {
|
||||
|
||||
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
|
||||
|
||||
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
|
||||
|
||||
type AppPublisherPublishHandler
|
||||
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
|
||||
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
|
||||
| ((params?: unknown) => Promise<unknown> | unknown)
|
||||
|
||||
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
|
||||
|
||||
@ -211,6 +211,12 @@ describe('ConfigModalFormFields', () => {
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
textInputView.unmount()
|
||||
|
||||
const hiddenFieldDisabledProps = createBaseProps()
|
||||
const hiddenFieldDisabledView = render(<ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />)
|
||||
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
|
||||
hiddenFieldDisabledView.unmount()
|
||||
|
||||
const singleFileProps = createBaseProps()
|
||||
singleFileProps.tempPayload = {
|
||||
...singleFileProps.tempPayload,
|
||||
|
||||
@ -49,6 +49,7 @@ type ConfigModalFormFieldsProps = {
|
||||
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
options?: string[]
|
||||
selectOptions: SelectOptionItem[]
|
||||
showHiddenField?: boolean
|
||||
tempPayload: InputVar
|
||||
t: Translate
|
||||
}
|
||||
@ -67,6 +68,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
onVarNameChange,
|
||||
options,
|
||||
selectOptions,
|
||||
showHiddenField = true,
|
||||
tempPayload,
|
||||
t,
|
||||
}) => {
|
||||
@ -242,7 +244,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</label>
|
||||
|
||||
{!isFileInput && (
|
||||
{showHiddenField && !isFileInput && (
|
||||
<div className="mt-5! flex h-6 items-center gap-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
|
||||
@ -33,6 +33,7 @@ type IConfigModalProps = {
|
||||
onClose: () => void
|
||||
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
|
||||
supportFile?: boolean
|
||||
showHiddenField?: boolean
|
||||
}
|
||||
|
||||
const ConfigModal: FC<IConfigModalProps> = ({
|
||||
@ -41,6 +42,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
isShow,
|
||||
onClose,
|
||||
onConfirm,
|
||||
showHiddenField,
|
||||
supportFile,
|
||||
}) => {
|
||||
const { modelConfig } = useContext(ConfigContext)
|
||||
@ -173,6 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
onVarNameChange={handleVarNameChange}
|
||||
options={options}
|
||||
selectOptions={selectOptions}
|
||||
showHiddenField={showHiddenField}
|
||||
tempPayload={tempPayload}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
mode: mode as unknown as ModelModeType,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
mode: mode as unknown as ModelModeType,
|
||||
completion_params: defaultCompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
@ -21,7 +22,6 @@ import type {
|
||||
TextToSpeechConfig,
|
||||
} from '@/models/debug'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
import { produce } from 'immer'
|
||||
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
resolvedModelModeType,
|
||||
])
|
||||
|
||||
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
|
||||
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
|
||||
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
|
||||
? params
|
||||
: undefined
|
||||
|
||||
@ -349,29 +349,40 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
|
||||
function AppPreview({ mode }: { mode: AppModeEnum }) {
|
||||
const { t } = useTranslation()
|
||||
const modeToPreviewInfoMap = {
|
||||
[AppModeEnum.CHAT]: {
|
||||
title: t('types.chatbot', { ns: 'app' }),
|
||||
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.ADVANCED_CHAT]: {
|
||||
title: t('types.advanced', { ns: 'app' }),
|
||||
description: t('newApp.advancedUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.AGENT_CHAT]: {
|
||||
title: t('types.agent', { ns: 'app' }),
|
||||
description: t('newApp.agentUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.COMPLETION]: {
|
||||
title: t('newApp.completeApp', { ns: 'app' }),
|
||||
description: t('newApp.completionUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.WORKFLOW]: {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
},
|
||||
}
|
||||
const previewInfo = modeToPreviewInfoMap[mode]
|
||||
const previewInfo = (() => {
|
||||
switch (mode) {
|
||||
case AppModeEnum.CHAT:
|
||||
return {
|
||||
title: t('types.chatbot', { ns: 'app' }),
|
||||
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.ADVANCED_CHAT:
|
||||
return {
|
||||
title: t('types.advanced', { ns: 'app' }),
|
||||
description: t('newApp.advancedUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.AGENT_CHAT:
|
||||
return {
|
||||
title: t('types.agent', { ns: 'app' }),
|
||||
description: t('newApp.agentUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.COMPLETION:
|
||||
return {
|
||||
title: t('newApp.completeApp', { ns: 'app' }),
|
||||
description: t('newApp.completionUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.WORKFLOW:
|
||||
return {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
}
|
||||
}
|
||||
})()
|
||||
return (
|
||||
<div className="px-8 py-4">
|
||||
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
|
||||
|
||||
@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Empty from '../empty'
|
||||
|
||||
const defaultMessage = 'workflow.tabs.noSnippetsFound'
|
||||
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -9,32 +11,32 @@ describe('Empty', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 36 placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards).toHaveLength(36)
|
||||
})
|
||||
|
||||
it('should display the no apps found message', () => {
|
||||
render(<Empty />)
|
||||
it('should display the provided message', () => {
|
||||
render(<Empty message="app.newApp.noAppsFound" />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct container styling for overlay', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const overlay = container.querySelector('.pointer-events-none')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
|
||||
})
|
||||
|
||||
it('should have correct styling for placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const card = container.querySelector('.bg-background-default-lighter')
|
||||
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
|
||||
})
|
||||
@ -42,10 +44,10 @@ describe('Empty', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
const { rerender } = render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
|
||||
rerender(<Empty />)
|
||||
rerender(<Empty message="app.newApp.noAppsFound" />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -51,16 +51,17 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
userProfile: { id: 'creator-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetIsCreatedByMe = vi.fn()
|
||||
const mockSetCreatorIDs = vi.fn()
|
||||
const mockSetCategory = vi.fn()
|
||||
const mockQueryState = {
|
||||
category: 'all',
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
creatorIDs: [] as string[],
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
|
||||
@ -68,7 +69,19 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
query: mockQueryState,
|
||||
setCategory: mockSetCategory,
|
||||
setKeywords: mockSetKeywords,
|
||||
setIsCreatedByMe: mockSetIsCreatedByMe,
|
||||
setCreatorIDs: mockSetCreatorIDs,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
|
||||
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
|
||||
],
|
||||
},
|
||||
setCreatorIDs: mockSetCreatorIDs,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -203,9 +216,9 @@ vi.mock('../app-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../new-app-card', () => ({
|
||||
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
||||
}),
|
||||
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
@ -220,14 +233,14 @@ vi.mock('../footer', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | null = null
|
||||
let intersectionCallbacks: IntersectionObserverCallback[] = []
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.IntersectionObserver = class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback
|
||||
intersectionCallbacks.push(callback)
|
||||
}
|
||||
|
||||
observe = mockObserve
|
||||
@ -242,12 +255,16 @@ beforeAll(() => {
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper plus a seeded
|
||||
// systemFeatures cache so List can resolve its useSuspenseQuery.
|
||||
const renderList = (searchParams = '') => {
|
||||
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
|
||||
mockSearchParams = new URLSearchParams(searchParams)
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { branding: { enabled: false } },
|
||||
})
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
const openTypeFilter = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
|
||||
}
|
||||
|
||||
type AppListInfiniteOptions = {
|
||||
@ -268,20 +285,21 @@ describe('List', () => {
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.category = 'all'
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockQueryState.creatorIDs = []
|
||||
mockUseWorkflowOnlineUsers.mockClear()
|
||||
intersectionCallback = null
|
||||
intersectionCallbacks = []
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
it('should render app type dropdown with all app types', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -301,9 +319,21 @@ describe('List', () => {
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
it('should render creators filter', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link to snippets on apps page', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('link', { name: 'app.studio.viewSnippets' })).toHaveAttribute('href', '/snippets')
|
||||
})
|
||||
|
||||
it('should not render link to snippets on snippets page', () => {
|
||||
renderList('', 'snippets')
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
@ -338,20 +368,22 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update category when workflow tab is clicked', () => {
|
||||
describe('Type Filter', () => {
|
||||
it('should update category when workflow type is selected', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update category when all tab is clicked', () => {
|
||||
it('should update category when all type is selected', () => {
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith('all')
|
||||
})
|
||||
@ -377,10 +409,7 @@ describe('List', () => {
|
||||
|
||||
renderList()
|
||||
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton)!.toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
@ -389,7 +418,7 @@ describe('List', () => {
|
||||
describe('App List Query', () => {
|
||||
it('should build paged query input from active filters', () => {
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.isCreatedByMe = true
|
||||
mockQueryState.creatorIDs = ['creator-1', 'creator-2']
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
|
||||
renderList()
|
||||
@ -403,7 +432,7 @@ describe('List', () => {
|
||||
limit: 30,
|
||||
name: 'sales',
|
||||
tag_ids: ['tag-1'],
|
||||
is_created_by_me: true,
|
||||
creator_ids: ['creator-1', 'creator-2'],
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
})
|
||||
@ -430,19 +459,19 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
describe('Creators Filter', () => {
|
||||
it('should render creators filter with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
it('should handle creator selection as a multi creator filter', () => {
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
|
||||
fireEvent.click(checkbox)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
|
||||
|
||||
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
|
||||
expect(mockSetCreatorIDs).toHaveBeenCalledWith(['creator-2'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -488,11 +517,11 @@ describe('List', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { unmount } = renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
@ -505,9 +534,10 @@ describe('List', () => {
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -524,9 +554,10 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
describe('App Type Dropdown', () => {
|
||||
it('should render all app type options', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -536,9 +567,7 @@ describe('List', () => {
|
||||
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update category for each app type tab click', () => {
|
||||
renderList()
|
||||
|
||||
it('should update category for each app type option click', () => {
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
@ -549,8 +578,11 @@ describe('List', () => {
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
mockSetCategory.mockClear()
|
||||
fireEvent.click(screen.getByText(text))
|
||||
const { unmount } = renderList()
|
||||
openTypeFilter()
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(mode)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -626,16 +658,18 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
describe('Infinite Scroll', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', async () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
await waitFor(() => expect(mockObserve).toHaveBeenCalled())
|
||||
|
||||
if (intersectionCallbacks.length) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
intersectionCallbacks.forEach(callback => callback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -646,12 +680,12 @@ describe('List', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
if (intersectionCallbacks.length) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
intersectionCallbacks.forEach(callback => callback(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -663,12 +697,12 @@ describe('List', () => {
|
||||
mockServiceState.isLoading = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
if (intersectionCallbacks.length) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
intersectionCallbacks.forEach(callback => callback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
16
web/app/components/apps/app-type-filter-shared.ts
Normal file
16
web/app/components/apps/app-type-filter-shared.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { parseAsStringLiteral } from 'nuqs'
|
||||
import { AppModes } from '@/types/app'
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
export type { AppListCategory }
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
76
web/app/components/apps/app-type-filter.tsx
Normal file
76
web/app/components/apps/app-type-filter.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import type { AppListCategory } from './app-type-filter-shared'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
value: AppListCategory
|
||||
onChange: (value: AppListCategory) => void
|
||||
}
|
||||
|
||||
export function AppTypeFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: AppTypeFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
|
||||
]), [t])
|
||||
|
||||
const activeOption = options.find(option => option.value === value)
|
||||
const isSelected = value !== 'all'
|
||||
const triggerLabel = isSelected ? activeOption?.text : t('studio.filters.types', { ns: 'app' })
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
chipClassName,
|
||||
isSelected
|
||||
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
|
||||
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
|
||||
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListCategory(nextValue) && onChange(nextValue)}>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
|
||||
<span>{option.text}</span>
|
||||
<DropdownMenuRadioItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
230
web/app/components/apps/creators-filter.tsx
Normal file
230
web/app/components/apps/creators-filter.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
type CreatorsFilterProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
}
|
||||
|
||||
type CreatorOption = {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl: string | null
|
||||
isYou: boolean
|
||||
}
|
||||
|
||||
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
|
||||
|
||||
const CreatorsFilter = ({
|
||||
value,
|
||||
onChange,
|
||||
}: CreatorsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const { data: membersData } = useMembers()
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const currentUserId = userProfile?.id
|
||||
const members = membersData?.accounts ?? []
|
||||
|
||||
return [...members]
|
||||
.filter(member => member.status !== 'pending')
|
||||
.sort((left, right) => {
|
||||
if (left.id === currentUserId)
|
||||
return -1
|
||||
if (right.id === currentUserId)
|
||||
return 1
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
.map(member => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatar_url,
|
||||
isYou: member.id === currentUserId,
|
||||
}))
|
||||
}, [membersData?.accounts, userProfile?.id])
|
||||
|
||||
const filteredCreators = useMemo(() => {
|
||||
const normalizedKeywords = keywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return creatorOptions
|
||||
|
||||
return creatorOptions.filter((creator) => {
|
||||
const keyword = normalizedKeywords
|
||||
return creator.name.toLowerCase().includes(keyword)
|
||||
})
|
||||
}, [creatorOptions, keywords])
|
||||
|
||||
const selectedCreators = useMemo(() => {
|
||||
const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
|
||||
return value
|
||||
.map(id => creatorMap.get(id))
|
||||
.filter((creator): creator is CreatorOption => Boolean(creator))
|
||||
}, [creatorOptions, value])
|
||||
|
||||
const toggleCreator = useCallback((creatorId: string) => {
|
||||
if (value.includes(creatorId)) {
|
||||
onChange(value.filter(id => id !== creatorId))
|
||||
return
|
||||
}
|
||||
|
||||
onChange([...value, creatorId])
|
||||
}, [onChange, value])
|
||||
|
||||
const resetCreators = useCallback(() => {
|
||||
onChange([])
|
||||
setKeywords('')
|
||||
}, [onChange])
|
||||
|
||||
const selectedCount = value.length
|
||||
const selectedAvatarCreators = selectedCreators.slice(0, 3)
|
||||
const isSelected = selectedCount > 0
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
baseChipClassName,
|
||||
isSelected
|
||||
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
|
||||
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
{!isSelected && (
|
||||
<>
|
||||
<span className="px-1 text-text-tertiary">{t('studio.filters.allCreators', { ns: 'app' })}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</>
|
||||
)}
|
||||
{isSelected && (
|
||||
<>
|
||||
<span className="px-1 text-text-tertiary">{t('studio.filters.creators', { ns: 'app' })}</span>
|
||||
<span className="flex items-center pr-1">
|
||||
{selectedAvatarCreators.map((creator, index) => (
|
||||
<Avatar
|
||||
key={creator.id}
|
||||
avatar={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size="xs"
|
||||
className={cn(
|
||||
'border border-components-panel-bg',
|
||||
index > 0 && '-ml-1',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-xs leading-4 font-medium text-text-tertiary">{`+${selectedCount}`}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('studio.filters.reset', { ns: 'app' })}
|
||||
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary hover:text-text-tertiary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
resetCreators()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ')
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetCreators()
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill h-3.5 w-3.5" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
|
||||
<div className="flex items-center gap-1 p-2 pb-1">
|
||||
<div className="relative min-w-0 grow">
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
|
||||
onClick={() => setKeywords('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-sm px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={resetCreators}
|
||||
>
|
||||
{t('studio.filters.reset', { ns: 'app' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto px-1 pb-1">
|
||||
{filteredCreators.map((creator) => {
|
||||
const checked = value.includes(creator.id)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={creator.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => toggleCreator(creator.id)}
|
||||
>
|
||||
<Checkbox
|
||||
id={creator.id}
|
||||
checked={checked}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex min-w-0 grow items-center gap-2 px-1">
|
||||
<Avatar
|
||||
avatar={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size="xs"
|
||||
className="border-[0.5px] border-divider-regular"
|
||||
/>
|
||||
<div className="flex min-w-0 grow items-center justify-between gap-2">
|
||||
<span className="truncate text-sm text-text-secondary">{creator.name}</span>
|
||||
{creator.isYou && (
|
||||
<span className="shrink-0 text-sm text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorsFilter
|
||||
@ -17,7 +17,11 @@ const DefaultCards = React.memo(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const Empty = () => {
|
||||
type EmptyProps = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const Empty = ({ message }: EmptyProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -25,7 +29,7 @@ const Empty = () => {
|
||||
<DefaultCards />
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
|
||||
<span className="system-md-medium text-text-tertiary">
|
||||
{t('newApp.noAppsFound', { ns: 'app' })}
|
||||
{message ?? t('newApp.noAppsFound', { ns: 'app' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -20,22 +20,22 @@ describe('useAppsQueryState', () => {
|
||||
expect(result.current.query).toEqual({
|
||||
category: 'all',
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
creatorIDs: [],
|
||||
})
|
||||
expect(typeof result.current.setCategory).toBe('function')
|
||||
expect(typeof result.current.setKeywords).toBe('function')
|
||||
expect(typeof result.current.setIsCreatedByMe).toBe('function')
|
||||
expect(typeof result.current.setCreatorIDs).toBe('function')
|
||||
})
|
||||
|
||||
it('should parse app list filters from URL', () => {
|
||||
const { result } = renderWithAdapter(
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term',
|
||||
)
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
category: AppModeEnum.WORKFLOW,
|
||||
keywords: 'search term',
|
||||
isCreatedByMe: true,
|
||||
creatorIDs: [],
|
||||
})
|
||||
})
|
||||
|
||||
@ -115,30 +115,29 @@ describe('useAppsQueryState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should update created-by-me URL state', async () => {
|
||||
it('should update creator IDs in local state without writing to the URL', () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(true)
|
||||
result.current.setCreatorIDs(['creator-1', 'creator-2'])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
|
||||
expect(update.options.history).toBe('push')
|
||||
expect(result.current.query.creatorIDs).toEqual(['creator-1', 'creator-2'])
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||
it('should clear creator IDs from local state without writing to the URL', () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(false)
|
||||
result.current.setCreatorIDs(['creator-1'])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
|
||||
act(() => {
|
||||
result.current.setCreatorIDs([])
|
||||
})
|
||||
|
||||
expect(result.current.query.creatorIDs).toEqual([])
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,48 +1,41 @@
|
||||
import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { AppModes } from '@/types/app'
|
||||
import type { AppListCategory } from '../app-type-filter-shared'
|
||||
import { debounce, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { parseAsAppListCategory } from '../app-type-filter-shared'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const appListQueryParsers = {
|
||||
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' }),
|
||||
category: parseAsAppListCategory,
|
||||
keywords: parseAsString.withDefault('').withOptions({
|
||||
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
|
||||
}),
|
||||
isCreatedByMe: parseAsBoolean
|
||||
.withDefault(false)
|
||||
.withOptions({ history: 'push' }),
|
||||
}
|
||||
|
||||
export function useAppsQueryState() {
|
||||
const [query, setQuery] = useQueryStates(appListQueryParsers)
|
||||
const [urlQuery, setUrlQuery] = useQueryStates(appListQueryParsers)
|
||||
const [creatorIDs, setCreatorIDs] = useState<string[]>([])
|
||||
|
||||
const setCategory = useCallback((category: AppListCategory) => {
|
||||
setQuery({ category })
|
||||
}, [setQuery])
|
||||
setUrlQuery({ category })
|
||||
}, [setUrlQuery])
|
||||
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery({ keywords })
|
||||
}, [setQuery])
|
||||
setUrlQuery({ keywords })
|
||||
}, [setUrlQuery])
|
||||
|
||||
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
|
||||
setQuery({ isCreatedByMe })
|
||||
}, [setQuery])
|
||||
const handleSetCreatorIDs = useCallback((creatorIDs: string[]) => {
|
||||
setCreatorIDs(creatorIDs)
|
||||
}, [])
|
||||
|
||||
const query = useMemo(() => ({
|
||||
...urlQuery,
|
||||
creatorIDs,
|
||||
}), [creatorIDs, urlQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setIsCreatedByMe,
|
||||
}), [query, setCategory, setKeywords, setIsCreatedByMe])
|
||||
setCreatorIDs: handleSetCreatorIDs,
|
||||
}), [handleSetCreatorIDs, query, setCategory, setKeywords])
|
||||
}
|
||||
|
||||
@ -15,19 +15,29 @@ import { fetchAppDetail } from '@/service/explore'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import List from './list'
|
||||
|
||||
export type StudioPageType = 'apps' | 'snippets'
|
||||
|
||||
type AppsProps = {
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
|
||||
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
|
||||
const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false })
|
||||
|
||||
const Apps = () => {
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const { replace } = useRouter()
|
||||
const templateId = searchParams.get('template-id')
|
||||
const templateDismissedRef = useRef(false)
|
||||
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@ -165,7 +175,7 @@ const Apps = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@ -1,30 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { AppListQuery } from '@/contract/console/apps'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import { AppTypeFilter } from './app-type-filter'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
|
||||
import NewAppCard from './new-app-card'
|
||||
@ -38,9 +41,11 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
pageType = 'apps',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@ -51,10 +56,10 @@ const List: FC<Props> = ({
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
query: { category, keywords, isCreatedByMe },
|
||||
query: { category, keywords, creatorIDs },
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setIsCreatedByMe,
|
||||
setCreatorIDs,
|
||||
} = useAppsQueryState()
|
||||
const [tagIDs, setTagIDs] = useState<string[]>([])
|
||||
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
|
||||
@ -63,6 +68,7 @@ const List: FC<Props> = ({
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const [needRefreshAppList, setNeedRefreshAppList] = useLocalStorage<string>(NEED_REFRESH_APP_LIST_KEY, '0', { raw: true })
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
setDroppedDSLFile(file)
|
||||
@ -90,9 +96,9 @@ const List: FC<Props> = ({
|
||||
limit: 30,
|
||||
name: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
|
||||
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
|
||||
...(category !== 'all' ? { mode: category } : {}),
|
||||
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
|
||||
}), [category, creatorIDs, debouncedKeywords, tagIDs])
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -126,21 +132,13 @@ const List: FC<Props> = ({
|
||||
}, [controlRefreshList, refetch])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||
if (needRefreshAppList === '1') {
|
||||
setNeedRefreshAppList(null)
|
||||
refetch()
|
||||
}
|
||||
}, [refetch])
|
||||
}, [needRefreshAppList, refetch, setNeedRefreshAppList])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
@ -172,10 +170,6 @@ const List: FC<Props> = ({
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
const handleCreatedByMeChange = useCallback((checked: boolean) => {
|
||||
setIsCreatedByMe(checked)
|
||||
}, [setIsCreatedByMe])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
|
||||
@ -207,32 +201,45 @@ const List: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<TabSliderNew
|
||||
value={category}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setCategory(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="mr-2 flex h-7 items-center space-x-2">
|
||||
<Checkbox checked={isCreatedByMe} onCheckedChange={handleCreatedByMeChange} />
|
||||
<div className="text-sm font-normal text-text-secondary">
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AppTypeFilter
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
<CreatorsFilter
|
||||
value={creatorIDs}
|
||||
onChange={setCreatorIDs}
|
||||
/>
|
||||
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<div className="relative w-50">
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('operation.search', { ns: 'common' })}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
|
||||
onClick={() => setKeywords('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{pageType === 'apps' && (
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
|
||||
>
|
||||
{t('studio.viewSnippets', { ns: 'app' })}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
@ -260,7 +267,7 @@ const List: FC<Props> = ({
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))
|
||||
: <Empty />}
|
||||
: <Empty message={pageType === 'snippets' ? t('tabs.noSnippetsFound', { ns: 'workflow' }) : undefined} />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
@ -0,0 +1,191 @@
|
||||
import {
|
||||
flushRegistrationSuccess,
|
||||
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||
rememberRegistrationSuccess,
|
||||
} from '../registration-tracking'
|
||||
|
||||
const mockTrackEvent = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
describe('registration tracking', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
|
||||
// Captures the registration event for a later flush instead of firing it right away.
|
||||
describe('rememberRegistrationSuccess', () => {
|
||||
it('should store the base event and not track immediately when there is no utm info', () => {
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
|
||||
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
|
||||
eventName: 'user_registration_success',
|
||||
properties: { method: 'email' },
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should store the utm event and merge utm info into properties when utm info is present', () => {
|
||||
rememberRegistrationSuccess({
|
||||
method: 'oauth',
|
||||
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
})
|
||||
|
||||
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
|
||||
eventName: 'user_registration_success_with_utm',
|
||||
properties: { method: 'oauth', utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should swallow errors when writing to sessionStorage fails', () => {
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: () => {
|
||||
throw new Error('quota exceeded')
|
||||
},
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Replays the remembered event exactly once, after the user ID has been attached.
|
||||
describe('flushRegistrationSuccess', () => {
|
||||
it('should track the remembered event and clear it from storage', () => {
|
||||
rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'blog' } })
|
||||
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'email',
|
||||
utm_source: 'blog',
|
||||
})
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should do nothing when there is no pending event', () => {
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire the event at most once across repeated flushes', () => {
|
||||
rememberRegistrationSuccess({ method: 'oauth' })
|
||||
|
||||
flushRegistrationSuccess()
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear malformed pending data without tracking', () => {
|
||||
window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, '{not-json')
|
||||
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear the pending entry without tracking when it has no event name', () => {
|
||||
window.sessionStorage.setItem(
|
||||
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||
JSON.stringify({ properties: { method: 'email' } }),
|
||||
)
|
||||
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should stop without tracking when reading from sessionStorage throws', () => {
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: () => {
|
||||
throw new Error('read failed')
|
||||
},
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('should still track when clearing the pending entry fails', () => {
|
||||
const pending = { eventName: 'user_registration_success', properties: { method: 'email' } }
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: () => JSON.stringify(pending),
|
||||
setItem: vi.fn(),
|
||||
removeItem: () => {
|
||||
throw new Error('remove failed')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { method: 'email' })
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Both producers and the consumer must degrade gracefully when sessionStorage is
|
||||
// missing (SSR) or blocked (privacy mode / disabled storage).
|
||||
describe('when sessionStorage is unavailable', () => {
|
||||
it('should no-op without throwing when window is undefined', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('should no-op without throwing when accessing sessionStorage throws', () => {
|
||||
vi.stubGlobal('window', {
|
||||
get sessionStorage() {
|
||||
throw new Error('storage disabled')
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'oauth' })).not.toThrow()
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
88
web/app/components/base/amplitude/registration-tracking.ts
Normal file
88
web/app/components/base/amplitude/registration-tracking.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { trackEvent } from './utils'
|
||||
|
||||
/**
|
||||
* Storage key for a registration success event that is waiting to be sent to
|
||||
* Amplitude until a user ID has been attached.
|
||||
*/
|
||||
export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event'
|
||||
|
||||
type RegistrationMethod = 'email' | 'oauth'
|
||||
|
||||
type PendingRegistrationSuccessEvent = {
|
||||
eventName: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
const getSessionStorage = (): Storage | null => {
|
||||
try {
|
||||
if (typeof window === 'undefined')
|
||||
return null
|
||||
return window.sessionStorage
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a registration success event so it can be sent to Amplitude *after* the
|
||||
* user ID is attached (see `flushRegistrationSuccess`).
|
||||
*
|
||||
* Amplitude attributes events to whatever identity is active when `track` runs. At
|
||||
* registration time the client does not yet know the user ID, so firing the event
|
||||
* immediately records it under an anonymous profile. We persist the event here and
|
||||
* replay it once `setUserId` runs in the app context provider after the redirect.
|
||||
*/
|
||||
export const rememberRegistrationSuccess = (
|
||||
{ method, utmInfo }: { method: RegistrationMethod, utmInfo?: Record<string, unknown> | null },
|
||||
) => {
|
||||
const storage = getSessionStorage()
|
||||
if (!storage)
|
||||
return
|
||||
|
||||
const pending: PendingRegistrationSuccessEvent = {
|
||||
eventName: utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success',
|
||||
properties: { method, ...utmInfo },
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending))
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a previously remembered registration success event to Amplitude.
|
||||
*
|
||||
* MUST be called after `setUserId` so the event lands on the identified user profile.
|
||||
* No-op when nothing is pending. The pending entry is removed before tracking so the
|
||||
* event fires at most once even if this runs multiple times.
|
||||
*/
|
||||
export const flushRegistrationSuccess = () => {
|
||||
const storage = getSessionStorage()
|
||||
if (!storage)
|
||||
return
|
||||
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!raw)
|
||||
return
|
||||
|
||||
try {
|
||||
storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent
|
||||
if (pending?.eventName)
|
||||
trackEvent(pending.eventName, pending.properties)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
@ -5,7 +5,7 @@ import type {
|
||||
EditorState,
|
||||
} from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
|
||||
import type { Hotkey, ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
CurrentBlockType,
|
||||
@ -131,7 +131,11 @@ export type PromptEditorProps = {
|
||||
errorMessageBlock?: ErrorMessageBlockType
|
||||
lastRunBlock?: LastRunBlockType
|
||||
isSupportFileVar?: boolean
|
||||
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> }>
|
||||
shortcutPopups?: Array<{
|
||||
hotkey: Hotkey
|
||||
displayMode?: ShortcutPopupDisplayMode
|
||||
Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }>
|
||||
}>
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
|
||||
@ -91,6 +91,29 @@ describe('InputField', () => {
|
||||
lastVarReferencePickerProps = undefined
|
||||
})
|
||||
|
||||
it('should keep the header and actions visible while the field content scrolls internally', () => {
|
||||
const { container } = render(
|
||||
<InputField
|
||||
nodeId="node-layout"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const panel = container.firstElementChild
|
||||
const header = panel?.children[0]
|
||||
const scrollBody = panel?.children[1]
|
||||
const footer = panel?.lastElementChild
|
||||
|
||||
expect(panel).toHaveClass('max-h-(--shortcut-popup-max-height)', 'overflow-hidden')
|
||||
expect(header).toHaveClass('shrink-0', 'pb-2')
|
||||
expect(scrollBody).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto')
|
||||
expect(footer).toHaveClass('shrink-0', 'bg-components-panel-bg')
|
||||
expect(footer).not.toHaveClass('border-t')
|
||||
})
|
||||
|
||||
it('should disable save and show validation error when variable name is invalid', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@ -3,6 +3,7 @@ import type { FormInputItem, FormInputItemDefault, ParagraphFormInput } from '@/
|
||||
import type { UploadFileSetting, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { produce } from 'immer'
|
||||
@ -11,7 +12,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TypeSelector from '@/app/components/app/configuration/config-var/config-modal/type-select'
|
||||
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
|
||||
import Input from '@/app/components/base/input'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import {
|
||||
@ -206,9 +206,11 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
}, [handleSave])
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[540px] w-[372px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 pb-0">
|
||||
<div className="flex max-h-(--shortcut-popup-max-height) w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
<div className="shrink-0 p-3 pb-2">
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 pt-0 pb-0">
|
||||
<div className="mt-3">
|
||||
<div className="system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.fieldType`, { ns: 'workflow' })}
|
||||
@ -322,7 +324,7 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 p-3">
|
||||
<div className="shrink-0 bg-components-panel-bg p-3">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
{isEdit
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ShortcutPopupInsertHandler } from '../index'
|
||||
import type { ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from '../index'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
@ -50,6 +50,7 @@ const CONTENT_EDITABLE_ID = 'ce'
|
||||
type MinimalEditorProps = {
|
||||
withContainer?: boolean
|
||||
hotkey?: string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
|
||||
displayMode?: ShortcutPopupDisplayMode
|
||||
children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode)
|
||||
className?: string
|
||||
onOpen?: () => void
|
||||
@ -59,6 +60,7 @@ type MinimalEditorProps = {
|
||||
const MinimalEditor: React.FC<MinimalEditorProps> = ({
|
||||
withContainer = true,
|
||||
hotkey,
|
||||
displayMode,
|
||||
children,
|
||||
className,
|
||||
onOpen,
|
||||
@ -83,6 +85,7 @@ const MinimalEditor: React.FC<MinimalEditorProps> = ({
|
||||
<ShortcutsPopupPlugin
|
||||
container={withContainer ? containerEl : undefined}
|
||||
hotkey={hotkey}
|
||||
displayMode={displayMode}
|
||||
className={className}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
@ -179,7 +182,9 @@ describe('ShortcutsPopupPlugin', () => {
|
||||
const host = screen.getByTestId(CONTAINER_ID)
|
||||
focusAndTriggerHotkey('/')
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
const floatingDiv = screen.getByTestId('shortcuts-popup')
|
||||
expect(host).toContainElement(portalContent)
|
||||
expect(floatingDiv).toHaveStyle({ position: 'absolute' })
|
||||
})
|
||||
|
||||
it('falls back to document.body when container is not provided', async () => {
|
||||
@ -188,10 +193,65 @@ describe('ShortcutsPopupPlugin', () => {
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
const floatingDiv = screen.getByTestId('shortcuts-popup')
|
||||
expect(document.body).toContainElement(portalContent)
|
||||
expect(floatingDiv).toHaveStyle({ position: 'fixed' })
|
||||
expect(floatingDiv).toHaveStyle({ zIndex: '50' })
|
||||
expect(floatingDiv).toHaveStyle({ overflow: 'visible' })
|
||||
})
|
||||
|
||||
it('clips the popup viewport so child popups own their internal scrolling', async () => {
|
||||
render(<MinimalEditor />)
|
||||
focusAndTriggerHotkey('/')
|
||||
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
|
||||
const floatingDiv = screen.getByTestId('shortcuts-popup')
|
||||
expect(floatingDiv.firstElementChild).toHaveClass('overflow-hidden')
|
||||
})
|
||||
|
||||
it('can render fixed next to the workflow panel instead of following the cursor', async () => {
|
||||
const originalInnerWidth = window.innerWidth
|
||||
const originalInnerHeight = window.innerHeight
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 })
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
|
||||
|
||||
const rightPanel = document.createElement('div')
|
||||
rightPanel.setAttribute('data-workflow-right-panel', '')
|
||||
rightPanel.getBoundingClientRect = vi.fn(() => ({
|
||||
x: 800,
|
||||
y: 56,
|
||||
width: 400,
|
||||
height: 840,
|
||||
top: 56,
|
||||
right: 1200,
|
||||
bottom: 896,
|
||||
left: 800,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect))
|
||||
document.body.appendChild(rightPanel)
|
||||
|
||||
try {
|
||||
render(<MinimalEditor withContainer={false} displayMode="workflow-panel-adjacent-center" />)
|
||||
focusAndTriggerHotkey('/')
|
||||
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
|
||||
const floatingDiv = screen.getByTestId('shortcuts-popup')
|
||||
await waitFor(() => {
|
||||
expect(floatingDiv).toHaveStyle({
|
||||
position: 'fixed',
|
||||
right: '404px',
|
||||
top: '474px',
|
||||
transform: 'translateY(-50%)',
|
||||
})
|
||||
})
|
||||
expect(floatingDiv.style.getPropertyValue('--shortcut-popup-max-width')).toBe('400px')
|
||||
expect(floatingDiv.style.getPropertyValue('--shortcut-popup-max-height')).toBe('836px')
|
||||
}
|
||||
finally {
|
||||
rightPanel.remove()
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── matchHotkey: string hotkey ───
|
||||
it('matches a string hotkey like "mod+/"', async () => {
|
||||
render(<MinimalEditor hotkey="mod+/" />)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { LexicalCommand } from 'lexical'
|
||||
import type { CSSProperties } from 'react'
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
@ -19,11 +20,13 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
|
||||
export type ShortcutPopupInsertHandler = <Payload>(command: LexicalCommand<Payload>, params: Payload) => void
|
||||
export type ShortcutPopupDisplayMode = 'selection' | 'workflow-panel-adjacent-center'
|
||||
|
||||
// Hotkey can be:
|
||||
// - string: 'mod+/'
|
||||
@ -37,10 +40,85 @@ type ShortcutPopupPluginProps = {
|
||||
children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode)
|
||||
className?: string
|
||||
container?: Element | null
|
||||
displayMode?: ShortcutPopupDisplayMode
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const VIEWPORT_PADDING = 8
|
||||
const PANEL_GAP = 4
|
||||
const POPUP_MAX_WIDTH = 400
|
||||
|
||||
type FixedPlacementState = {
|
||||
right: number
|
||||
top: number
|
||||
availableWidth: number
|
||||
availableHeight: number
|
||||
}
|
||||
|
||||
function getWorkflowPanelAdjacentPlacement(): FixedPlacementState {
|
||||
const rightPanel = document.querySelector('[data-workflow-right-panel]') as HTMLElement | null
|
||||
const rightPanelRect = rightPanel?.getBoundingClientRect()
|
||||
const rightBoundary = rightPanelRect && rightPanelRect.left > 0
|
||||
? rightPanelRect.left
|
||||
: window.innerWidth
|
||||
const topBoundary = rightPanelRect?.top ?? 56
|
||||
const bottomBoundary = window.innerHeight - VIEWPORT_PADDING
|
||||
const availableWidth = Math.max(0, rightBoundary - VIEWPORT_PADDING * 2)
|
||||
const availableHeight = Math.max(0, bottomBoundary - topBoundary)
|
||||
|
||||
return {
|
||||
right: Math.max(VIEWPORT_PADDING, window.innerWidth - rightBoundary + PANEL_GAP),
|
||||
top: topBoundary + availableHeight / 2,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkflowPanelAdjacentPlacementSnapshot() {
|
||||
/* v8 ignore next 2 -- server/non-browser fallback for a client-only positioning branch. @preserve */
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined')
|
||||
return '0|0|0|0'
|
||||
|
||||
const placement = getWorkflowPanelAdjacentPlacement()
|
||||
return [
|
||||
placement.right,
|
||||
placement.top,
|
||||
placement.availableWidth,
|
||||
placement.availableHeight,
|
||||
].join('|')
|
||||
}
|
||||
|
||||
function parseWorkflowPanelAdjacentPlacement(snapshot: string): FixedPlacementState {
|
||||
const [right = '0', top = '0', availableWidth = '0', availableHeight = '0'] = snapshot.split('|')
|
||||
return {
|
||||
right: Number(right),
|
||||
top: Number(top),
|
||||
availableWidth: Number(availableWidth),
|
||||
availableHeight: Number(availableHeight),
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeWorkflowPanelAdjacentPlacement(callback: () => void) {
|
||||
/* v8 ignore next 2 -- server/non-browser fallback for a client-only positioning branch. @preserve */
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined')
|
||||
return () => {}
|
||||
|
||||
window.addEventListener('resize', callback)
|
||||
|
||||
const rightPanel = document.querySelector('[data-workflow-right-panel]')
|
||||
const resizeObserver = rightPanel && typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(callback)
|
||||
: null
|
||||
if (rightPanel)
|
||||
resizeObserver?.observe(rightPanel)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', callback)
|
||||
resizeObserver?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
const META_ALIASES = new Set(['meta', 'cmd', 'command'])
|
||||
const CTRL_ALIASES = new Set(['ctrl'])
|
||||
const ALT_ALIASES = new Set(['alt', 'option'])
|
||||
@ -134,11 +212,17 @@ export default function ShortcutsPopupPlugin({
|
||||
children,
|
||||
className,
|
||||
container,
|
||||
displayMode = 'selection',
|
||||
onOpen,
|
||||
onClose,
|
||||
}: ShortcutPopupPluginProps): React.ReactPortal | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const workflowPanelAdjacentPlacementSnapshot = useSyncExternalStore(
|
||||
subscribeWorkflowPanelAdjacentPlacement,
|
||||
getWorkflowPanelAdjacentPlacementSnapshot,
|
||||
() => '0|0|0|0',
|
||||
)
|
||||
const portalRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastSelectionRef = useRef<Range | null>(null)
|
||||
|
||||
@ -148,6 +232,7 @@ export default function ShortcutsPopupPlugin({
|
||||
|
||||
const { refs, floatingStyles, isPositioned } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
strategy: useContainer ? 'absolute' : 'fixed',
|
||||
middleware: [
|
||||
offset(0), // fix hide cursor
|
||||
shift({
|
||||
@ -192,6 +277,12 @@ export default function ShortcutsPopupPlugin({
|
||||
}, [editor])
|
||||
|
||||
const openPortal = useCallback(() => {
|
||||
if (displayMode !== 'selection') {
|
||||
setOpen(true)
|
||||
onOpen?.()
|
||||
return
|
||||
}
|
||||
|
||||
const domSelection = window.getSelection()
|
||||
let range: Range | null = null
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
@ -237,7 +328,7 @@ export default function ShortcutsPopupPlugin({
|
||||
|
||||
setOpen(true)
|
||||
onOpen?.()
|
||||
}, [editor, onOpen, refs])
|
||||
}, [displayMode, editor, onOpen, refs])
|
||||
|
||||
const closePortal = useCallback(() => {
|
||||
setOpen(false)
|
||||
@ -292,25 +383,44 @@ export default function ShortcutsPopupPlugin({
|
||||
if (!open || !containerEl)
|
||||
return null
|
||||
|
||||
const isFixedPanelAdjacent = displayMode === 'workflow-panel-adjacent-center'
|
||||
const fixedPlacementState = parseWorkflowPanelAdjacentPlacement(workflowPanelAdjacentPlacementSnapshot)
|
||||
const fixedPanelAdjacentStyles: CSSProperties = isFixedPanelAdjacent
|
||||
? {
|
||||
position: 'fixed',
|
||||
right: fixedPlacementState.right,
|
||||
top: fixedPlacementState.top,
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 50,
|
||||
overflow: 'visible',
|
||||
visibility: 'visible',
|
||||
['--shortcut-popup-max-width' as string]: `${Math.min(POPUP_MAX_WIDTH, fixedPlacementState.availableWidth)}px`,
|
||||
['--shortcut-popup-max-height' as string]: `${fixedPlacementState.availableHeight}px`,
|
||||
} as CSSProperties
|
||||
: {}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-testid="shortcuts-popup"
|
||||
ref={(node) => {
|
||||
portalRef.current = node
|
||||
refs.setFloating(node)
|
||||
if (!isFixedPanelAdjacent)
|
||||
refs.setFloating(node)
|
||||
}}
|
||||
className={cn(
|
||||
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: useContainer ? undefined : 50,
|
||||
overflow: 'visible',
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
style={isFixedPanelAdjacent
|
||||
? fixedPanelAdjacentStyles
|
||||
: {
|
||||
...floatingStyles,
|
||||
zIndex: useContainer ? undefined : 50,
|
||||
overflow: 'visible',
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-x-hidden overflow-y-auto rounded-xl">
|
||||
<div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-hidden rounded-xl">
|
||||
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user