Compare commits

..

280 Commits

Author SHA1 Message Date
2ff07b6311 mysql support in migration 2026-05-22 17:38:03 +08:00
1554d80df5 [autofix.ci] apply automated fixes 2026-05-22 09:20:07 +00:00
7ec50f4656 fix typecheck && migration 2026-05-22 17:15:27 +08:00
66c4b9d589 remove docker build for current stage 2026-05-22 16:24:48 +08:00
cb218f2832 add permission constraint to smoke test 2026-05-22 16:17:55 +08:00
ed6a079582 [autofix.ci] apply automated fixes 2026-05-22 08:16:10 +00:00
f1d68e4178 use softprops/action-gh-release for release 2026-05-22 16:11:33 +08:00
851bf36f24 Merge remote-tracking branch 'upstream/main' into feat/cli 2026-05-22 16:07:59 +08:00
f6e4d558a6 Revert "test(cli): add integration test suite for Discovery, Run, Output, Error Handling and CLI Framework"
This reverts commit c38c5d375e.
2026-05-22 15:53:38 +08:00
yyh
93b7a81071 fix(dify-ui): align form label guidance (#36510) 2026-05-22 07:29:57 +00:00
157e6244dd refactor: add missing @override decorator to agent runners, tool caches, and logging extensions (#36511) 2026-05-22 06:41:48 +00:00
yyh
964aaad7ed refactor: streamline workflow context menu lifecycle (#36500) 2026-05-22 04:31:39 +00:00
c38c5d375e test(cli): add integration test suite for Discovery, Run, Output, Error Handling and CLI Framework
Add comprehensive integration tests under cli/test/testcases/ covering:

Discovery:
- App list (list, single, all-workspaces)
- Describe App
- Cross-workspace query

Run:
- Basic App execution
- Streaming output
- HITL (Human-in-the-Loop) — all 19 cases incl. multi-action / expired-token / already-consumed
- File input
- Conversation mode
- Environment variable injection
- Cache and version consistency

Output:
- JSON/YAML output
- Table output

Error Handling:
- Exit code end-to-end validation
- Error message spec

CLI Framework:
- Global Flags
- Non-Interactive mode

Also extend test fixtures:
- scenarios.ts: add hitl-pause-multi-action / hitl-resume-expired-token / hitl-resume-already-consumed
- server.ts: add GET /form/human_input route, multi-action HITL response, expired/consumed token error handling

Known bugs tracked as it.todo:
- WTA-249: server 4xx in -o json mode exit code should be 1 (currently 0 in some cases)
- WTA-252: --help missing GLOBAL FLAGS section and Quick start examples
- WTA-255: hosts.yml YAML parse failure should output JSON envelope
- WTA-257: uncaught TypeError should output JSON envelope in -o json mode
2026-05-22 10:46:18 +08:00
92181dbe09 fix(api): preserve remote file URL query params (#36478) 2026-05-22 01:45:20 +00:00
30deef45d9 fix(api): pass SSL verify flag to SSRF proxy mounts (#36455) 2026-05-22 01:31:46 +00:00
ee28074390 refactor: add missing @override decorator to Moderation subclasses (#36492) 2026-05-21 19:42:20 +00:00
1fb491337b refactor: add missing @override decorator to datasource plugin classes (#36494) 2026-05-21 19:41:42 +00:00
82b0a03f5a refactor: add missing @override decorator to PluginModelRuntime (#36493) 2026-05-21 19:40:40 +00:00
6185016910 refactor: add missing @override decorator to file access controller and workflow file runtime (#36495) 2026-05-21 19:39:51 +00:00
b4f5f4869f refactor: add missing @override decorator to code executor providers and transformers (#36496) 2026-05-21 19:39:10 +00:00
7ecbed3b04 chore: add Type to test (#36454)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-21 19:25:03 +00:00
5b58defd62 refactor: add missing @override decorator to GraphEngineLayer subclasses (#36491) 2026-05-21 16:32:02 +00:00
73196de5e1 refactor: add missing @override decorator to AppQueueManager subclasses (#36490) 2026-05-21 16:25:07 +00:00
ea5e487d3c fix(api): stop returning 204 with response body and add CI check (#36489) 2026-05-21 16:20:34 +00:00
f19702f76c feat(api): Flask-RESTX response() vs actual return value checker (#36488) 2026-05-21 15:05:06 +00:00
092c8bca81 refactor(api): migrate console.datasets.metadata to BaseModel (#36450)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-21 15:04:42 +00:00
c50d504c44 refactor: add missing @override decorator to AppGenerateResponseConverter subclasses (#36486) 2026-05-21 14:00:22 +00:00
1b4356b66a fix(ci): bad pyinfra type coverage report comments (#36482) 2026-05-21 12:08:24 +00:00
yyh
7f633622aa fix(web): use popup-open selectors for trigger styles (#36471) 2026-05-21 06:13:11 +00:00
yyh
66f5ab4cfc feat: add dify-ui input primitive (#36446) 2026-05-21 03:15:38 +00:00
0cf9597f52 fix: suggested questions API crash on legacy conversation override configs (#36459) 2026-05-21 01:58:52 +00:00
60cd346fa6 feat: wire workflow agent node runtime (#36437)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 12:39:45 +00:00
56d4d54c16 chore: compatiable conversation is not exists (#33274)
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-20 12:37:33 +00:00
yyh
9f9cb4d17e feat(ui): migrate radio to Base UI and update web callsites (#36451)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 12:05:31 +00:00
yyh
7d0d9019d8 chore: upgrade base ui to 1.5.0 (#36442) 2026-05-20 09:58:08 +00:00
d646bcf257 chore: remove unused pyrefly ignore comments in dataset.py (#36443)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 09:14:43 +00:00
e3b45a48eb fix: allow config pubsub join timeout for lower post-run latency (#36438)
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
2026-05-20 08:45:51 +00:00
848c15a265 chore: update to only SaaS can view template (#36440) 2026-05-20 08:18:26 +00:00
yyh
be8627233d ci: show web test shard failures (#36436) 2026-05-20 08:03:15 +00:00
1fe8b7fb1d fix(auth): use validity-returned token in ChangePasswordForm reset submit (#36415) 2026-05-20 07:59:09 +00:00
yyh
5a585c8618 refactor(web): use dropdown data attributes (#36431)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 07:32:11 +00:00
cc9b90a5ae chore(api): cap non-dev dependency major versions (#36429) 2026-05-20 07:25:50 +00:00
b64d4b53ca chore: move API readiness reporting to terminal output (#36433) 2026-05-20 07:23:35 +00:00
5cdf4e405b fix(web): debounce email check when change email (#36421)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JzoNgKVO <27049666+JzoNgKVO@users.noreply.github.com>
2026-05-20 07:09:15 +00:00
7cb14cb4cc chore(codeowners): assign trigger scheduler ownership (#36430) 2026-05-20 06:48:34 +00:00
de38bba99b chore: example for [Refactor/Chore] add missing-override-decorator #36406 (#36425)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 06:16:52 +00:00
f04d809426 fix(api): fix invalid token error while changing email (#36412) 2026-05-20 05:51:15 +00:00
7ed3c7c500 chore: Check more files (#36407)
Co-authored-by: 99 <wh2099@pm.me>
2026-05-20 04:20:18 +00:00
yyh
77f1aeb1ac fix(web): resolve model provider console warnings (#36422) 2026-05-20 04:02:01 +00:00
7bc5c89e3c fix: prevent recursion error when SharePoint folder is empty (#36372) 2026-05-20 03:56:49 +00:00
718ab8433e chore: bump versions for litellm and langsmith (#36385) 2026-05-20 03:50:05 +00:00
8f197c5a0a build: fix api docker build (#36423) 2026-05-20 03:48:18 +00:00
0295862d0d chore(deps): bump the storage group across 1 directory with 4 updates (#36393)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 03:41:43 +00:00
2b2a5824c1 refactor: migrate to tailwind v4 style (#36417) 2026-05-20 03:39:44 +00:00
yyh
468cc19e68 fix(web): prevent local cloud analytics script errors (#36420) 2026-05-20 03:23:21 +00:00
77333e57a7 refactor: convert isinstance chains to match/case pattern (#36364) 2026-05-20 03:07:19 +00:00
5381452de9 feat(cli,api): difyctl version probes server and reports compat verdict (#36356)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:27:34 +08:00
f52491e2c1 chore: update deps (#36413) 2026-05-20 01:48:30 +00:00
ed5f6b153f fix(cli): color hint lines in BaseError human output
Apply magenta prefix + cyan value to all hint lines rendered via
formatErrorForCli, covering logout, authed-command guard, and any
other BaseError path. Thread isErrTTY from process.stderr.isTTY.
2026-05-19 15:35:11 -07:00
6f760a3901 fix(api): restore conversation history for OPENAPI invoke source
OPENAPI callers (difyctl) don't send parent_message_id, causing
extract_thread_messages to treat each message as a new thread start
and return empty history. Apply UUID_NIL sentinel for OPENAPI the
same way SERVICE_API does, keeping the two invoke sources distinct.
2026-05-19 14:13:24 -07:00
0bf64ca3f2 fix(cli): color hints with magenta prefix and cyan values 2026-05-19 13:53:44 -07:00
e827aca154 fix(cli): hide button_style labels from HITL actions display 2026-05-19 13:45:38 -07:00
8de813c867 feat(cli+api): --file flag support for local upload and remote URL inputs 2026-05-19 13:42:25 -07:00
05408af8a1 fix: fix add uv_cache_dir env (#36398) 2026-05-19 17:56:54 +00:00
yyh
d3ae074456 chore(web): remove generic tailwind skill (#36402) 2026-05-19 13:16:10 +00:00
0b48a7e991 fix: workflow node selection state not sync caused problem (#36390) 2026-05-19 12:29:16 +00:00
809f513ccb chore(codeowners): update plugin ownership (#36394) 2026-05-19 19:25:49 +08:00
d9e90d0fa0 feat: add new agent (#36284)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 10:43:23 +00:00
d1417bbe4b fix(api): add Phoenix wrapper spans and error tracing (#36388)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 10:09:23 +00:00
2565637e36 test: stabilize trigger subscription name uniqueness setup (#36353) 2026-05-19 10:09:02 +00:00
cae9923e5a fix: prevent agent tool info popover from jumping on close (#36389)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-05-19 09:54:52 +00:00
yyh
a328bbbced feat(dev-proxy): reload env file changes (#36384) 2026-05-19 08:24:47 +00:00
5276eb689b chore: hide model provider setting in default model setting (#36383) 2026-05-19 08:19:58 +00:00
yyh
4b2badb6f2 refactor(web): migrate multi-checkbox lists to CheckboxGroup (#36381)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 07:55:33 +00:00
34a89416f7 test(api): manage backend pytest services natively (#36235) 2026-05-19 07:52:15 +00:00
d09d360530 feat(cli): HITL compact block, resume status framing, spinner fix
- renderHitlBlock: compact inline Actions/Inputs, no blank lines,
  rename Prompt→Message, indent multi-line form_content by 4 spaces
- resumeApp: write ✓ form submitted / workflow execution resumed / ✓ workflow finished
  to stderr in text mode only; JSON/non-text formats are unaffected
- StreamingStructuredStrategy: call spinner.stop() before exit(0) in
  HITL catch to prevent spinner bleed onto pause block output
2026-05-19 00:48:43 -07:00
8a4c87234f chore: switched to shared contract (#36380) 2026-05-19 15:19:41 +08:00
31ea69be66 feat(cli): add release pipeline (#36365) 2026-05-19 14:51:28 +08:00
a13ab76002 fix(agenton): use AsyncGenerator return annotation for asynccontextmanager (#36361)
Co-authored-by: Arya Rizky <algojogacor@users.noreply.github.com>
2026-05-19 06:19:35 +00:00
b04b4449db chore(api): annotate simple contract responses (#36331)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-05-19 06:13:20 +00:00
6de46024a3 fix(cli): CJK table alignment, workflow plain-text output, HITL label rename
- formatTable: use displayWidth() for CJK/fullwidth chars instead of
  String.length, so double-wide characters don't misalign columns
- workflowTextHandler: print value directly when outputs has exactly one
  string key; fall back to JSON for multi-key or non-string outputs
- hitl-render: rename 'Prompt:' label to 'Message:' — less ambiguous
  than 'prompt' in an LLM context
2026-05-18 23:03:07 -07:00
yyh
674cdc3521 feat(dev-proxy): isolate local auth cookies by target (#36371) 2026-05-19 05:59:55 +00:00
yyh
2031d31ee8 refactor(web): migrate annotation selection to checkbox group (#36370) 2026-05-19 05:40:24 +00:00
yyh
04d62867af feat(dify-ui): add shared form primitives (#36334)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 05:38:57 +00:00
7f392b6950 chore(release): bump version to 1.14.2 (#36313)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 13:27:26 +08:00
b0a3399774 feat: enhance app creation tracking with source and template ID (#36369)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-19 05:02:17 +00:00
0d5173f73f fix(cli): client-side --mode enum validation, EPIPE guard, stderr redirects
- Reject invalid --mode values before HTTP (e.g. "chatbot" → clear error)
- Add options field to FlagDefinition + validateFlagOptions in parseArgv
- EPIPE exit-0 guard in run.ts catch block
- tree:gen/tree:check diagnostic output redirected to stderr
- Tailwind class sort in web/app/device/components/code-input.tsx
2026-05-18 21:54:49 -07:00
fd1ebdd6cb chore(web): device-flow lint fixes 2026-05-18 21:54:49 -07:00
9fe7adaf69 test(web): terminal state tests — tighter reset assertions + lookup_failed reset coverage 2026-05-18 21:54:49 -07:00
7a6c84dca3 test(web): device-flow terminal state ghost reset buttons 2026-05-18 21:54:49 -07:00
75a8120152 test(web): authorize-sso — add approveExternal args, loadErr, onError, mock reset coverage 2026-05-18 21:54:49 -07:00
ca103b60cc feat(web): device-flow authorize-sso — Avatar card + Button 2026-05-18 21:54:48 -07:00
2c90cfa00f test(web): authorize-account — add onDenied, error-path, mock reset coverage 2026-05-18 21:54:48 -07:00
6851624dbe feat(web): device-flow authorize-account — Avatar card + accountName/avatarUrl props 2026-05-18 21:54:48 -07:00
44d1b66c93 test(web): chooser — add encodeURIComponent + SSO href coverage 2026-05-18 21:54:48 -07:00
f372eb8e5b test(web): assert setPostLoginRedirect called in chooser account button test 2026-05-18 21:54:48 -07:00
36101c7126 feat(web): device-flow chooser — Button + icons 2026-05-18 21:54:48 -07:00
fe212003b1 fix(web): clear errMsg when resetting to code_entry from terminal states 2026-05-18 21:54:47 -07:00
948214fe6a feat(web): device-flow page — signin shell, Button, terminal icons + ghost actions 2026-05-18 21:54:47 -07:00
14328634b5 feat(web): device-flow layout shell + header (signin parity) 2026-05-18 21:54:47 -07:00
de0a44be06 Merge branch 'main' into feat/cli 2026-05-19 10:37:42 +08:00
6153a6b663 [autofix.ci] apply automated fixes 2026-05-19 02:29:28 +00:00
d5dee5326e feat(cli): align HITL pause envelope, split resume into top-level command, JSON purity
- Match server SSE envelope: HitlPausePayload now mirrors {event, task_id,
  workflow_run_id, data:{...}}; renamed user_actions → actions.
- New shared hitl-render.ts: text-mode block, JSON-mode pretty pure JSON
  (no ANSI), colored stderr hint. Exit 0 on pause (no longer a process error).
- Move RunAppResume → top-level ResumeApp at commands/resume/app/; rewire
  imports + regen tree.generated.ts. User-facing strings updated to
  `difyctl resume app`.
- Spinner gated on text mode: structured strategy passes
  enabled = isText && !livePrint so -o json/yaml never spin.
- Hint emits external-channel note when form_token is null (email-only
  delivery to OPENAPI/SERVICE_API surface returns no resume token).
- ColorScheme extended with dim/cyan/green/yellow/magenta methods.
- dify-mock fixture + tests updated to envelope shape; HITL pause test
  split into text + json variants.
2026-05-18 19:25:13 -07:00
49b33647e7 fix(api): map OPENAPI invoke_from to STANDALONE_WEB_APP token surface
OPENAPI workflow runs previously resolved form_token via console-priority
fallback, returning a CONSOLE token that the OPENAPI resume endpoint rejects
with 404. Add a dispatch map so SERVICE_API and OPENAPI invocations both
filter recipients down to their allowed surface; everything else keeps the
priority fallback.
2026-05-18 19:24:53 -07:00
badfd7689a feat(cli): build-time command-tree codegen + hidden/deprecated framework flags
Replaces the hand-written src/commands/tree.ts with a build-time-generated
artifact derived from src/commands/**/index.ts. tree.ts becomes a one-line
re-export of tree.generated.ts. Determinism: lexicographic sort, LF pinned
via .gitattributes, atomic write (tmp + rename), CI-gated by `pnpm tree:check`.

Codegen script (cli/scripts/generate-command-tree.ts) walks the commands
tree, derives canonical PascalCase identifiers (with reserved-word + hyphen
handling), and emits a static ESM module with sorted default imports and a
nested literal of shape CommandTree. Shared exclusion predicate
(isExcludedCommandPath) consumed by both codegen and coverage.test.ts so
underscore-prefixed segments stay non-commands.

Wired pre* lifecycle hooks (prebuild/predev/pretest) and ci composite
gating `tree:check` first. Pack now emits .js outputs (fixedExtension:false)
to drop .mjs; bin/run.js stays on .js. Vitest test.include extended to
cover scripts/.

Framework additions bundled in:
- static hidden = true       omits command from printTopLevelHelp listing
                              (still resolves and runs when invoked)
- static deprecated = '...'  prints "deprecated: <msg>" to stderr before
                              constructing the command

Verified: pnpm ci green (tree:check ok, tsc clean, lint clean, 702 tests
pass, build complete). Smoke: node bin/run.js version + auth login --help,
add-a-command flow, loose-file error case all behave as expected.
2026-05-18 18:25:57 -07:00
0ff00e742f fix(cli): restore BaseError catch routing post-oclif removal
PR #36328 (remove oclif) dropped the DifyCommand.catch() override that
routed BaseError through formatErrorForCli with semantic exit codes.
The replacement catch in framework/run.ts wrote raw err.message and
always exited 1, losing the code prefix, hint, http_status line, JSON
envelope path, and Auth/Usage/VersionCompat exit codes.

framework/run.ts:
- Add sniffOutputFormat(argv) helper: detects --output / -o (= and space
  forms), stops at --, first-occurrence-wins. Schema-free so it survives
  command-construction failures and pre-parse throws.
- Rewrite catch block: branch BaseError -> Error -> non-Error. BaseError
  branch routes through formatErrorForCli({ format: sniffOutputFormat(argv) })
  and exits via err.exit(). Explicit return after each process.exit
  defends against stubbed exits in tests.

run/app/sse-collector.ts:
- decodeStreamError now unwraps openapi-v1 InvokeError envelopes
  ({error_type, args, message}) buried inside env.message. Prefers
  args.description, falls back to inner.message, then raw on shape
  mismatch.

framework/command.ts:
- Sort named imports (fix pre-existing lint error).

Tests (run.test.ts new, sse-collector.test.ts extended):
- 10 sniffOutputFormat cases.
- 12 run() catch-routing cases: BaseError human/JSON, Usage/Server5xx
  exit codes, withRequest method+url in human and JSON, generic Error,
  non-Error throw, success path, constructor-time BaseError, --
  separator.
- 5 decodeStreamError unwrap cases.

Full suite: 675/675. type-check + lint clean. No subclass changes.
2026-05-18 16:34:32 -07:00
2d5186fb28 fix(offline): guard marketplace I/O paths for ENG-421 (#36335)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-18 13:53:42 +00:00
a89b43bccc simplify type signature 2026-05-18 20:30:57 +08:00
c6792ce415 [autofix.ci] apply automated fixes 2026-05-18 11:13:20 +00:00
8918142ce1 refactor: remove oclif (#36328) 2026-05-18 19:07:23 +08:00
06f076e0ff fix: no model selected but params keep loading (#36342) 2026-05-18 10:19:52 +00:00
5b79f7e99d docs: fix docker README numbering and refresh stale references (#36303) 2026-05-18 10:17:49 +00:00
1cee1a25b6 fix(console): require admin/owner to set default builtin tool credential (#36264)
Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
2026-05-18 10:15:51 +00:00
c0f237bf35 feat(web): allow annotation reply score threshold below 0.8 (#36337) 2026-05-18 10:05:13 +00:00
75d7fc0526 ci: add hotfix cherry-pick provenance check (#36340) 2026-05-18 10:03:56 +00:00
c057b5c5ff chore: Filter model presets by supported parameters (#36339) 2026-05-18 10:03:46 +00:00
5468c4ec96 fix: can not create empty knowledge (#36336)
Co-authored-by: Joel <iamjoel007@gmail.com>
2026-05-18 09:41:57 +00:00
f4c02e4c6b fix: fallback phoenix parent trace when parent tracing disabled (#36290)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-18 08:56:50 +00:00
9dc95eeb20 test(api): isolate container DB between tests (#36310)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-05-18 08:52:19 +00:00
76bba64b79 chore: add type to test (#36324)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-18 08:47:47 +00:00
59e96fbb2a fix(dev): handle empty pyrefly target paths (#36325) 2026-05-18 08:04:03 +00:00
yyh
06ea0f7ac2 fix: improve API extension dialog controls (#36323) 2026-05-18 08:03:08 +00:00
730a0bef9e refactor: add console client migration demo (#36300) 2026-05-18 07:41:23 +00:00
2eb37caf2e refactor(api): migrate console.app.workflow to BaseModel (#36216)
Co-authored-by: WH-2099 <wh2099@pm.me>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-18 07:31:37 +00:00
7e8147295b refactor: convert isinstance chains to match/case (part 5) (#36298)
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
2026-05-18 07:16:31 +00:00
c07686928a fix(api): close base64 file lookup sessions (#36308)
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
2026-05-18 07:13:12 +00:00
d1238180ed fix: use Generator type annotation with @contextmanager decorators (#36297)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-05-18 07:12:49 +00:00
yyh
969760364d ci: ensure pnpm is available in setup-web action (#36315) 2026-05-18 06:55:38 +00:00
ceabfeb3a7 chore(docker): upgrade plugin daemon to 0.6.1 (#36312) 2026-05-18 06:51:30 +00:00
yyh
c407f40e0d refactor(web): simplify github install focus (#36314) 2026-05-18 06:45:12 +00:00
yyh
28818f2e2a fix(web): constrain dialog overflow layouts (#36302) 2026-05-18 06:11:58 +00:00
e2d6ae818c Merge remote-tracking branch 'upstream/main' into feat/cli 2026-05-18 14:00:59 +08:00
yyh
e2c52c9b0f refactor: migrate checkbox to dify-ui (#36295)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-18 05:27:42 +00:00
1925d58369 chore: generate contract in ci (#36286) 2026-05-18 03:13:40 +00:00
2fd7b82970 feat(cli,api): startSpinner export, local install scripts, OPENAPI enum mapping
- spinner.ts: extract startSpinner() returning ActiveSpinner handle for non-blocking use
- scripts/: add install-local.sh / uninstall-local.sh + pnpm install:local / uninstall:local
- api enums: add OPENAPI to InvokeFrom source mapping in both enum definitions
2026-05-17 20:10:01 -07:00
1cc7953f79 feat(cli): add --think flag to strip or show model thinking blocks
By default <think>...</think> blocks emitted by thinking-capable models
are silently stripped from the answer before printing. With --think,
thinking blocks are routed to stderr (with tags preserved) and the
clean answer goes to stdout.

- src/io/think-filter.ts: ThinkChunkFilter (stateful streaming filter
  that handles partial tags split across chunk boundaries), stripThinkBlocks
  and extractThinkBlocks for bulk non-stream processing; \r\n-aware
- stream-handlers.ts: ChatStreamPrinter and CompletionStreamPrinter use
  ThinkChunkFilter; streamPrinterFor(mode, think=false) backward-compat
- streaming-structured.ts: post-collect strip/extract on resp.answer
- streaming-text.ts + print-flags.ts: ctx.think threaded through
- run.ts, resume/run.ts, _strategies/index.ts: think field in types
- run/app/index.ts, resume/index.ts: --think oclif flag, default false
2026-05-17 19:18:34 -07:00
b79fc5d6b4 fix: add missing phase field to _TokenData TypedDict (#36261) 2026-05-18 02:08:56 +00:00
yyh
6649e4025e feat(dify-ui): add Checkbox/CheckboxGroup primitives (#36271) 2026-05-18 02:01:56 +00:00
b96f372f45 chore(api): upgrade graphon to 0.4.0 (#36124)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
2026-05-18 00:34:17 +00:00
127fbf2c9a refactor: use match cases for workflow stream responses (#36267) 2026-05-17 21:38:20 +00:00
3c70d28064 fix(auth): preserve phase field in _TokenData so reset-password / change-email phase-bound checks don't 400 (#36116) (#36117)
Signed-off-by: vuko <alexander.vukovic@seqis.com>
2026-05-17 19:55:00 +00:00
cd4d6f8a22 fix(web): migrate metadata picker to combobox (#36255)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-17 03:35:20 +00:00
9d0906c684 chore: improve swagger markdown optional fields typing (#36247) 2026-05-16 16:40:20 +00:00
41b6f894c0 fix: fetch memory of LLM node may cause out of flask context (#36253) 2026-05-16 16:38:48 +00:00
e7e6fe8813 refactor: convert isinstance chains to match/case (part 3) (#36242)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-16 08:16:14 +00:00
c0bdd6792f refactor: convert isinstance chains to match/case in easy_ui_based_generate_task_pipeline.py (#36222) 2026-05-15 13:51:49 +00:00
31cf656b35 feat(cli): app run overhaul — always stream, --inputs JSON, HITL pause/resume, Ctrl+C stop
- Remove blocking mode; all apps stream SSE, --stream controls live vs collect output
- Replace --input k=v with --inputs '{json}' (single object, mutually exclusive with --inputs-file)
- Add --workflow-id, --file flags
- HITL: human_input_required → pause JSON to stdout + hint to stderr + exit 2
- Ctrl+C: captures task_id from SSE, calls stop-task, exits 1
- New difyctl run app resume subcommand: POST form, reconnect SSE, stream to completion
- resume: --action (auto-select), --with-history (include_state_snapshot), --stream flags
- Delete BlockingStrategy; simplify pickStrategy(isText, livePrint)
- Add HitlPauseError, SILENT_EVENTS handling in sse-collector and stream-handlers
- Update dify-mock: always SSE, hitl-pause/hitl-resume scenarios, stop/form/events handlers
- Update agent guide: --inputs JSON syntax, HITL pause/resume instructions
2026-05-15 02:53:19 -07:00
8be6665d22 feat(api,cli): openapi HITL endpoints — always-stream, human_input_form, workflow_events, stop-task
- Remove response_mode from AppRunRequest; openapi /run always streams
- Add POST /apps/<id>/tasks/<task_id>/stop (SIGINT hook target)
- Add GET/POST /apps/<id>/form/human_input/<token> (HITL form fetch/submit)
- Add GET /apps/<id>/tasks/<task_id>/events (SSE reconnect after resume)
- Add HumanInputSurface.OPENAPI; map to STANDALONE_WEB_APP recipient type
- Regenerate cli/src/types/data-contracts.ts via pnpm sync-models
2026-05-15 02:50:54 -07:00
27b084c4d4 fix: reduce db roundtrips of message update (#36213) 2026-05-15 08:39:48 +00:00
3f7a68fc77 fix(api): avoid dify-agent path lookup during Docker build (#36187) 2026-05-15 08:25:58 +00:00
yyh
a252fbddfa feat: initialize user timezone and language from browser (#36170)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-15 08:12:52 +00:00
ff02636a4b fix(web): app icon in webapp (#36206) 2026-05-15 07:44:09 +00:00
63946d829e fix(web): web app description missing (#36209) 2026-05-15 07:43:44 +00:00
cdcfd2ef2c fix: regenerate document summary after update via API (#35950) (#36035)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-15 07:26:29 +00:00
b04a3851cc refactor: enhance layout and scrolling behavior in various modals for improved user experience (#36210)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-15 07:17:23 +00:00
b41338cd08 chore(layout): reintroduce AmplitudeProvider in common layouts for analytics tracking (#36208)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-15 06:33:31 +00:00
c2b91d849d ci(cli): replace Makefile with pnpm scripts, add Dockerfile.dev and CI workflows
- Replace Makefile with package.json scripts (ci, clean, docker:build-dev,
  sync-models, version:info); update cli-tests.yml to use pnpm ci
- Add cli/Dockerfile.dev: multi-stage local build from monorepo source,
  proper cache layer order, non-root user, corepack-driven pnpm version
- Add .dockerignore at repo root to exclude node_modules/dist/.git from
  build context
- Add cli-docker-build.yml: PR + merge_group validation of Dockerfile.dev,
  amd64+arm64 via Depot for org; amd64-only for forks
- cli-release.yml: pin action SHAs, depot-ubuntu-24.04 runner, move
  verify-release step before build for fast-fail
- main-ci.yml: add packages/tsconfig/** and cli-docker-build.yml to cli
  path filter
- Drop cli/docs/specs/** and cli/docs/*.md (superseded)
2026-05-14 22:10:42 -07:00
28153df4d3 chore: enchance copywriting in none education plan warning modal (#36201)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-05-15 05:08:06 +00:00
e0f4e98a2f chore(cli): pre-merge cleanup — docker images, comments, tsconfig lib
- docker-compose.yaml: revert api/web from build: back to image tags
  (1.14.1); fix api_websocket/worker/worker_beat downgraded to 1.14.0
- Remove verbose internal design comments from openapi controllers
- web/next.config.ts: trim anti-framing comment to one line
- cli/tsconfig.json: drop lib:ES2015 override (broke Error.cause typing)
- eslint.config.mjs: ignore cli/context/** and cli/docs/** (local caches)
- pnpm-lock.yaml: regenerate after fresh install
2026-05-14 20:44:51 -07:00
9d554495cf feat(cli): add OPENAPI_ENABLED env switch, default false
Gates the entire /openapi/v1/* blueprint on a single env var.
When false (default), the blueprint is never registered so all
CLI requests return 404 before any auth or logic runs.

Set OPENAPI_ENABLED=true to activate the endpoint group.
2026-05-14 20:16:14 -07:00
3bc3386535 refactor(install): improve layout and scrolling behavior for plugin installation step (#36199)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-15 03:12:14 +00:00
c2868075fa Merge remote-tracking branch 'origin/main' into feat/cli
# Conflicts:
#	docker/docker-compose.yaml
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
2026-05-14 20:11:59 -07:00
7654f14241 fix: replace deprecated testcontainers log waits (#36125) 2026-05-15 01:30:59 +00:00
yyh
194b54bae4 fix: allow tag rename without type payload (#36182)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-15 01:29:17 +00:00
0e16d36edb fix(commands): purge tenant tool credentials on reset-encrypt-key-pair (#35396) (#35843)
Co-authored-by: xr843 <xianren843@protonmail.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 16:25:54 +00:00
432a6412a3 fix(security): tenant-scope FilePreviewApi text-extract endpoint (GHSA-2qwc-c2cc-2xwv) (#35797)
Signed-off-by: xr843 <137012659+xr843@users.noreply.github.com>
Co-authored-by: Ido Shani <ido@zafran.io>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 16:13:04 +00:00
55d05fe52d fix(security): enforce tenant scoping on app trace-config endpoints (GHSA-48xc-wmw8-3jr3) (#35793)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ido Shani <ido@zafran.io>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 15:59:31 +00:00
0d500e6965 fix(api): allow LLM nodes to access retrieved knowledge files (#36175) 2026-05-14 13:09:25 +00:00
5798610f27 refactor(api): migrate console.app.workflow_comment to BaseModel (#36180) 2026-05-14 12:13:47 +00:00
a35b28dbef refactor: cleanup duplicate code (#36173) 2026-05-14 10:34:31 +00:00
1a4288c811 fix: action btn is hidden if there are many packages to install (#36176) 2026-05-14 10:21:32 +00:00
9dc32f2318 chore: increase default graph engine min workers (#35650)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
2026-05-14 09:27:45 +00:00
7210f856c9 fix: pipeline template render (#36168)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-14 09:11:18 +00:00
ebcc1200a3 feat(MessageLogModal): refactor modal structure and improve tab handling (#36169)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 08:50:21 +00:00
e660d7af38 fix(api): gracefully handle credential fetch failures in rag pipeline (#36165)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 08:27:19 +00:00
d9ccfcbc6e fix: fix delete logs failed (#36151)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 08:02:31 +00:00
a9bcec013f feat: allow disabling run time cred check (#36031)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 07:12:10 +00:00
aeb7687e2c fix: add null check in get_recommend_app_detail before accessing result['id'] (#36153) 2026-05-14 06:42:22 +00:00
9355d36718 chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in /dify-agent (#36160)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 06:41:22 +00:00
a03ee828a3 fix: get recommend_app categories should not re-order it (#36161) 2026-05-14 06:36:28 +00:00
7066372892 feat(workflow): enhance workflow run callbacks with additional data tracking (#36149)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 06:20:12 +00:00
55f95dbc36 feat(agent): init agent server (#36087)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 06:04:44 +00:00
8b40de3c4e chore: enchance notify link ui (#36155) 2026-05-14 06:03:44 +00:00
af4b9bfa8f chore: Remove pyright in favor of pyrefly (#36154) 2026-05-14 05:49:08 +00:00
b9e3130388 chore: drop unnecessary | None on LLMError and Mail.send (#36147)
Co-authored-by: Brian Wang <20699847+BrianWang1990@users.noreply.github.com>
2026-05-14 03:22:00 +00:00
12d33652b6 fix(errors): clean unnecessary | None in error classes (#36135) 2026-05-14 03:21:41 +00:00
fe8cf2aff4 fix: fix pydantic union type error (#36134) 2026-05-14 02:54:23 +00:00
d1d190374d fix: credit pool access outside flask context (#36143) 2026-05-14 02:45:53 +00:00
e1be4e6aa8 chore(deps): bump langsmith from 0.7.31 to 0.8.0 in /api (#36142)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 02:36:02 +00:00
301a470e7a fix: isolate Langfuse v3 SDK TracerProvider to prevent cross-task interference (#36136)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 01:46:23 +00:00
91251ad5a5 chore(deps): bump authlib from 1.6.11 to 1.6.12 in /api (#36121)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 14:56:20 +00:00
3f6644a615 chore(i18n): sync translations with en-US (#36115)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-05-13 09:20:47 +00:00
yyh
5edc682c4a fix(web): refine account avatar interactions (#36111)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 08:36:04 +00:00
13c00ecfc4 chore(deps): bump ujson from 5.12.0 to 5.12.1 in /api (#36112)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 08:34:32 +00:00
9d545144ce chore: remove obsolete admin console routes (#35637)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 08:08:50 +00:00
2afa39cdcb fix: knowledge hit-testing render failed. (#36106)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-13 07:31:38 +00:00
bb1c883be4 ci: Update pyrefly dependency version to 1.0.0 (#36100)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 07:13:59 +00:00
yyh
03861bcee3 refactor: stabilize selector preview cards (#36105)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 06:16:26 +00:00
c34fc429ae fix: use Trans component for delete app confirmation text on editing page (#36092)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-05-13 06:08:59 +00:00
1a83dfaf1f refactor: use BaseModel in openapi group. Generate ts code from swagger (#36076)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-13 12:56:42 +08:00
d110112863 chore: update deps (#36091) 2026-05-13 03:51:21 +00:00
934a20e745 fix: restore tracing after HITL workflow resume (#36064)
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-13 03:23:32 +00:00
7e56a244a8 fix: fix plugin miss version and checksum (#36083)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 01:39:27 +00:00
6facd9360c chore: some match case (#36080)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 14:45:58 +00:00
a18d7f51eb fix: fixed relative (#36078)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-12 11:05:57 +00:00
680ef077ae chore: admin also has the permission of changing role (#36069)
Co-authored-by: Yansong Zhang <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-12 10:15:05 +00:00
c26be9d3f4 fix: redirect unauthorized dataset access to /datasets for knowledge editors (#36073)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-05-12 09:38:49 +00:00
51a8f79d67 chore: DocumentSegment to Typebase (#35635)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-12 07:02:17 +00:00
bb73776339 chore(release): bump version to 1.14.1 (#36034) 2026-05-12 06:56:16 +00:00
9424bf60b0 fix: the /threads and /db-pool-stat endpoints in api... in... (#35665) 2026-05-12 05:43:37 +00:00
cbedcd2882 fix(security): harden self-hosted SECRET_KEY bootstrap (#36049)
Co-authored-by: EndlessLucky <66432853+EndlessLucky@users.noreply.github.com>
2026-05-12 05:35:24 +00:00
1a93af5cd0 refactor: rewrite estimate_args_validate using Pydantic v2 models (#36036)
Signed-off-by: Deepam Goyal <deepam02goyal@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 05:34:45 +00:00
yyh
cd90d7ffc1 refactor(web): migrate searchable pickers to combobox (#36066)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-12 05:34:19 +00:00
4bb987eca3 fix: validate missing text indexing technique (#35941) 2026-05-12 05:07:03 +00:00
4fd4615c56 fix: avoid trial workflow schema model collision (#36061)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-12 03:24:14 +00:00
83d14e0540 ci(cli): use depot-ubuntu-24.04 for cli-tests jobs
Aligns cli-tests.yml and the cli-tests-skip/finalizer jobs in main-ci.yml
with the rest of the pipeline; all other jobs already use depot-ubuntu-24.04.
2026-05-11 19:46:11 -07:00
1f7da9c191 Merge branch 'main' into feat/cli
Conflicts resolved:
- api/services/app_service.py: extend AppListParams with status + openapi_visible fields so the openapi caller's per-page visibility gate survives the dict->BaseModel refactor; openapi controller now constructs AppListParams.
- pnpm-workspace.yaml: union of CLI-only entries (@napi-rs/keyring, @oclif/*) with main's bumped versions (@next/*, @orpc/*, eslint-plugin-sonarjs, eslint-plugin-storybook); kept eventsource-parser.
- pnpm-lock.yaml: regenerated.
- web/app/signin/utils/post-login-redirect.ts: union impl — keep main's resolvePostLoginRedirect(searchParams) + setOAuthPendingRedirect; add hardened sessionStorage-based setPostLoginRedirect for device flow with same-origin + path whitelist; device redirect takes precedence over oauth pending.
2026-05-11 19:29:37 -07:00
b21d0ae32d fix(cli): call this.parse in arg-less commands to silence oclif UnparsedCommand warning
oclif v4 warns "did not parse its arguments" for any command class whose
run() never calls this.parse(ClassName), independent of whether the
command declares args/flags. Add the call in the five arg-less commands.
2026-05-11 19:01:14 -07:00
c7d30bf09a chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in /api (#36050)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 01:56:40 +00:00
6779366dca feat(api,web,cli): difyctl v1.0 — OAuth device flow, /openapi/v1 auth pipeline, CLI client 2026-05-11 18:40:39 -07:00
yyh
59dab7deac refactor(apps): simplify query state and debounce URL writes (#36043) 2026-05-11 09:58:19 +00:00
a60cb3b800 chore: port WorkflowComment (#36039)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-11 09:17:12 +00:00
yyh
6164408da1 fix(web): align tag filter dropdown icon (#36041) 2026-05-11 08:42:09 +00:00
7fc40e6c9e feat: improve phoenix workflow tracing (#35605)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-11 08:37:17 +00:00
d625ac0bf1 refactor: port some if else to match (#31841)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-11 06:39:59 +00:00
1082f488a1 refactor: enhance modal layouts and scrolling behavior across components (#36033)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-11 06:16:56 +00:00
f1c4c1a5ff refactor: replace dict params with BaseModel in AppService (#35904)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-11 06:09:50 +00:00
yyh
dd1cdbbd41 refactor(web): split premium badge button semantics (#36026)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-11 05:57:30 +00:00
74a04afe27 chore(api): upgrade graphon to v0.3.1 (#35987)
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-11 05:32:17 +00:00
b108ea42f6 fix(docker): update middleware env setup (#35946) 2026-05-11 05:21:02 +00:00
1aa6188b7d chore(deps): bump the google group across 1 directory with 2 updates (#36012)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 04:35:14 +00:00
yyh
bd0d10ac5c fix: use infotip for help glyphs (#36008)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-11 04:23:04 +00:00
yyh
2162ea6a68 fix: improve workflow checklist semantics (#36006) 2026-05-11 04:22:46 +00:00
153064bbd4 chore(i18n): sync translations with en-US (#36019)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-05-11 04:06:49 +00:00
yyh
a643b05368 fix(web): remove unsafe select value casts (#36007)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-11 04:01:49 +00:00
279b66bc7f chore(deps): bump gunicorn from 25.3.0 to 26.0.0 in /api in the flask group (#36011)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 03:53:07 +00:00
yyh
e134c1e0d5 feat(web): improve a11y and remove data-testid (#35999) 2026-05-11 03:53:03 +00:00
9127209dd5 chore(web): refresh agent skills (#36015) 2026-05-11 03:52:47 +00:00
a2ee151e48 chore(deps): bump the github-actions-dependencies group with 2 updates (#36009)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 03:52:38 +00:00
9e3e616391 chore(deps): bump the storage group in /api with 2 updates (#36017)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 03:52:13 +00:00
837b5cad86 chore(deps): bump opentelemetry-exporter-otlp-proto-grpc from 1.41.0 to 1.41.1 in /api in the opentelemetry group (#36013)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 03:51:51 +00:00
1a011dc14a refactor: port DatasetProcessRule (#31004)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-11 03:37:26 +00:00
bf117dd0c8 fix(trace): LangSmith trace_id mismatch in chatflow workflow traces (#35979) 2026-05-11 02:52:29 +00:00
1e6dc62470 chore: separate websocket service (#35981) 2026-05-11 02:22:40 +00:00
0b70eec695 feat(human-input): expose selected action value (#35451) 2026-05-11 02:16:29 +00:00
e8dc706414 fix(api): "File validation failed" on Chatflow follow-up with custom file type + memory (#35891)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-11 01:57:56 +00:00
9a2bea9287 chore(i18n): sync translations with en-US (#35994)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-05-10 13:23:31 +00:00
b95e6f6a7a feat: support editable class labels in question classifier (#35430) 2026-05-10 12:10:16 +00:00
yyh
b99ba74aa4 chore(web): remove drawer overlay import restriction (#35990) 2026-05-10 09:35:27 +00:00
7b5c371b9d chore: api para type (#35985)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-10 06:04:42 +00:00
c67ce6f66d chore: unify api && clean some type ignore (#35984)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-10 04:16:29 +00:00
e48d7bb097 refactor(web): migrate drawer components to dify-ui and remove legacy drawer implementation (#35982)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-09 16:24:45 +00:00
yyh
24ea21db25 refactor(web): converge overlay layering on dify-ui z-50 (#35976)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-09 12:18:39 +00:00
8581a68174 refactor(web): drop headless-ui, migrate overlay to dify-ui (#35963)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-05-09 10:33:25 +00:00
f720a3bed2 fix: Image rendering in the knowledge base failed. (#35914)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-09 10:06:01 +00:00
4a56763d2f refactor(api): migrate console.app.workflow etc. to BaseModel (#35967) 2026-05-09 08:34:15 +00:00
yyh
861f73267c feat(dify-ui): add Tabs/ToggleGroup (#35965)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-09 08:23:50 +00:00
1efd365b62 fix(swagger): Apply the inline-nested-dicts patch to HTTP Swagger endpoints (#35952) 2026-05-09 08:21:26 +00:00
65c36a51ef ci: update comment (#35968) 2026-05-09 07:53:42 +00:00
19476109da chore(api): upgrade graphon to v0.3.0 (#35469)
Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-05-09 07:30:03 +00:00
f3eb3ab4dd fix: mismatched button label in prefilled WebApp launch description (#35964) 2026-05-09 07:01:35 +00:00
yyh
2c9e30426d refactor(web): migrate headless-ui components to dify-ui (#35962) 2026-05-09 06:49:26 +00:00
yyh
2bb1f0906b refactor(web): migrate legacy tooltip callers (#35961)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-09 05:26:21 +00:00
d5ad6aedc0 fix(swagger): add util to convert BaseModel to schema for query params (#35959)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-09 04:52:45 +00:00
yyh
5ebeb34feb fix(web): forward csp nonce to theme script (#35960) 2026-05-09 04:35:29 +00:00
c5ac191a79 chore(deps): bump gitpython from 3.1.49 to 3.1.50 in /api (#35958)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 04:11:09 +00:00
140ad6ba4e chore: add Type to test (#35942)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-09 03:16:22 +00:00
e03eb3a76c chore: bump LiteLLM for CVE-2026-42208 (#35953) 2026-05-09 03:14:14 +00:00
38a419d073 ci: auto gen api doc and download link (#35919)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-05-09 03:01:47 +00:00
c74cbb68da fix: change write to db order (#35948) 2026-05-09 02:36:54 +00:00
yyh
271019006e fix: prevent workflow preview resize observer loop (#35936) 2026-05-09 01:29:20 +00:00
19bf36a716 chore: dep inject for session (#35934)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 17:48:33 +00:00
48d27e250b refactor: split docker-compose env config into separate files (#31586)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-08 15:36:20 +00:00
d06b5529b3 chore(docker): clean up env examples (#35938) 2026-05-08 12:53:13 +00:00
8132c444dc feat: support SQLALCHEMY_POOL_RESET_ON_RETURN config (#31156) 2026-05-08 12:25:46 +00:00
cb0356e9d7 chore(i18n): sync translations with en-US (#35933)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-05-08 11:25:15 +00:00
4d80892d7b refactor: convert isinstance chains to match/case (#35902) (#35922)
Signed-off-by: EvanYao826 <2869018789@qq.com>
2026-05-08 09:51:10 +00:00
af754f497a chore: add query generator before lauch webapp (#35416)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-05-08 09:49:43 +00:00
yyh
8f93bb36ba feat(dify-ui): add drawer (#35917)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-08 08:53:32 +00:00
82f24b336d fix(workflow): handle file-preview URLs in node output display (#34150) 2026-05-08 07:55:46 +00:00
3451 changed files with 150619 additions and 51187 deletions

View File

@ -63,7 +63,7 @@ pnpm analyze-component <path> --json
```typescript ```typescript
// ❌ Before: Complex state logic in component // ❌ Before: Complex state logic in component
const Configuration: FC = () => { function Configuration() {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...) const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...) const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({}) const [completionParams, setCompletionParams] = useState<FormValue>({})
@ -85,7 +85,7 @@ export const useModelConfig = (appId: string) => {
} }
// Component becomes cleaner // Component becomes cleaner
const Configuration: FC = () => { function Configuration() {
const { modelConfig, setModelConfig } = useModelConfig(appId) const { modelConfig, setModelConfig } = useModelConfig(appId)
return <div>...</div> return <div>...</div>
} }
@ -189,8 +189,6 @@ const Template = useMemo(() => {
**Dify Convention**: **Dify Convention**:
- This skill is for component decomposition, not query/mutation design. - This skill is for component decomposition, not query/mutation design.
- When refactoring data fetching, follow `web/AGENTS.md`.
- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling.
- Do not introduce deprecated `useInvalid` / `useReset`. - Do not introduce deprecated `useInvalid` / `useReset`.
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state. - Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.

View File

@ -60,8 +60,10 @@ const Template = useMemo(() => {
**After** (complexity: ~3): **After** (complexity: ~3):
```typescript ```typescript
import type { ComponentType } from 'react'
// Define lookup table outside component // Define lookup table outside component
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, FC<TemplateProps>>> = { const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplateProps>>> = {
[AppModeEnum.CHAT]: { [AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh, [LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa, [LanguagesSupported[7]]: TemplateChatJa,

View File

@ -65,10 +65,10 @@ interface ConfigurationHeaderProps {
onPublish: () => void onPublish: () => void
} }
const ConfigurationHeader: FC<ConfigurationHeaderProps> = ({ function ConfigurationHeader({
isAdvancedMode, isAdvancedMode,
onPublish, onPublish,
}) => { }: ConfigurationHeaderProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@ -136,7 +136,7 @@ const AppInfo = () => {
} }
// ✅ After: Separate view components // ✅ After: Separate view components
const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => { function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
return ( return (
<div className="expanded"> <div className="expanded">
{/* Clean, focused expanded view */} {/* Clean, focused expanded view */}
@ -144,7 +144,7 @@ const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
) )
} }
const AppInfoCollapsed: FC<AppInfoViewProps> = ({ appDetail, onAction }) => { function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) {
return ( return (
<div className="collapsed"> <div className="collapsed">
{/* Clean, focused collapsed view */} {/* Clean, focused collapsed view */}
@ -203,12 +203,12 @@ interface AppInfoModalsProps {
onSuccess: () => void onSuccess: () => void
} }
const AppInfoModals: FC<AppInfoModalsProps> = ({ function AppInfoModals({
appDetail, appDetail,
activeModal, activeModal,
onClose, onClose,
onSuccess, onSuccess,
}) => { }: AppInfoModalsProps) {
const handleEdit = async (data) => { /* logic */ } const handleEdit = async (data) => { /* logic */ }
const handleDuplicate = async (data) => { /* logic */ } const handleDuplicate = async (data) => { /* logic */ }
const handleDelete = async () => { /* logic */ } const handleDelete = async () => { /* logic */ }
@ -296,7 +296,7 @@ interface OperationItemProps {
onAction: (id: string) => void onAction: (id: string) => void
} }
const OperationItem: FC<OperationItemProps> = ({ operation, onAction }) => { function OperationItem({ operation, onAction }: OperationItemProps) {
return ( return (
<div className="operation-item"> <div className="operation-item">
<span className="icon">{operation.icon}</span> <span className="icon">{operation.icon}</span>
@ -435,7 +435,7 @@ interface ChildProps {
onSubmit: () => void onSubmit: () => void
} }
const Child: FC<ChildProps> = ({ value, onChange, onSubmit }) => { function Child({ value, onChange, onSubmit }: ChildProps) {
return ( return (
<div> <div>
<input value={value} onChange={e => onChange(e.target.value)} /> <input value={value} onChange={e => onChange(e.target.value)} />

View File

@ -112,13 +112,13 @@ export const useModelConfig = ({
```typescript ```typescript
// Before: 50+ lines of state management // Before: 50+ lines of state management
const Configuration: FC = () => { function Configuration() {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...) const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
// ... lots of related state and effects // ... lots of related state and effects
} }
// After: Clean component // After: Clean component
const Configuration: FC = () => { function Configuration() {
const { const {
modelConfig, modelConfig,
setModelConfig, setModelConfig,
@ -159,8 +159,6 @@ const Configuration: FC = () => {
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns. When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
- Follow `web/AGENTS.md` first.
- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling.
- Do not introduce deprecated `useInvalid` / `useReset`. - Do not introduce deprecated `useInvalid` / `useReset`.
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks. - Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.

View File

@ -23,7 +23,7 @@ Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS
- `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter - `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter
3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved. 3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved.
4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved. 4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved.
5. Re-check official docs with Context7 before introducing a new Playwright or Cucumber pattern. 5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern.
## Local Rules ## Local Rules

View File

@ -9,18 +9,18 @@ Category: Performance
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. 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.
## Complex prop memoization ## Complex prop stability
IsUrgent: True IsUrgent: False
Category: Performance Category: Performance
### Description ### Description
Wrap complex prop values (objects, arrays, maps) in `useMemo` prior to passing them into child components to guarantee stable references and prevent unnecessary renders. 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.
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate. Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
Wrong: Risky:
```tsx ```tsx
<HeavyComp <HeavyComp
@ -31,7 +31,7 @@ Wrong:
/> />
``` ```
Right: Better when stable identity matters:
```tsx ```tsx
const config = useMemo(() => ({ const config = useMemo(() => ({

View File

@ -1,46 +0,0 @@
---
name: frontend-query-mutation
description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions()/mutationOptions() directly or extract a helper or use-* hook, configuring oRPC experimental_defaults/default options, handling conditional queries, cache updates/invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers.
---
# Frontend Query & Mutation
## Intent
- Keep contract as the single source of truth in `web/contract/*`.
- Prefer contract-shaped `queryOptions()` and `mutationOptions()`.
- Keep default cache behavior with `consoleQuery`/`marketplaceQuery` setup, and keep business orchestration in feature vertical hooks when direct contract calls are not enough.
- Treat `web/service/use-*` query or mutation wrappers as legacy migration targets, not the preferred destination.
- Keep abstractions minimal to preserve TypeScript inference.
## Workflow
1. Identify the change surface.
- Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape.
- Read `references/runtime-rules.md` for conditional queries, default options, cache updates/invalidation, error handling, and legacy migrations.
- Read both references when a task spans contract shape and runtime behavior.
2. Implement the smallest abstraction that fits the task.
- Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site.
- Extract a small shared query helper only when multiple call sites share the same extra options.
- Create or keep feature hooks only for real orchestration or shared domain behavior.
- When touching thin `web/service/use-*` wrappers, migrate them away when feasible.
3. Preserve Dify conventions.
- Keep contract inputs in `{ params, query?, body? }` shape.
- Bind default cache updates/invalidation in `createTanstackQueryUtils(...experimental_defaults...)`; use feature hooks only for workflows that cannot be expressed as default operation behavior.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required.
## Files Commonly Touched
- `web/contract/console/*.ts`
- `web/contract/marketplace.ts`
- `web/contract/router.ts`
- `web/service/client.ts`
- legacy `web/service/use-*.ts` files when migrating wrappers away
- component and hook call sites using `consoleQuery` or `marketplaceQuery`
## References
- Use `references/contract-patterns.md` for contract shape, router registration, query and mutation helpers, and anti-patterns that degrade inference.
- Use `references/runtime-rules.md` for conditional queries, invalidation, `mutate` versus `mutateAsync`, and legacy migration rules.
Treat this skill as the single query and mutation entry point for Dify frontend work. Keep detailed rules in the reference files instead of duplicating them in project docs.

View File

@ -1,4 +0,0 @@
interface:
display_name: "Frontend Query & Mutation"
short_description: "Dify TanStack Query, oRPC, and default option patterns"
default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, oRPC default options, conditional queries, cache updates/invalidation, or legacy query/mutation migrations."

View File

@ -1,129 +0,0 @@
# Contract Patterns
## Table of Contents
- Intent
- Minimal structure
- Core workflow
- Query usage decision rule
- Mutation usage decision rule
- Thin hook decision rule
- Anti-patterns
- Contract rules
- Type export
## Intent
- Keep contract as the single source of truth in `web/contract/*`.
- Default query usage to call-site `useQuery(consoleQuery|marketplaceQuery.xxx.queryOptions(...))` when endpoint behavior maps 1:1 to the contract.
- Keep abstractions minimal and preserve TypeScript inference.
## Minimal Structure
```text
web/contract/
├── base.ts
├── router.ts
├── marketplace.ts
└── console/
├── billing.ts
└── ...other domains
web/service/client.ts
```
## Core Workflow
1. Define contract in `web/contract/console/{domain}.ts` or `web/contract/marketplace.ts`.
- Use `base.route({...}).output(type<...>())` as the baseline.
- Add `.input(type<...>())` only when the request has `params`, `query`, or `body`.
- For `GET` without input, omit `.input(...)`; do not use `.input(type<unknown>())`.
2. Register contract in `web/contract/router.ts`.
- Import directly from domain files and nest by API prefix.
3. Consume from UI call sites via oRPC query utilities.
```typescript
import { useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({
staleTime: 5 * 60 * 1000,
throwOnError: true,
select: invoice => invoice.url,
}))
```
## Query Usage Decision Rule
1. Default to direct `*.queryOptions(...)` usage at the call site.
2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook.
3. Create or keep feature hooks only for orchestration.
- Combine multiple queries or mutations.
- Share domain-level derived state or invalidation helpers.
- Prefer `web/features/{domain}/hooks/*` for feature-owned workflows.
4. Treat `web/service/use-{domain}.ts` as legacy.
- Do not create new thin service wrappers for oRPC contracts.
- When touching existing wrappers, inline direct `consoleQuery` or `marketplaceQuery` consumption when the wrapper is only a passthrough.
```typescript
const invoicesBaseQueryOptions = () =>
consoleQuery.billing.invoices.queryOptions({ retry: false })
const invoiceQuery = useQuery({
...invoicesBaseQueryOptions(),
throwOnError: true,
})
```
## Mutation Usage Decision Rule
1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`.
2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic.
```typescript
const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions())
```
## Thin Hook Decision Rule
Remove thin hooks when they only rename a single oRPC query or mutation helper.
Keep hooks when they orchestrate business behavior across multiple operations, own local workflow state, or normalize a feature-specific API.
Prefer feature vertical hooks for kept orchestration. Do not move new contract-first wrappers into `web/service/use-*`.
Use:
```typescript
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
```
Keep:
```typescript
const applyTagBindingsMutation = useApplyTagBindingsMutation()
```
`useApplyTagBindingsMutation` is acceptable because it coordinates bind and unbind requests, computes deltas, and exposes a feature-level workflow rather than a single endpoint passthrough.
## Anti-Patterns
- Do not wrap `useQuery` with `options?: Partial<UseQueryOptions>`.
- Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case.
- Do not create thin `use-*` passthrough hooks for a single endpoint.
- Do not create business-layer helpers whose only purpose is to call `consoleQuery.xxx.mutationOptions()` or `queryOptions()`.
- Do not introduce new `web/service/use-*` files for oRPC contract passthroughs.
- These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection.
## Contract Rules
- Input structure: always use `{ params, query?, body? }`.
- No-input `GET`: omit `.input(...)`; do not use `.input(type<unknown>())`.
- Path params: use `{paramName}` in the path and match it in the `params` object.
- Router nesting: group by API prefix, for example `/billing/*` becomes `billing: {}`.
- No barrel files: import directly from specific files.
- Types: import from `@/types/` and use the `type<T>()` helper.
- Mutations: prefer `mutationOptions`; use explicit `mutationKey` mainly for defaults, filtering, and devtools.
## Type Export
```typescript
export type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract>
```

View File

@ -1,172 +0,0 @@
# Runtime Rules
## Table of Contents
- Conditional queries
- oRPC default options
- Cache invalidation
- Key API guide
- `mutate` vs `mutateAsync`
- Legacy migration
## Conditional Queries
Prefer contract-shaped `queryOptions(...)`.
When required input is missing, prefer `input: skipToken` instead of placeholder params or non-null assertions.
Use `enabled` only for extra business gating after the input itself is already valid.
```typescript
import { skipToken, useQuery } from '@tanstack/react-query'
// Disable the query by skipping input construction.
function useAccessMode(appId: string | undefined) {
return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({
input: appId
? { params: { appId } }
: skipToken,
}))
}
// Avoid runtime-only guards that bypass type checking.
function useBadAccessMode(appId: string | undefined) {
return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({
input: { params: { appId: appId! } },
enabled: !!appId,
}))
}
```
## oRPC Default Options
Use `experimental_defaults` in `createTanstackQueryUtils` when a contract operation should always carry shared TanStack Query behavior, such as default stale time, mutation cache writes, or invalidation.
Place defaults at the query utility creation point in `web/service/client.ts`:
```typescript
export const consoleQuery = createTanstackQueryUtils(consoleClient, {
path: ['console'],
experimental_defaults: {
tags: {
create: {
mutationOptions: {
onSuccess: (tag, _variables, _result, context) => {
context.client.setQueryData(
consoleQuery.tags.list.queryKey({
input: {
query: {
type: tag.type,
},
},
}),
(oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags,
)
},
},
},
},
},
})
```
Rules:
- Keep defaults inline in the `consoleQuery` or `marketplaceQuery` initialization when they need sibling oRPC key builders.
- Do not create a wrapper function solely to host `createTanstackQueryUtils`.
- Do not split defaults into a vertical feature file if that forces handwritten operation paths such as `generateOperationKey(['console', ...])`.
- Keep feature-level orchestration in the feature vertical; keep query utility lifecycle defaults with the query utility.
- Prefer call-site callbacks for UI feedback only; shared cache behavior belongs in oRPC defaults when it is tied to a contract operation.
## Cache Invalidation
Bind shared invalidation in oRPC defaults when it is tied to a contract operation.
Use feature vertical hooks only for multi-operation workflows or domain orchestration that cannot live in a single operation default.
Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate.
Use:
- `.key()` for namespace or prefix invalidation
- `.queryKey(...)` only for exact cache reads or writes such as `getQueryData` and `setQueryData`
- `queryClient.invalidateQueries(...)` in mutation `onSuccess`
Do not use deprecated `useInvalid` from `use-base.ts`.
```typescript
// Feature orchestration owns cache invalidation only when defaults are not enough.
export const useUpdateAccessMode = () => {
const queryClient = useQueryClient()
return useMutation(consoleQuery.accessControl.updateAccessMode.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(),
})
},
}))
}
// Component only adds UI behavior.
updateAccessMode({ appId, mode }, {
onSuccess: () => toast.success('...'),
})
// Avoid putting invalidation knowledge in the component.
mutate({ appId, mode }, {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(),
})
},
})
```
## Key API Guide
- `.key(...)`
- Use for partial matching operations.
- Prefer it for invalidation, refetch, and cancel patterns.
- Example: `queryClient.invalidateQueries({ queryKey: consoleQuery.billing.key() })`
- `.queryKey(...)`
- Use for a specific query's full key.
- Prefer it for exact cache addressing and direct reads or writes.
- `.mutationKey(...)`
- Use for a specific mutation's full key.
- Prefer it for mutation defaults registration, mutation-status filtering, and devtools grouping.
## `mutate` vs `mutateAsync`
Prefer `mutate` by default.
Use `mutateAsync` only when Promise semantics are truly required, such as parallel mutations or sequential steps with result dependencies.
Rules:
- Event handlers should usually call `mutate(...)` with `onSuccess` or `onError`.
- Every `await mutateAsync(...)` must be wrapped in `try/catch`.
- Do not use `mutateAsync` when callbacks already express the flow clearly.
```typescript
// Default case.
mutation.mutate(data, {
onSuccess: result => router.push(result.url),
})
// Promise semantics are required.
try {
const order = await createOrder.mutateAsync(orderData)
await confirmPayment.mutateAsync({ orderId: order.id, token })
router.push(`/orders/${order.id}`)
}
catch (error) {
toast.error(error instanceof Error ? error.message : 'Unknown error')
}
```
## Legacy Migration
When touching old code, migrate it toward these rules:
| Old pattern | New pattern |
|---|---|
| `useInvalid(key)` in service wrappers | oRPC defaults, or a feature vertical hook for real orchestration |
| component-triggered invalidation after mutation | move invalidation into oRPC defaults or a feature vertical hook |
| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` |
| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` |

View File

@ -5,7 +5,7 @@ description: Generate Vitest + React Testing Library tests for Dify frontend com
# Dify Frontend Testing Skill # Dify Frontend Testing Skill
This skill enables Claude to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices. This skill enables Codex to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices.
> **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`). > **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`).
@ -24,35 +24,27 @@ Apply this skill when the user:
**Do NOT apply** when: **Do NOT apply** when:
- User is asking about backend/API tests (Python/pytest) - User is asking about backend/API tests (Python/pytest)
- User is asking about E2E tests (Playwright/Cypress) - User is asking about E2E tests (Cucumber + Playwright under `e2e/`)
- User is only asking conceptual questions without code context - User is only asking conceptual questions without code context
## Quick Reference ## Quick Reference
### Tech Stack
| Tool | Version | Purpose |
|------|---------|---------|
| Vitest | 4.0.16 | Test runner |
| React Testing Library | 16.0 | Component testing |
| jsdom | - | Test environment |
| nock | 14.0 | HTTP mocking |
| TypeScript | 5.x | Type safety |
### Key Commands ### Key Commands
Run these commands from `web/`. From the repository root, prefix them with `pnpm -C web`.
```bash ```bash
# Run all tests # Run all tests
pnpm test pnpm test
# Watch mode # Watch mode
pnpm test:watch pnpm test --watch
# Run specific file # Run specific file
pnpm test path/to/file.spec.tsx pnpm test path/to/file.spec.tsx
# Generate coverage report # Generate coverage report
pnpm test:coverage pnpm test --coverage
# Analyze component complexity # Analyze component complexity
pnpm analyze-component <path> pnpm analyze-component <path>
@ -228,7 +220,10 @@ Every test should clearly separate:
### 2. Black-Box Testing ### 2. Black-Box Testing
- Test observable behavior, not implementation details - Test observable behavior, not implementation details
- Use semantic queries (getByRole, getByLabelText) - Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`)
- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`.
- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment.
- Do not assert decorative icons by test id. Assert the named control that contains them, or mark decorative icons `aria-hidden`.
- Avoid testing internal state directly - Avoid testing internal state directly
- **Prefer pattern matching over hardcoded strings** in assertions: - **Prefer pattern matching over hardcoded strings** in assertions:

View File

@ -56,7 +56,7 @@ See [Zustand Store Testing](#zustand-store-testing) section for full details.
| Location | Purpose | | Location | Purpose |
|----------|---------| |----------|---------|
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `next/image`, `zustand`) | | `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) |
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) | | `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
| `web/__mocks__/` | Reusable mock factories shared across multiple test files | | `web/__mocks__/` | Reusable mock factories shared across multiple test files |
| Test file | Test-specific mocks, inline with `vi.mock()` | | Test file | Test-specific mocks, inline with `vi.mock()` |
@ -216,28 +216,21 @@ describe('Component', () => {
}) })
``` ```
### 5. HTTP Mocking with Nock ### 5. HTTP and `fetch` Mocking
```typescript ```typescript
import nock from 'nock'
const GITHUB_HOST = 'https://api.github.com'
const GITHUB_PATH = '/repos/owner/repo'
const mockGithubApi = (status: number, body: Record<string, unknown>, delayMs = 0) => {
return nock(GITHUB_HOST)
.get(GITHUB_PATH)
.delay(delayMs)
.reply(status, body)
}
describe('GithubComponent', () => { describe('GithubComponent', () => {
afterEach(() => { beforeEach(() => {
nock.cleanAll() vi.clearAllMocks()
}) })
it('should display repo info', async () => { it('should display repo info', async () => {
mockGithubApi(200, { name: 'dify', stars: 1000 }) vi.mocked(globalThis.fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ name: 'dify', stars: 1000 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
render(<GithubComponent />) render(<GithubComponent />)
@ -247,7 +240,12 @@ describe('GithubComponent', () => {
}) })
it('should handle API error', async () => { it('should handle API error', async () => {
mockGithubApi(500, { message: 'Server error' }) vi.mocked(globalThis.fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
}),
)
render(<GithubComponent />) render(<GithubComponent />)
@ -258,6 +256,8 @@ describe('GithubComponent', () => {
}) })
``` ```
Prefer mocking `@/service/*` modules or spying on `global.fetch` / `ky` clients with deterministic responses. Do not introduce an HTTP interception dependency such as `nock` or MSW unless it is already declared in the workspace or adding it is part of the task.
### 6. Context Providers ### 6. Context Providers
```typescript ```typescript
@ -332,7 +332,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
1. **Don't mock Zustand store modules** - Use real stores with `setState()` 1. **Don't mock Zustand store modules** - Use real stores with `setState()`
1. Don't mock components you can import directly 1. Don't mock components you can import directly
1. Don't create overly simplified mocks that miss conditional logic 1. Don't create overly simplified mocks that miss conditional logic
1. Don't forget to clean up nock after each test 1. Don't leave HTTP mocks or service mock state leaking between tests
1. Don't use `any` types in mocks without necessity 1. Don't use `any` types in mocks without necessity
### Mock Decision Tree ### Mock Decision Tree

View File

@ -227,12 +227,12 @@ Failing tests compound:
**Fix failures immediately before proceeding.** **Fix failures immediately before proceeding.**
## Integration with Claude's Todo Feature ## Integration with Codex's Todo Feature
When using Claude for multi-file testing: When using Codex for multi-file testing:
1. **Ask Claude to create a todo list** before starting 1. **Create a todo list** before starting
1. **Request one file at a time** or ensure Claude processes incrementally 1. **Process one file at a time**
1. **Verify each test passes** before asking for the next 1. **Verify each test passes** before asking for the next
1. **Mark todos complete** as you progress 1. **Mark todos complete** as you progress

View File

@ -0,0 +1,71 @@
---
name: how-to-write-component
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
---
# How To Write A Component
Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation.
## Core Defaults
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
## Ownership
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
## Components, Props, And Types
- Type component signatures directly; do not use `FC` or `React.FC`.
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
## Queries And Mutations
- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape.
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid.
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
- Do not use deprecated `useInvalid` or `useReset`.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
## Component Boundaries
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
## You Might Not Need An Effect
- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration.
- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive.
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.
## Navigation And Performance
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.

0
.codex Normal file
View File

View File

@ -1,5 +1,6 @@
[run] [run]
omit = omit =
api/conftest.py
api/tests/* api/tests/*
api/migrations/* api/migrations/*
api/core/rag/datasource/vdb/* api/core/rag/datasource/vdb/*

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
**/node_modules
**/.pnpm-store
**/dist
**/.next
**/.turbo
**/.cache
**/__pycache__
**/*.pyc
**/.mypy_cache
**/.ruff_cache
.git
.github
*.md
!web/README.md
!api/README.md

4
.gitattributes vendored
View File

@ -5,3 +5,7 @@
# them. # them.
*.sh text eol=lf *.sh text eol=lf
# Codegen output must stay byte-identical across platforms so
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
*.generated.ts text eol=lf

64
.github/CODEOWNERS vendored
View File

@ -4,7 +4,7 @@
# Owners can be @username, @org/team-name, or email addresses. # Owners can be @username, @org/team-name, or email addresses.
# For more information, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # For more information, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
* @crazywoola @laipz8200 @Yeuoly * @crazywoola @laipz8200
# ESLint suppression file is maintained by autofix.ci pruning. # ESLint suppression file is maintained by autofix.ci pruning.
/eslint-suppressions.json /eslint-suppressions.json
@ -18,6 +18,10 @@
# Docs # Docs
/docs/ @crazywoola /docs/ @crazywoola
# CLI
/cli/ @langgenius/maintainers
/.github/workflows/cli-tests.yml @langgenius/maintainers
# Backend (default owner, more specific rules below will override) # Backend (default owner, more specific rules below will override)
/api/ @QuantumGhost /api/ @QuantumGhost
@ -85,39 +89,39 @@
/api/tasks/deal_dataset_vector_index_task.py @JohnJyong /api/tasks/deal_dataset_vector_index_task.py @JohnJyong
# Backend - Plugins # Backend - Plugins
/api/core/plugin/ @Mairuis @Yeuoly @Stream29 /api/core/plugin/ @WH-2099
/api/services/plugin/ @Mairuis @Yeuoly @Stream29 /api/services/plugin/ @WH-2099
/api/controllers/console/workspace/plugin.py @Mairuis @Yeuoly @Stream29 /api/controllers/console/workspace/plugin.py @WH-2099
/api/controllers/inner_api/plugin/ @Mairuis @Yeuoly @Stream29 /api/controllers/inner_api/plugin/ @WH-2099
/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @Mairuis @Yeuoly @Stream29 /api/tasks/process_tenant_plugin_autoupgrade_check_task.py @WH-2099
# Backend - Trigger/Schedule/Webhook # Backend - Trigger/Schedule/Webhook
/api/controllers/trigger/ @Mairuis @Yeuoly /api/controllers/trigger/ @CourTeous33
/api/controllers/console/app/workflow_trigger.py @Mairuis @Yeuoly /api/controllers/console/app/workflow_trigger.py @CourTeous33
/api/controllers/console/workspace/trigger_providers.py @Mairuis @Yeuoly /api/controllers/console/workspace/trigger_providers.py @CourTeous33
/api/core/trigger/ @Mairuis @Yeuoly /api/core/trigger/ @CourTeous33
/api/core/app/layers/trigger_post_layer.py @Mairuis @Yeuoly /api/core/app/layers/trigger_post_layer.py @CourTeous33
/api/services/trigger/ @Mairuis @Yeuoly /api/services/trigger/ @CourTeous33
/api/models/trigger.py @Mairuis @Yeuoly /api/models/trigger.py @CourTeous33
/api/fields/workflow_trigger_fields.py @Mairuis @Yeuoly /api/fields/workflow_trigger_fields.py @CourTeous33
/api/repositories/workflow_trigger_log_repository.py @Mairuis @Yeuoly /api/repositories/workflow_trigger_log_repository.py @CourTeous33
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @Mairuis @Yeuoly /api/repositories/sqlalchemy_workflow_trigger_log_repository.py @CourTeous33
/api/libs/schedule_utils.py @Mairuis @Yeuoly /api/libs/schedule_utils.py @CourTeous33
/api/services/workflow/scheduler.py @Mairuis @Yeuoly /api/services/workflow/scheduler.py @CourTeous33
/api/schedule/trigger_provider_refresh_task.py @Mairuis @Yeuoly /api/schedule/trigger_provider_refresh_task.py @CourTeous33
/api/schedule/workflow_schedule_task.py @Mairuis @Yeuoly /api/schedule/workflow_schedule_task.py @CourTeous33
/api/tasks/trigger_processing_tasks.py @Mairuis @Yeuoly /api/tasks/trigger_processing_tasks.py @CourTeous33
/api/tasks/trigger_subscription_refresh_tasks.py @Mairuis @Yeuoly /api/tasks/trigger_subscription_refresh_tasks.py @CourTeous33
/api/tasks/workflow_schedule_tasks.py @Mairuis @Yeuoly /api/tasks/workflow_schedule_tasks.py @CourTeous33
/api/tasks/workflow_cfs_scheduler/ @Mairuis @Yeuoly /api/tasks/workflow_cfs_scheduler/ @CourTeous33
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @Mairuis @Yeuoly /api/events/event_handlers/sync_plugin_trigger_when_app_created.py @CourTeous33
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @Mairuis @Yeuoly /api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @CourTeous33
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @Mairuis @Yeuoly /api/events/event_handlers/sync_workflow_schedule_when_app_published.py @CourTeous33
/api/events/event_handlers/sync_webhook_when_app_created.py @Mairuis @Yeuoly /api/events/event_handlers/sync_webhook_when_app_created.py @CourTeous33
# Backend - Async Workflow # Backend - Async Workflow
/api/services/async_workflow_service.py @Mairuis @Yeuoly /api/services/async_workflow_service.py @Mairuis
/api/tasks/async_workflow_tasks.py @Mairuis @Yeuoly /api/tasks/async_workflow_tasks.py @Mairuis
# Backend - Billing # Backend - Billing
/api/services/billing_service.py @hj24 @zyssyz123 /api/services/billing_service.py @hj24 @zyssyz123

View File

@ -1,10 +1,15 @@
name: Setup Web Environment name: Setup Web Environment
description: Set up Node.js, Vite+, pnpm, and web dependencies
runs: runs:
using: composite using: composite
steps: steps:
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
run_install: false
- name: Setup Vite+ - name: Setup Vite+
uses: voidzero-dev/setup-vp@4f5aa3e38c781f1b01e78fb9255527cee8a6efa6 # v1.8.0 uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: true cache: true

View File

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_SHA=${BASE_SHA:-}
HEAD_SHA=${HEAD_SHA:-}
MAIN_REF=${MAIN_REF:-origin/main}
REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x."
error() {
printf 'ERROR: %s\n' "$1" >&2
}
if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then
error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT"
exit 2
fi
if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then
error "Base commit '$BASE_SHA' is not available in the local git checkout."
exit 2
fi
if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then
error "Head commit '$HEAD_SHA' is not available in the local git checkout."
exit 2
fi
if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then
error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT"
exit 2
fi
failed=0
checked=0
while IFS= read -r commit_sha; do
[[ -n "$commit_sha" ]] || continue
checked=$((checked + 1))
subject=$(git log -1 --format=%s "$commit_sha")
source_sha=$(
git log -1 --format=%B "$commit_sha" \
| sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \
| tail -n 1
)
if [[ -z "$source_sha" ]]; then
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
failed=1
continue
fi
if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then
error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT"
failed=1
continue
fi
if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then
error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT"
failed=1
fi
done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA")
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
if [[ "$checked" -eq 0 ]]; then
echo "No PR commits to check."
else
echo "Verified $checked PR commit(s) include cherry-pick provenance from main."
fi

View File

@ -48,10 +48,23 @@ jobs:
run: uv sync --project api --dev run: uv sync --project api --dev
- name: Run dify config tests - name: Run dify config tests
run: uv run --project api dev/pytest/pytest_config_tests.py run: uv run --project api pytest api/tests/unit_tests/configs/test_env_consistency.py
- name: Run Unit Tests - name: Run Unit Tests
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh run: |
uv run --project api pytest \
-p no:benchmark \
--timeout "${PYTEST_TIMEOUT:-20}" \
-n auto \
api/tests/unit_tests \
api/providers/vdb/*/tests/unit_tests \
api/providers/trace/*/tests/unit_tests \
--ignore=api/tests/unit_tests/controllers
# Controller tests register Flask routes at import time, so keep them out of xdist.
uv run --project api pytest \
--timeout "${PYTEST_TIMEOUT:-20}" \
--cov-append \
api/tests/unit_tests/controllers
- name: Upload unit coverage data - name: Upload unit coverage data
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -96,32 +109,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: uv sync --project api --dev run: uv sync --project api --dev
- name: Set up dotenvs
run: |
cp docker/.env.example docker/.env
cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports
run: sh .github/workflows/expose_service_ports.sh
- name: Set up Sandbox
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
services: |
db_postgres
redis
sandbox
ssrf_proxy
- name: setup test config
run: |
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
- name: Run Integration Tests - name: Run Integration Tests
run: | run: |
uv run --project api pytest \ uv run --project api pytest \
-p no:benchmark \
--start-middleware \
-n auto \ -n auto \
--timeout "${PYTEST_TIMEOUT:-180}" \ --timeout "${PYTEST_TIMEOUT:-180}" \
api/tests/integration_tests/workflow \ api/tests/integration_tests/workflow \
@ -203,7 +195,7 @@ jobs:
- name: Report coverage - name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }} if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with: with:
files: ./coverage.xml files: ./coverage.xml
disable_search: true disable_search: true

View File

@ -116,6 +116,16 @@ jobs:
if: github.event_name != 'merge_group' if: github.event_name != 'merge_group'
uses: ./.github/actions/setup-web uses: ./.github/actions/setup-web
- name: Generate API docs
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: |
cd api
uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json
- name: Generate frontend contracts
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
- name: ESLint autofix - name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true' if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
run: | run: |

View File

@ -35,15 +35,15 @@ jobs:
- service_name: "build-api-amd64" - service_name: "build-api-amd64"
image_name_env: "DIFY_API_IMAGE_NAME" image_name_env: "DIFY_API_IMAGE_NAME"
artifact_context: "api" artifact_context: "api"
build_context: "{{defaultContext}}:api" build_context: "{{defaultContext}}"
file: "Dockerfile" file: "api/Dockerfile"
platform: linux/amd64 platform: linux/amd64
runs_on: depot-ubuntu-24.04-4 runs_on: depot-ubuntu-24.04-4
- service_name: "build-api-arm64" - service_name: "build-api-arm64"
image_name_env: "DIFY_API_IMAGE_NAME" image_name_env: "DIFY_API_IMAGE_NAME"
artifact_context: "api" artifact_context: "api"
build_context: "{{defaultContext}}:api" build_context: "{{defaultContext}}"
file: "Dockerfile" file: "api/Dockerfile"
platform: linux/arm64 platform: linux/arm64
runs_on: depot-ubuntu-24.04-4 runs_on: depot-ubuntu-24.04-4
- service_name: "build-web-amd64" - service_name: "build-web-amd64"
@ -117,8 +117,8 @@ jobs:
matrix: matrix:
include: include:
- service_name: "validate-api-amd64" - service_name: "validate-api-amd64"
build_context: "{{defaultContext}}:api" build_context: "{{defaultContext}}"
file: "Dockerfile" file: "api/Dockerfile"
- service_name: "validate-web-amd64" - service_name: "validate-web-amd64"
build_context: "{{defaultContext}}" build_context: "{{defaultContext}}"
file: "web/Dockerfile" file: "web/Dockerfile"

88
.github/workflows/cli-release.yml vendored Normal file
View File

@ -0,0 +1,88 @@
name: CLI Release
on:
workflow_dispatch:
push:
tags:
- 'difyctl-v*'
concurrency:
group: cli-release-${{ github.ref }}
cancel-in-progress: true
jobs:
release:
name: build standalone binaries (all targets)
runs-on: depot-ubuntu-24.04
if: github.repository == 'langgenius/dify'
permissions:
contents: write
defaults:
run:
shell: bash
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
with:
bun-version: latest
- name: Read cli/package.json
id: manifest
run: |
version=$(node -p "require('./package.json').version")
channel=$(node -p "require('./package.json').difyctl.channel")
minDify=$(node -p "require('./package.json').difyctl.compat.minDify")
maxDify=$(node -p "require('./package.json').difyctl.compat.maxDify")
{
echo "version=$version"
echo "channel=$channel"
echo "minDify=$minDify"
echo "maxDify=$maxDify"
} >> "$GITHUB_OUTPUT"
- name: Validate manifest
run: scripts/release-validate-manifest.sh
- name: Install cross-arch native prebuilds
# Re-installs node_modules with every @napi-rs/keyring platform variant
# so `bun build --compile` can embed the right .node into each target.
working-directory: ./
run: NPM_CONFIG_USERCONFIG="$PWD/cli/scripts/cross-arch.npmrc" pnpm install --frozen-lockfile
- name: Compile standalone binaries (all targets)
env:
CLI_VERSION: ${{ steps.manifest.outputs.version }}
DIFYCTL_CHANNEL: ${{ steps.manifest.outputs.channel }}
DIFYCTL_MIN_DIFY: ${{ steps.manifest.outputs.minDify }}
DIFYCTL_MAX_DIFY: ${{ steps.manifest.outputs.maxDify }}
run: |
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
pnpm build:bin
- name: Generate sha256 checksum file
env:
CLI_VERSION: ${{ steps.manifest.outputs.version }}
run: scripts/release-write-checksums.sh
- name: Publish GitHub Release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: difyctl-v${{ steps.manifest.outputs.version }}
name: difyctl ${{ steps.manifest.outputs.version }}
prerelease: ${{ steps.manifest.outputs.channel != 'stable' }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
cli/dist/bin/difyctl-v*

60
.github/workflows/cli-smoke.yml vendored Normal file
View File

@ -0,0 +1,60 @@
name: CLI Smoke (live dify)
on:
workflow_dispatch:
inputs:
dify_version:
description: "Dify image tag to test against (e.g. 1.7.0)"
type: string
required: true
cli_ref:
description: "Git ref to build the cli from (default: current branch)"
type: string
required: false
permissions:
contents: read
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Bring up dify
env:
DIFY_VERSION: ${{ inputs.dify_version }}
run: |
cd docker
cp .env.example .env
DIFY_API_IMAGE_TAG="$DIFY_VERSION" \
DIFY_WEB_IMAGE_TAG="$DIFY_VERSION" \
docker compose up -d api worker web db redis
for i in $(seq 1 60); do
if curl -fsS http://localhost:5001/health >/dev/null 2>&1; then
echo "dify api ready after ${i}s"
break
fi
sleep 1
done
- name: Run smoke against live dify
working-directory: ./cli
run: pnpm exec tsx scripts/run-smoke.ts --base-url http://localhost:5001
- name: Dump dify logs on failure
if: failure()
run: |
cd docker
docker compose logs api worker web --tail=200

46
.github/workflows/cli-tests.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: CLI Tests
on:
workflow_call:
secrets:
CODECOV_TOKEN:
required: false
permissions:
contents: read
concurrency:
group: cli-tests-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
test:
name: CLI Tests
runs-on: depot-ubuntu-24.04
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
defaults:
run:
shell: bash
working-directory: ./cli
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: CI pipeline (typecheck, lint, coverage, build)
run: pnpm ci
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
directory: cli/coverage
flags: cli
env:
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}

View File

@ -37,7 +37,7 @@ jobs:
- name: Prepare middleware env - name: Prepare middleware env
run: | run: |
cd docker cd docker
cp middleware.env.example middleware.env cp envs/middleware.env.example middleware.env
- name: Set up Middlewares - name: Set up Middlewares
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
@ -87,7 +87,7 @@ jobs:
- name: Prepare middleware env for MySQL - name: Prepare middleware env for MySQL
run: | run: |
cd docker cd docker
cp middleware.env.example middleware.env cp envs/middleware.env.example middleware.env
sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' middleware.env sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' middleware.env
sed -i 's/DB_HOST=db_postgres/DB_HOST=db_mysql/' middleware.env sed -i 's/DB_HOST=db_postgres/DB_HOST=db_mysql/' middleware.env
sed -i 's/DB_PORT=5432/DB_PORT=3306/' middleware.env sed -i 's/DB_PORT=5432/DB_PORT=3306/' middleware.env

View File

@ -6,6 +6,12 @@ on:
- "main" - "main"
paths: paths:
- api/Dockerfile - api/Dockerfile
- api/Dockerfile.dockerignore
- api/pyproject.toml
- api/uv.lock
- dify-agent/pyproject.toml
- dify-agent/README.md
- dify-agent/src/**
- web/Dockerfile - web/Dockerfile
concurrency: concurrency:
@ -25,13 +31,13 @@ jobs:
- service_name: "api-amd64" - service_name: "api-amd64"
platform: linux/amd64 platform: linux/amd64
runs_on: depot-ubuntu-24.04-4 runs_on: depot-ubuntu-24.04-4
context: "{{defaultContext}}:api" context: "{{defaultContext}}"
file: "Dockerfile" file: "api/Dockerfile"
- service_name: "api-arm64" - service_name: "api-arm64"
platform: linux/arm64 platform: linux/arm64
runs_on: depot-ubuntu-24.04-4 runs_on: depot-ubuntu-24.04-4
context: "{{defaultContext}}:api" context: "{{defaultContext}}"
file: "Dockerfile" file: "api/Dockerfile"
- service_name: "web-amd64" - service_name: "web-amd64"
platform: linux/amd64 platform: linux/amd64
runs_on: depot-ubuntu-24.04-4 runs_on: depot-ubuntu-24.04-4
@ -64,8 +70,8 @@ jobs:
matrix: matrix:
include: include:
- service_name: "api-amd64" - service_name: "api-amd64"
context: "{{defaultContext}}:api" context: "{{defaultContext}}"
file: "Dockerfile" file: "api/Dockerfile"
- service_name: "web-amd64" - service_name: "web-amd64"
context: "{{defaultContext}}" context: "{{defaultContext}}"
file: "web/Dockerfile" file: "web/Dockerfile"

View File

@ -1,17 +0,0 @@
#!/bin/bash
yq eval '.services.weaviate.ports += ["8080:8080"]' -i docker/docker-compose.yaml
yq eval '.services.weaviate.ports += ["50051:50051"]' -i docker/docker-compose.yaml
yq eval '.services.qdrant.ports += ["6333:6333"]' -i docker/docker-compose.yaml
yq eval '.services.chroma.ports += ["8000:8000"]' -i docker/docker-compose.yaml
yq eval '.services["milvus-standalone"].ports += ["19530:19530"]' -i docker/docker-compose.yaml
yq eval '.services.pgvector.ports += ["5433:5432"]' -i docker/docker-compose.yaml
yq eval '.services["pgvecto-rs"].ports += ["5431:5432"]' -i docker/docker-compose.yaml
yq eval '.services["elasticsearch"].ports += ["9200:9200"]' -i docker/docker-compose.yaml
yq eval '.services.couchbase-server.ports += ["8091-8096:8091-8096"]' -i docker/docker-compose.yaml
yq eval '.services.couchbase-server.ports += ["11210:11210"]' -i docker/docker-compose.yaml
yq eval '.services.tidb.ports += ["4000:4000"]' -i docker/tidb/docker-compose.yaml
yq eval '.services.oceanbase.ports += ["2881:2881"]' -i docker/docker-compose.yaml
yq eval '.services.opengauss.ports += ["6600:6600"]' -i docker/docker-compose.yaml
echo "Ports exposed for sandbox, weaviate (HTTP 8080, gRPC 50051), tidb, qdrant, chroma, milvus, pgvector, pgvecto-rs, elasticsearch, couchbase, opengauss"

View File

@ -0,0 +1,49 @@
name: Hotfix Cherry-Pick Provenance
on:
pull_request:
branches:
- 'hotfix/**'
- 'lts/**'
types:
- opened
- edited
- reopened
- ready_for_review
- synchronize
permissions:
contents: read
concurrency:
group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
check-cherry-pick-provenance:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Fetch PR base, PR head, and main
env:
BASE_REF: ${{ github.base_ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
git fetch --no-tags --prune origin \
"+refs/heads/main:refs/remotes/origin/main" \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head"
- name: Load checker from main
run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
- name: Check PR commits
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
MAIN_REF: origin/main
run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"

View File

@ -9,6 +9,6 @@ jobs:
pull-requests: write pull-requests: write
runs-on: depot-ubuntu-24.04 runs-on: depot-ubuntu-24.04
steps: steps:
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with: with:
sync-labels: true sync-labels: true

View File

@ -42,6 +42,7 @@ jobs:
runs-on: depot-ubuntu-24.04 runs-on: depot-ubuntu-24.04
outputs: outputs:
api-changed: ${{ steps.changes.outputs.api }} api-changed: ${{ steps.changes.outputs.api }}
cli-changed: ${{ steps.changes.outputs.cli }}
e2e-changed: ${{ steps.changes.outputs.e2e }} e2e-changed: ${{ steps.changes.outputs.e2e }}
web-changed: ${{ steps.changes.outputs.web }} web-changed: ${{ steps.changes.outputs.web }}
vdb-changed: ${{ steps.changes.outputs.vdb }} vdb-changed: ${{ steps.changes.outputs.vdb }}
@ -55,14 +56,25 @@ jobs:
api: api:
- 'api/**' - 'api/**'
- '.github/workflows/api-tests.yml' - '.github/workflows/api-tests.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.example' - 'docker/.env.example'
- 'docker/middleware.env.example' - 'docker/envs/middleware.env.example'
- 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose.middleware.yaml'
- 'docker/docker-compose-template.yaml' - 'docker/docker-compose-template.yaml'
- 'docker/generate_docker_compose' - 'docker/generate_docker_compose'
- 'docker/ssrf_proxy/**' - 'docker/ssrf_proxy/**'
- 'docker/volumes/sandbox/conf/**' - 'docker/volumes/sandbox/conf/**'
cli:
- 'cli/**'
- 'packages/tsconfig/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'eslint.config.mjs'
- '.npmrc'
- '.nvmrc'
- '.github/workflows/cli-tests.yml'
- '.github/workflows/cli-docker-build.yml'
- '.github/actions/setup-web/**'
web: web:
- 'web/**' - 'web/**'
- 'packages/**' - 'packages/**'
@ -84,17 +96,19 @@ jobs:
- 'pnpm-workspace.yaml' - 'pnpm-workspace.yaml'
- '.nvmrc' - '.nvmrc'
- 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose.middleware.yaml'
- 'docker/middleware.env.example' - 'docker/envs/middleware.env.example'
- '.github/workflows/web-e2e.yml' - '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**' - '.github/actions/setup-web/**'
vdb: vdb:
- 'api/core/rag/datasource/**' - 'api/core/rag/datasource/**'
- 'api/tests/integration_tests/vdb/**' - 'api/tests/integration_tests/vdb/**'
- 'api/conftest.py'
- 'api/tests/pytest_dify.py'
- 'api/providers/vdb/*/tests/**' - 'api/providers/vdb/*/tests/**'
- '.github/workflows/vdb-tests.yml' - '.github/workflows/vdb-tests.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.example' - 'docker/.env.example'
- 'docker/middleware.env.example' - 'docker/envs/middleware.env.example'
- 'docker/docker-compose.pytest.ports.yaml'
- 'docker/docker-compose.yaml' - 'docker/docker-compose.yaml'
- 'docker/docker-compose-template.yaml' - 'docker/docker-compose-template.yaml'
- 'docker/generate_docker_compose' - 'docker/generate_docker_compose'
@ -114,9 +128,8 @@ jobs:
- 'api/migrations/**' - 'api/migrations/**'
- 'api/.env.example' - 'api/.env.example'
- '.github/workflows/db-migration-test.yml' - '.github/workflows/db-migration-test.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.example' - 'docker/.env.example'
- 'docker/middleware.env.example' - 'docker/envs/middleware.env.example'
- 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose.middleware.yaml'
- 'docker/docker-compose-template.yaml' - 'docker/docker-compose-template.yaml'
- 'docker/generate_docker_compose' - 'docker/generate_docker_compose'
@ -184,6 +197,66 @@ jobs:
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2 echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1 exit 1
cli-tests-run:
name: Run CLI Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed == 'true'
uses: ./.github/workflows/cli-tests.yml
secrets: inherit
cli-tests-skip:
name: Skip CLI Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped CLI tests
run: echo "No CLI-related changes detected; skipping CLI tests."
cli-tests:
name: CLI Tests
if: ${{ always() }}
needs:
- pre_job
- check-changes
- cli-tests-run
- cli-tests-skip
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize CLI Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }}
RUN_RESULT: ${{ needs.cli-tests-run.result }}
SKIP_RESULT: ${{ needs.cli-tests-skip.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "CLI tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "CLI tests ran successfully."
exit 0
fi
echo "CLI tests were required but finished with result: $RUN_RESULT" >&2
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "CLI tests were skipped because no CLI-related files changed."
exit 0
fi
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
web-tests-run: web-tests-run:
name: Run Web Tests name: Run Web Tests
needs: needs:

View File

@ -77,10 +77,28 @@ jobs:
} }
if (diff.trim()) { if (diff.trim()) {
await github.rest.issues.createComment({ const body = '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>';
const marker = '### Pyrefly Diff';
const { data: comments } = await github.rest.issues.listComments({
issue_number: prNumber, issue_number: prNumber,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
body: '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>',
}); });
const existing = comments.find((comment) => comment.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
comment_id: existing.id,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
} else {
await github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
}
} }

View File

@ -103,9 +103,26 @@ jobs:
].join('\n') ].join('\n')
: '### Pyrefly Diff\nNo changes detected.'; : '### Pyrefly Diff\nNo changes detected.';
await github.rest.issues.createComment({ const marker = '### Pyrefly Diff';
const { data: comments } = await github.rest.issues.listComments({
issue_number: prNumber, issue_number: prNumber,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
body,
}); });
const existing = comments.find((comment) => comment.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
comment_id: existing.id,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
} else {
await github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
}

View File

@ -63,8 +63,8 @@ jobs:
id: render id: render
run: | run: |
comment_body="$(uv run --directory api python libs/pyrefly_type_coverage.py \ comment_body="$(uv run --directory api python libs/pyrefly_type_coverage.py \
--base base_report.json \ --base "$GITHUB_WORKSPACE/base_report.json" \
< pr_report.json)" < "$GITHUB_WORKSPACE/pr_report.json")"
{ {
echo "### Pyrefly Type Coverage" echo "### Pyrefly Type Coverage"

View File

@ -65,6 +65,9 @@ jobs:
# Save structured data for the fork-PR comment workflow # Save structured data for the fork-PR comment workflow
cp /tmp/pyrefly_report_pr.json pr_report.json cp /tmp/pyrefly_report_pr.json pr_report.json
cp /tmp/pyrefly_report_base.json base_report.json cp /tmp/pyrefly_report_base.json base_report.json
# Keep fork-PR comments correct while the trusted workflow_run job is
# still using the default-branch renderer, which resolves --base from api/.
cp /tmp/pyrefly_report_base.json api/base_report.json
- name: Save PR number - name: Save PR number
run: | run: |
@ -77,6 +80,7 @@ jobs:
path: | path: |
pr_report.json pr_report.json
base_report.json base_report.json
api/base_report.json
pr_number.txt pr_number.txt
- name: Comment PR with type coverage - name: Comment PR with type coverage

View File

@ -47,6 +47,10 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
run: uv run --directory api --dev lint-imports run: uv run --directory api --dev lint-imports
- name: Run Response Contract Linter
if: steps.changed-files.outputs.any_changed == 'true'
run: uv run --project api --dev python api/dev/lint_response_contracts.py --fail-on-mismatch
- name: Run Type Checks - name: Run Type Checks
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
run: make type-check-core run: make type-check-core

View File

@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync - name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != '' if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -48,14 +48,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: uv sync --project api --dev run: uv sync --project api --dev
- name: Set up dotenvs
run: |
cp docker/.env.example docker/.env
cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports
run: sh .github/workflows/expose_service_ports.sh
# - name: Set up Vector Store (TiDB) # - name: Set up Vector Store (TiDB)
# uses: hoverkraft-tech/compose-action@v2.0.2 # uses: hoverkraft-tech/compose-action@v2.0.2
# with: # with:
@ -64,32 +56,13 @@ jobs:
# tidb # tidb
# tiflash # tiflash
- name: Set up Full Vector Store Matrix
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.yaml
services: |
weaviate
qdrant
couchbase-server
etcd
minio
milvus-standalone
pgvecto-rs
pgvector
chroma
elasticsearch
oceanbase
- name: setup test config
run: |
echo $(pwd)
ls -lah .
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
# - name: Check VDB Ready (TiDB) # - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py # run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
- name: Test Vector Stores - name: Test Vector Stores
run: uv run --project api bash dev/pytest/pytest_vdb.sh run: |
uv run --project api pytest \
--start-vdb \
--vdb-services "weaviate,qdrant,couchbase-server,etcd,minio,milvus-standalone,pgvecto-rs,pgvector,chroma,elasticsearch,oceanbase" \
--timeout "${PYTEST_TIMEOUT:-180}" \
api/providers/vdb/*/tests/integration_tests

View File

@ -45,14 +45,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: uv sync --project api --dev run: uv sync --project api --dev
- name: Set up dotenvs
run: |
cp docker/.env.example docker/.env
cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports
run: sh .github/workflows/expose_service_ports.sh
# - name: Set up Vector Store (TiDB) # - name: Set up Vector Store (TiDB)
# uses: hoverkraft-tech/compose-action@v2.0.2 # uses: hoverkraft-tech/compose-action@v2.0.2
# with: # with:
@ -61,31 +53,14 @@ jobs:
# tidb # tidb
# tiflash # tiflash
- name: Set up Vector Stores for Smoke Coverage
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.yaml
services: |
db_postgres
redis
weaviate
qdrant
pgvector
chroma
- name: setup test config
run: |
echo $(pwd)
ls -lah .
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
# - name: Check VDB Ready (TiDB) # - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py # run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
- name: Test Vector Stores - name: Test Vector Stores
run: | run: |
uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \ uv run --project api pytest \
--start-vdb \
--timeout "${PYTEST_TIMEOUT:-180}" \
api/providers/vdb/vdb-chroma/tests/integration_tests \ api/providers/vdb/vdb-chroma/tests/integration_tests \
api/providers/vdb/vdb-pgvector/tests/integration_tests \ api/providers/vdb/vdb-pgvector/tests/integration_tests \
api/providers/vdb/vdb-qdrant/tests/integration_tests \ api/providers/vdb/vdb-qdrant/tests/integration_tests \

View File

@ -39,7 +39,7 @@ jobs:
uses: ./.github/actions/setup-web uses: ./.github/actions/setup-web
- name: Run tests - name: Run tests
run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
- name: Upload blob report - name: Upload blob report
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
@ -83,7 +83,7 @@ jobs:
- name: Report coverage - name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }} if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with: with:
directory: web/coverage directory: web/coverage
flags: web flags: web
@ -117,7 +117,7 @@ jobs:
- name: Report coverage - name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }} if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with: with:
directory: packages/dify-ui/coverage directory: packages/dify-ui/coverage
flags: dify-ui flags: dify-ui

9
.gitignore vendored
View File

@ -115,6 +115,12 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# cli/ has a src/env/ module (DIFY_* registry) — don't treat it as a venv
!/cli/src/env/
!/cli/src/commands/env/
# cli/scripts/lib/ holds TS build helpers (resolve-buildinfo etc.) — don't treat as Python lib/
!/cli/scripts/lib/
.conda/ .conda/
# Spyder project settings # Spyder project settings
@ -247,8 +253,9 @@ scripts/stress-test/reports/
# settings # settings
*.local.json *.local.json
*.local.md *.local.md
*.local.toml
# Code Agent Folder # Code Agent Folder
.qoder/* .qoder/*
.context/*
.eslintcache .eslintcache

View File

@ -9,6 +9,7 @@ The codebase is split into:
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design - **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
- **Frontend Web** (`/web`): Next.js application using TypeScript and React - **Frontend Web** (`/web`): Next.js application using TypeScript and React
- **Docker deployment** (`/docker`): Containerized deployment configurations - **Docker deployment** (`/docker`): Containerized deployment configurations
- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent
## Backend Workflow ## Backend Workflow

View File

@ -3,6 +3,10 @@ DOCKER_REGISTRY=langgenius
WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web
API_IMAGE=$(DOCKER_REGISTRY)/dify-api API_IMAGE=$(DOCKER_REGISTRY)/dify-api
VERSION=latest VERSION=latest
DOCKER_DIR=docker
DOCKER_MIDDLEWARE_ENV=$(DOCKER_DIR)/middleware.env
DOCKER_MIDDLEWARE_ENV_EXAMPLE=$(DOCKER_DIR)/envs/middleware.env.example
DOCKER_MIDDLEWARE_PROJECT=dify-middlewares-dev
# Default target - show help # Default target - show help
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@ -17,8 +21,13 @@ dev-setup: prepare-docker prepare-web prepare-api
# Step 1: Prepare Docker middleware # Step 1: Prepare Docker middleware
prepare-docker: prepare-docker:
@echo "🐳 Setting up Docker middleware..." @echo "🐳 Setting up Docker middleware..."
@cp -n docker/middleware.env.example docker/middleware.env 2>/dev/null || echo "Docker middleware.env already exists" @if [ ! -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev up -d cp "$(DOCKER_MIDDLEWARE_ENV_EXAMPLE)" "$(DOCKER_MIDDLEWARE_ENV)"; \
echo "Docker middleware.env created"; \
else \
echo "Docker middleware.env already exists"; \
fi
@cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) up -d
@echo "✅ Docker middleware started" @echo "✅ Docker middleware started"
# Step 2: Prepare web environment # Step 2: Prepare web environment
@ -39,12 +48,18 @@ prepare-api:
# Clean dev environment # Clean dev environment
dev-clean: dev-clean:
@echo "⚠️ Stopping Docker containers..." @echo "⚠️ Stopping Docker containers..."
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev down @if [ -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) down; \
else \
echo "Docker middleware.env does not exist, skipping compose down"; \
fi
@echo "🗑️ Removing volumes..." @echo "🗑️ Removing volumes..."
@rm -rf docker/volumes/db @rm -rf docker/volumes/db
@rm -rf docker/volumes/mysql
@rm -rf docker/volumes/redis @rm -rf docker/volumes/redis
@rm -rf docker/volumes/plugin_daemon @rm -rf docker/volumes/plugin_daemon
@rm -rf docker/volumes/weaviate @rm -rf docker/volumes/weaviate
@rm -rf docker/volumes/sandbox/dependencies
@rm -rf api/storage @rm -rf api/storage
@echo "✅ Cleanup complete" @echo "✅ Cleanup complete"
@ -60,24 +75,29 @@ check:
@echo "✅ Code check complete" @echo "✅ Code check complete"
lint: lint:
@echo "🔧 Running ruff format, check with fixes, import linter, and dotenv-linter..." @echo "🔧 Running ruff format, check with fixes, response contract lint, import linter, and dotenv-linter..."
@uv run --project api --dev ruff format ./api @uv run --project api --dev ruff format ./api
@uv run --project api --dev ruff check --fix ./api @uv run --project api --dev ruff check --fix ./api
@$(MAKE) api-contract-lint
@uv run --directory api --dev lint-imports @uv run --directory api --dev lint-imports
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example @uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
@echo "✅ Linting complete" @echo "✅ Linting complete"
api-contract-lint:
@echo "🔎 Linting Flask response contracts..."
@uv run --project api --dev python api/dev/lint_response_contracts.py
@echo "✅ Response contract lint complete"
type-check: type-check:
@echo "📝 Running type checks (basedpyright + pyrefly + mypy)..." @echo "📝 Running type checks (pyrefly + mypy)..."
@./dev/basedpyright-check $(PATH_TO_CHECK) @./dev/pyrefly-check-local $(PATH_TO_CHECK)
@./dev/pyrefly-check-local @uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped .
@echo "✅ Type checks complete" @echo "✅ Type checks complete"
type-check-core: type-check-core:
@echo "📝 Running core type checks (basedpyright + mypy)..." @echo "📝 Running core type checks (pyrefly + mypy)..."
@./dev/basedpyright-check $(PATH_TO_CHECK) @./dev/pyrefly-check-local $(PATH_TO_CHECK)
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped . @uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@echo "✅ Core type checks complete" @echo "✅ Core type checks complete"
test: test:
@ -86,7 +106,46 @@ test:
echo "Target: $(TARGET_TESTS)"; \ echo "Target: $(TARGET_TESTS)"; \
uv run --project api --dev pytest $(TARGET_TESTS); \ uv run --project api --dev pytest $(TARGET_TESTS); \
else \ else \
PYTEST_XDIST_ARGS="-n auto" uv run --project api --dev dev/pytest/pytest_unit_tests.sh; \ echo "Running backend unit tests"; \
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
api/tests/unit_tests \
api/providers/vdb/*/tests/unit_tests \
api/providers/trace/*/tests/unit_tests \
--ignore=api/tests/unit_tests/controllers; \
uv run --project api --dev pytest --timeout "$${PYTEST_TIMEOUT:-20}" --cov-append \
api/tests/unit_tests/controllers; \
fi
@echo "✅ Unit tests complete"
test-all:
@echo "🧪 Running full backend test suite..."
@if [ -n "$(TARGET_TESTS)" ]; then \
echo "Target: $(TARGET_TESTS)"; \
uv run --project api --dev pytest $(TARGET_TESTS); \
else \
echo "Running backend unit tests"; \
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
api/tests/unit_tests \
api/providers/vdb/*/tests/unit_tests \
api/providers/trace/*/tests/unit_tests \
--ignore=api/tests/unit_tests/controllers; \
uv run --project api --dev pytest --timeout "$${PYTEST_TIMEOUT:-20}" --cov-append \
api/tests/unit_tests/controllers; \
echo "Running backend integration tests"; \
uv run --project api --dev pytest -p no:benchmark --start-middleware -n auto \
--timeout "$${PYTEST_TIMEOUT:-180}" \
--cov-append \
api/tests/integration_tests/workflow \
api/tests/integration_tests/tools \
api/tests/test_containers_integration_tests; \
echo "Running VDB smoke tests"; \
uv run --project api --dev pytest --start-vdb \
--timeout "$${PYTEST_TIMEOUT:-180}" \
--cov-append \
api/providers/vdb/vdb-chroma/tests/integration_tests \
api/providers/vdb/vdb-pgvector/tests/integration_tests \
api/providers/vdb/vdb-qdrant/tests/integration_tests \
api/providers/vdb/vdb-weaviate/tests/integration_tests; \
fi fi
@echo "✅ Tests complete" @echo "✅ Tests complete"
@ -132,15 +191,17 @@ help:
@echo " make prepare-docker - Set up Docker middleware" @echo " make prepare-docker - Set up Docker middleware"
@echo " make prepare-web - Set up web environment" @echo " make prepare-web - Set up web environment"
@echo " make prepare-api - Set up API environment" @echo " make prepare-api - Set up API environment"
@echo " make dev-clean - Stop Docker middleware containers" @echo " make dev-clean - Stop Docker middleware containers and remove dev data"
@echo "" @echo ""
@echo "Backend Code Quality:" @echo "Backend Code Quality:"
@echo " make format - Format code with ruff" @echo " make format - Format code with ruff"
@echo " make check - Check code with ruff" @echo " make check - Check code with ruff"
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)" @echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
@echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)" @echo " make api-contract-lint - Check Flask response docs against returned schemas"
@echo " make type-check-core - Run core type checks (basedpyright, mypy)" @echo " make type-check - Run type checks (pyrefly, mypy)"
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)" @echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
@echo " make test-all - Run full backend tests, including Docker-backed suites"
@echo "" @echo ""
@echo "Docker Build Targets:" @echo "Docker Build Targets:"
@echo " make build-web - Build web Docker image" @echo " make build-web - Build web Docker image"
@ -150,4 +211,4 @@ help:
@echo " make build-push-all - Build and push all Docker images" @echo " make build-push-all - Build and push all Docker images"
# Phony targets # Phony targets
.PHONY: build-web build-api push-web push-api build-all push-all build-push-all dev-setup prepare-docker prepare-web prepare-api dev-clean help format check lint type-check test .PHONY: build-web build-api push-web push-api build-all push-all build-push-all dev-setup prepare-docker prepare-web prepare-api dev-clean help format check lint api-contract-lint type-check test test-all

View File

@ -74,7 +74,8 @@ Dify is an open-source LLM app development platform. Its intuitive interface com
The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine: The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine:
```bash ```bash
cd dify/docker cd dify
cd docker
cp .env.example .env cp .env.example .env
docker compose up -d docker compose up -d
``` ```
@ -136,7 +137,7 @@ Star Dify on GitHub and be instantly notified of new releases.
### Custom configurations ### Custom configurations
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments). If you need to customize the configuration, edit `docker/.env`. The essential startup defaults live in [`docker/.env.example`](docker/.env.example), and optional advanced variables are split under `docker/envs/` by theme. After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
### Metrics Monitoring with Grafana ### Metrics Monitoring with Grafana

View File

@ -34,7 +34,7 @@ TRIGGER_URL=http://localhost:5001
FILES_ACCESS_TIMEOUT=300 FILES_ACCESS_TIMEOUT=300
# Collaboration mode toggle # Collaboration mode toggle
ENABLE_COLLABORATION_MODE=false ENABLE_COLLABORATION_MODE=true
# Access token expiration time in minutes # Access token expiration time in minutes
ACCESS_TOKEN_EXPIRE_MINUTES=60 ACCESS_TOKEN_EXPIRE_MINUTES=60
@ -88,6 +88,10 @@ REDIS_HEALTH_CHECK_INTERVAL=30
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1 CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
CELERY_BACKEND=redis CELERY_BACKEND=redis
# Ops trace retry configuration
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5
# Database configuration # Database configuration
DB_TYPE=postgresql DB_TYPE=postgresql
DB_USERNAME=postgres DB_USERNAME=postgres
@ -98,6 +102,8 @@ DB_DATABASE=dify
SQLALCHEMY_POOL_PRE_PING=true SQLALCHEMY_POOL_PRE_PING=true
SQLALCHEMY_POOL_TIMEOUT=30 SQLALCHEMY_POOL_TIMEOUT=30
# Connection pool reset behavior on return
SQLALCHEMY_POOL_RESET_ON_RETURN=rollback
# Storage configuration # Storage configuration
# use for store upload files, private keys... # use for store upload files, private keys...
@ -381,7 +387,7 @@ VIKINGDB_ACCESS_KEY=your-ak
VIKINGDB_SECRET_KEY=your-sk VIKINGDB_SECRET_KEY=your-sk
VIKINGDB_REGION=cn-shanghai VIKINGDB_REGION=cn-shanghai
VIKINGDB_HOST=api-vikingdb.xxx.volces.com VIKINGDB_HOST=api-vikingdb.xxx.volces.com
VIKINGDB_SCHEMA=http VIKINGDB_SCHEME=http
VIKINGDB_CONNECTION_TIMEOUT=30 VIKINGDB_CONNECTION_TIMEOUT=30
VIKINGDB_SOCKET_TIMEOUT=30 VIKINGDB_SOCKET_TIMEOUT=30
@ -432,8 +438,6 @@ UPLOAD_FILE_EXTENSION_BLACKLIST=
# Model configuration # Model configuration
MULTIMODAL_SEND_FORMAT=base64 MULTIMODAL_SEND_FORMAT=base64
PROMPT_GENERATION_MAX_TOKENS=512
CODE_GENERATION_MAX_TOKENS=1024
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
# Mail configuration, support: resend, smtp, sendgrid # Mail configuration, support: resend, smtp, sendgrid
@ -553,7 +557,7 @@ MAX_VARIABLE_SIZE=204800
# GraphEngine Worker Pool Configuration # GraphEngine Worker Pool Configuration
# Minimum number of workers per GraphEngine instance (default: 1) # Minimum number of workers per GraphEngine instance (default: 1)
GRAPH_ENGINE_MIN_WORKERS=1 GRAPH_ENGINE_MIN_WORKERS=3
# Maximum number of workers per GraphEngine instance (default: 10) # Maximum number of workers per GraphEngine instance (default: 10)
GRAPH_ENGINE_MAX_WORKERS=10 GRAPH_ENGINE_MAX_WORKERS=10
# Queue depth threshold that triggers worker scale up (default: 3) # Queue depth threshold that triggers worker scale up (default: 3)
@ -763,6 +767,7 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while use redis as event bus. # Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to enable this for large deployments. # It's highly recommended to enable this for large deployments.
EVENT_BUS_REDIS_USE_CLUSTERS=false EVENT_BUS_REDIS_USE_CLUSTERS=false
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
# Whether to Enable human input timeout check task # Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true

View File

@ -180,6 +180,8 @@ Quick checks while iterating:
- Format: `make format` - Format: `make format`
- Lint (includes auto-fix): `make lint` - Lint (includes auto-fix): `make lint`
- Type check: `make type-check` - Type check: `make type-check`
- Unit tests: `make test`
- Full backend tests, including Docker-backed suites: `make test-all`
- Targeted tests: `make test TARGET_TESTS=./api/tests/<target_tests>` - Targeted tests: `make test TARGET_TESTS=./api/tests/<target_tests>`
Before opening a PR / submitting: Before opening a PR / submitting:
@ -193,6 +195,11 @@ Before opening a PR / submitting:
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic. - Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
- Services: coordinate repositories, providers, background tasks; keep side effects explicit. - Services: coordinate repositories, providers, background tasks; keep side effects explicit.
- Document non-obvious behaviour with concise docstrings and comments. - Document non-obvious behaviour with concise docstrings and comments.
- For `204 No Content` responses, return an empty body only; never return a dict, model, or other payload.
- For Flask-RESTX controller request, query, and response schemas, follow `controllers/API_SCHEMA_GUIDE.md`.
In short: use Pydantic models, document GET query params with `query_params_from_model(...)`, register response
DTOs with `register_response_schema_models(...)`, serialize response DTOs with `dump_response(...)`,
and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns.
### Miscellaneous ### Miscellaneous

View File

@ -22,9 +22,12 @@ RUN apt-get update \
libmpfr-dev libmpc-dev libmpfr-dev libmpc-dev
# Install Python dependencies (workspace members under providers/vdb/) # Install Python dependencies (workspace members under providers/vdb/)
COPY pyproject.toml uv.lock ./ COPY api/pyproject.toml api/uv.lock ./
COPY providers ./providers COPY api/providers ./providers
RUN uv sync --locked --no-dev COPY dify-agent/pyproject.toml dify-agent/README.md /app/dify-agent/
COPY dify-agent/src /app/dify-agent/src
# Trust the checked-in lock during image builds; local path sources are copied from the repository context.
RUN uv sync --frozen --no-dev
# production stage # production stage
FROM base AS production FROM base AS production
@ -107,10 +110,10 @@ RUN python -c "import tiktoken; tiktoken.encoding_for_model('gpt2')" \
&& chown -R dify:dify ${TIKTOKEN_CACHE_DIR} && chown -R dify:dify ${TIKTOKEN_CACHE_DIR}
# Copy source code # Copy source code
COPY --chown=dify:dify . /app/api/ COPY --chown=dify:dify api /app/api/
# Prepare entrypoint script # Prepare entrypoint script
COPY --chown=dify:dify --chmod=755 docker/entrypoint.sh /entrypoint.sh COPY --chown=dify:dify --chmod=755 api/docker/entrypoint.sh /entrypoint.sh
ARG COMMIT_SHA ARG COMMIT_SHA

View File

@ -0,0 +1,25 @@
*
!api/
!api/**
!dify-agent/
!dify-agent/pyproject.toml
!dify-agent/README.md
!dify-agent/src/
!dify-agent/src/**
api/.venv
api/.venv/**
api/.env
api/*.env.*
api/.idea
api/.mypy_cache
api/.ruff_cache
api/storage/generate_files/*
api/storage/privkeys/*
api/storage/tools/*
api/storage/upload_files/*
api/logs
api/*.log*
**/__pycache__
**/*.pyc

View File

@ -99,7 +99,7 @@ The scripts resolve paths relative to their location, so you can run them from a
./dev/reformat # Run all formatters and linters ./dev/reformat # Run all formatters and linters
uv run ruff check --fix ./ # Fix linting issues uv run ruff check --fix ./ # Fix linting issues
uv run ruff format ./ # Format code uv run ruff format ./ # Format code
uv run basedpyright . # Type checking uv run pyrefly check # Type checking
``` ```
## Generate TS stub ## Generate TS stub

View File

@ -117,7 +117,7 @@ def create_flask_app_with_configs() -> DifyApp:
logger.warning("Failed to add trace headers to response", exc_info=True) logger.warning("Failed to add trace headers to response", exc_info=True)
return response return response
# Capture the decorator's return value to avoid pyright reportUnusedFunction # Capture the decorator return values so static checkers do not treat the hooks as unused.
_ = before_request _ = before_request
_ = add_trace_headers _ = add_trace_headers
@ -159,6 +159,7 @@ def initialize_extensions(app: DifyApp):
ext_logstore, ext_logstore,
ext_mail, ext_mail,
ext_migrate, ext_migrate,
ext_oauth_bearer,
ext_orjson, ext_orjson,
ext_otel, ext_otel,
ext_proxy_fix, ext_proxy_fix,
@ -181,7 +182,6 @@ def initialize_extensions(app: DifyApp):
ext_import_modules, ext_import_modules,
ext_orjson, ext_orjson,
ext_forward_refs, ext_forward_refs,
ext_set_secretkey,
ext_compress, ext_compress,
ext_code_based_extension, ext_code_based_extension,
ext_database, ext_database,
@ -189,6 +189,7 @@ def initialize_extensions(app: DifyApp):
ext_migrate, ext_migrate,
ext_redis, ext_redis,
ext_storage, ext_storage,
ext_set_secretkey,
ext_logstore, # Initialize logstore after storage, before celery ext_logstore, # Initialize logstore after storage, before celery
ext_celery, ext_celery,
ext_login, ext_login,
@ -203,6 +204,7 @@ def initialize_extensions(app: DifyApp):
ext_enterprise_telemetry, ext_enterprise_telemetry,
ext_request_logging, ext_request_logging,
ext_session_factory, ext_session_factory,
ext_oauth_bearer,
] ]
for ext in extensions: for ext in extensions:
short_name = ext.__name__.split(".")[-1] short_name = ext.__name__.split(".")[-1]

1
api/clients/__init__.py Normal file
View File

@ -0,0 +1 @@
"""External service client packages."""

View File

@ -0,0 +1,74 @@
"""API-side integration boundary for the Dify Agent backend.
Public wire DTOs come from ``dify_agent.protocol``. This package only contains
API adapters: request building from Dify product concepts, a thin client wrapper,
event adaptation for future workflow integration, and deterministic fakes.
"""
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.errors import (
AgentBackendError,
AgentBackendHTTPError,
AgentBackendRequestBuildError,
AgentBackendRunFailedError,
AgentBackendStreamError,
AgentBackendTransportError,
AgentBackendValidationError,
)
from clients.agent_backend.event_adapter import (
AgentBackendInternalEvent,
AgentBackendInternalEventType,
AgentBackendRunCancelledInternalEvent,
AgentBackendRunEventAdapter,
AgentBackendRunFailedInternalEvent,
AgentBackendRunPausedInternalEvent,
AgentBackendRunStartedInternalEvent,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent,
)
from clients.agent_backend.factory import create_agent_backend_run_client
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
from clients.agent_backend.request_builder import (
AGENT_SOUL_PROMPT_LAYER_ID,
DIFY_PLUGIN_CONTEXT_LAYER_ID,
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
WORKFLOW_USER_PROMPT_LAYER_ID,
AgentBackendModelConfig,
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log,
)
__all__ = [
"AGENT_SOUL_PROMPT_LAYER_ID",
"DIFY_PLUGIN_CONTEXT_LAYER_ID",
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
"WORKFLOW_USER_PROMPT_LAYER_ID",
"AgentBackendError",
"AgentBackendHTTPError",
"AgentBackendInternalEvent",
"AgentBackendInternalEventType",
"AgentBackendModelConfig",
"AgentBackendOutputConfig",
"AgentBackendRequestBuildError",
"AgentBackendRunCancelledInternalEvent",
"AgentBackendRunClient",
"AgentBackendRunEventAdapter",
"AgentBackendRunFailedError",
"AgentBackendRunFailedInternalEvent",
"AgentBackendRunPausedInternalEvent",
"AgentBackendRunRequestBuilder",
"AgentBackendRunStartedInternalEvent",
"AgentBackendRunSucceededInternalEvent",
"AgentBackendStreamError",
"AgentBackendStreamInternalEvent",
"AgentBackendTransportError",
"AgentBackendValidationError",
"AgentBackendWorkflowNodeRunInput",
"DifyAgentBackendRunClient",
"FakeAgentBackendRunClient",
"FakeAgentBackendScenario",
"create_agent_backend_run_client",
"redact_for_agent_backend_log",
]

View File

@ -0,0 +1,130 @@
"""Synchronous API-side wrapper around the public ``dify-agent`` client.
``dify-agent`` owns the cross-service DTOs and HTTP/SSE implementation. The API
backend keeps this thin wrapper so workflow code depends on a local protocol,
gets API-native errors, and can use a deterministic fake in tests without
creating another wire contract.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Protocol
from dify_agent.client import (
DifyAgentClientError,
DifyAgentHTTPError,
DifyAgentStreamError,
DifyAgentTimeoutError,
DifyAgentValidationError,
)
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunEvent,
RunStatusResponse,
)
from clients.agent_backend.errors import (
AgentBackendError,
AgentBackendHTTPError,
AgentBackendStreamError,
AgentBackendTransportError,
AgentBackendValidationError,
)
class AgentBackendRunClient(Protocol):
"""Local boundary used by API workflow integrations to run Agent backend jobs."""
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one Agent backend run and return its accepted status."""
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Request explicit cancellation for one Agent backend run."""
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Yield public ``dify-agent`` run events in stream order."""
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Wait for a run to reach a terminal status and return that status."""
class _DifyAgentSyncClient(Protocol):
"""Subset of ``dify_agent.client.Client`` used by the API wrapper."""
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one run synchronously."""
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run synchronously."""
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Stream run events synchronously."""
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Wait for terminal run status synchronously."""
class DifyAgentBackendRunClient:
"""Adapter from API sync call sites to ``dify_agent.client.Client`` sync methods."""
client: _DifyAgentSyncClient
def __init__(self, client: _DifyAgentSyncClient) -> None:
self.client = client
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one run through ``POST /runs`` and normalize client exceptions."""
try:
return self.client.create_run_sync(request)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run through ``POST /runs/{run_id}/cancel`` and normalize exceptions."""
try:
return self.client.cancel_run_sync(run_id, request=request)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Stream run events from ``/events/sse`` with the wrapped client's reconnect policy."""
try:
yield from self.client.stream_events_sync(run_id, after=after)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Poll run status until terminal state and normalize client exceptions."""
try:
return self.client.wait_run_sync(run_id, timeout_seconds=timeout_seconds)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def _normalize_dify_agent_error(exc: Exception) -> AgentBackendError:
"""Map public ``dify-agent`` client errors to API-side integration errors."""
match exc:
case DifyAgentValidationError() as error:
return AgentBackendValidationError(
"Agent backend request or response validation failed", detail=error.detail
)
case DifyAgentHTTPError() as error:
return AgentBackendHTTPError(
f"Agent backend HTTP {error.status_code}",
status_code=error.status_code,
detail=error.detail,
)
case DifyAgentTimeoutError() as error:
return AgentBackendTransportError(str(error))
case DifyAgentStreamError() as error:
return AgentBackendStreamError(str(error))
case DifyAgentClientError() as error:
return AgentBackendTransportError(str(error))
case AgentBackendError() as error:
return error
case _:
return AgentBackendTransportError(str(exc) or type(exc).__name__)

View File

@ -0,0 +1,61 @@
"""API-side errors for the Dify Agent backend integration.
The wire protocol and low-level HTTP behaviour are owned by ``dify-agent``.
This module only normalizes those client errors into the API backend's boundary
so workflow/node code does not depend directly on transport-specific exception
classes.
"""
from __future__ import annotations
from typing import Any
class AgentBackendError(Exception):
"""Base error for API-side Agent backend integration failures."""
class AgentBackendRequestBuildError(AgentBackendError):
"""Raised when Dify product/workflow state cannot be mapped to a run request."""
class AgentBackendTransportError(AgentBackendError):
"""Raised for timeout or request-level failures talking to Agent backend."""
class AgentBackendHTTPError(AgentBackendTransportError):
"""Raised for Agent backend HTTP errors after status/detail normalization."""
status_code: int
detail: object
def __init__(self, message: str, *, status_code: int, detail: object) -> None:
self.status_code = status_code
self.detail = detail
super().__init__(message)
class AgentBackendValidationError(AgentBackendError):
"""Raised for local request validation or Agent backend 422 responses."""
detail: object
def __init__(self, message: str, *, detail: object) -> None:
self.detail = detail
super().__init__(message)
class AgentBackendStreamError(AgentBackendError):
"""Raised when an Agent backend event stream is malformed or exhausted."""
class AgentBackendRunFailedError(AgentBackendError):
"""Raised by callers that choose to translate a terminal failed run into an exception."""
run_id: str
detail: Any
def __init__(self, run_id: str, detail: Any) -> None:
self.run_id = run_id
self.detail = detail
super().__init__(f"Agent backend run failed: {run_id}")

View File

@ -0,0 +1,167 @@
"""Adapt public ``dify-agent`` run events into API-internal event semantics.
The adapter does not define a new cross-service event contract. It consumes
``dify_agent.protocol.RunEvent`` and produces small API-internal models that the
future workflow Agent Node can map to Graphon/AppQueue events in phase 3.
"""
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Literal, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunEvent,
RunFailedEvent,
RunPausedEvent,
RunStartedEvent,
RunSucceededEvent,
)
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
_EVENT_DATA_ADAPTER = TypeAdapter(object)
class AgentBackendInternalEventType(StrEnum):
"""API-only event labels used before Graphon/AppQueue integration."""
RUN_STARTED = "run_started"
STREAM_EVENT = "stream_event"
RUN_PAUSED = "run_paused"
RUN_SUCCEEDED = "run_succeeded"
RUN_FAILED = "run_failed"
RUN_CANCELLED = "run_cancelled"
class AgentBackendInternalEventBase(BaseModel):
"""Common fields preserved from public Dify Agent run events."""
run_id: str
source_event_id: str | None = None
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class AgentBackendRunStartedInternalEvent(AgentBackendInternalEventBase):
"""API-internal marker for a started Agent backend run."""
type: Literal[AgentBackendInternalEventType.RUN_STARTED] = AgentBackendInternalEventType.RUN_STARTED
class AgentBackendStreamInternalEvent(AgentBackendInternalEventBase):
"""API-internal wrapper for one pydantic-ai stream event payload."""
type: Literal[AgentBackendInternalEventType.STREAM_EVENT] = AgentBackendInternalEventType.STREAM_EVENT
event_kind: str | None = None
data: JsonValue
class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal success event carrying final output and session state."""
type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED
output: JsonValue
session_snapshot: CompositorSessionSnapshot
class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase):
"""API-internal resumable pause event for human handoff and Babysit flows."""
type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED
reason: str
message: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal failure event carrying the backend-safe error text."""
type: Literal[AgentBackendInternalEventType.RUN_FAILED] = AgentBackendInternalEventType.RUN_FAILED
error: str
reason: str | None = None
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
"""API-internal terminal cancellation event."""
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
reason: str | None = None
message: str | None = None
type AgentBackendInternalEvent = Annotated[
AgentBackendRunStartedInternalEvent
| AgentBackendStreamInternalEvent
| AgentBackendRunPausedInternalEvent
| AgentBackendRunSucceededInternalEvent
| AgentBackendRunFailedInternalEvent
| AgentBackendRunCancelledInternalEvent,
Field(discriminator="type"),
]
class AgentBackendRunEventAdapter:
"""Maps public ``dify-agent`` event variants to API-internal event variants."""
def adapt(self, event: RunEvent) -> list[AgentBackendInternalEvent]:
"""Return zero or more API-internal events derived from one public run event."""
match event:
case RunStartedEvent():
return [
AgentBackendRunStartedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
)
]
case PydanticAIStreamRunEvent():
data = cast(JsonValue, _EVENT_DATA_ADAPTER.dump_python(event.data, mode="json"))
event_kind = data.get("event_kind") if isinstance(data, dict) else None
return [
AgentBackendStreamInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
event_kind=event_kind if isinstance(event_kind, str) else None,
data=data,
)
]
case RunSucceededEvent():
return [
AgentBackendRunSucceededInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
output=event.data.output,
session_snapshot=event.data.session_snapshot,
)
]
case RunPausedEvent():
return [
AgentBackendRunPausedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
session_snapshot=event.data.session_snapshot,
)
]
case RunFailedEvent():
return [
AgentBackendRunFailedInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
error=event.data.error,
reason=event.data.reason,
)
]
case RunCancelledEvent():
return [
AgentBackendRunCancelledInternalEvent(
run_id=event.run_id,
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
)
]
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")

View File

@ -0,0 +1,22 @@
"""Factories for API-side Agent backend clients."""
from __future__ import annotations
from dify_agent.client import Client
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
def create_agent_backend_run_client(
*,
base_url: str | None = None,
use_fake: bool = False,
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
) -> AgentBackendRunClient:
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
if use_fake:
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
if base_url is None:
raise ValueError("base_url is required when creating a real Agent backend client")
return DifyAgentBackendRunClient(Client(base_url=base_url))

View File

@ -0,0 +1,117 @@
"""Deterministic fake Agent backend client using public ``dify-agent`` events.
Tests should exercise the same ``RunEvent`` DTOs as the real HTTP client. This
fake therefore replaces the previous custom mock protocol instead of emulating a
separate ``agent-backend.v1`` event stream.
"""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
from enum import StrEnum
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunEvent,
RunFailedEvent,
RunFailedEventData,
RunStartedEvent,
RunStatusResponse,
RunSucceededEvent,
RunSucceededEventData,
)
_FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC)
class FakeAgentBackendScenario(StrEnum):
"""Deterministic fake scenarios for API-side integration tests."""
SUCCESS = "success"
FAILED = "failed"
class FakeAgentBackendRunClient:
"""In-memory implementation of ``AgentBackendRunClient`` for unit tests."""
scenario: FakeAgentBackendScenario
run_id: str
request: CreateRunRequest | None
def __init__(
self,
*,
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
run_id: str = "fake-run-1",
) -> None:
self.scenario = scenario
self.run_id = run_id
self.request = None
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Record the request and return a deterministic accepted response."""
self.request = request
return CreateRunResponse(run_id=self.run_id, status="running")
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Return a deterministic cancellation response."""
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
"""Yield the deterministic public ``RunEvent`` sequence for ``run_id``."""
for event in self._events(run_id):
if after is not None and event.id is not None and event.id <= after:
continue
yield event
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
"""Return a deterministic terminal status; timeout is accepted for protocol parity."""
del timeout_seconds
match self.scenario:
case FakeAgentBackendScenario.SUCCESS:
return RunStatusResponse(
run_id=run_id,
status="succeeded",
created_at=_FIXED_TIME,
updated_at=_FIXED_TIME,
)
case FakeAgentBackendScenario.FAILED:
return RunStatusResponse(
run_id=run_id,
status="failed",
created_at=_FIXED_TIME,
updated_at=_FIXED_TIME,
error="fake failure",
)
def _events(self, run_id: str) -> tuple[RunEvent, ...]:
match self.scenario:
case FakeAgentBackendScenario.SUCCESS:
return (
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
RunSucceededEvent(
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunSucceededEventData(
output={"text": "hello agent"},
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
),
)
case FakeAgentBackendScenario.FAILED:
return (
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
RunFailedEvent(
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunFailedEventData(error="fake failure", reason="unit_test"),
),
)

View File

@ -0,0 +1,194 @@
"""Build ``dify-agent`` run requests from API-side product concepts.
This module is intentionally an adapter, not a wire DTO package. The emitted
object is always ``dify_agent.protocol.CreateRunRequest`` so the Agent backend
protocol has a single owner. API-only context such as Agent Soul vs workflow job
prompt is preserved in layer names and metadata until the dedicated product
schemas land in later phases.
"""
from __future__ import annotations
from typing import ClassVar
from agenton.compositor import CompositorSessionSnapshot
from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LAYER_TYPE_ID,
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DifyPluginCredentialValue,
DifyPluginLayerConfig,
DifyPluginLLMLayerConfig,
)
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.protocol import (
DIFY_AGENT_MODEL_LAYER_ID,
DIFY_AGENT_OUTPUT_LAYER_ID,
CreateRunRequest,
ExecutionContext,
LayerExitSignals,
RunComposition,
RunLayerSpec,
RunPurpose,
)
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt"
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
DIFY_PLUGIN_CONTEXT_LAYER_ID = "plugin"
class AgentBackendModelConfig(BaseModel):
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
tenant_id: str
plugin_id: str
model_provider: str
model: str
user_id: str | None = None
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentBackendOutputConfig(BaseModel):
"""API-side structured output declaration for the conventional output layer."""
json_schema: dict[str, JsonValue]
name: str = "final_result"
description: str | None = None
strict: bool | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentBackendWorkflowNodeRunInput(BaseModel):
"""Inputs needed to build the first workflow-node-oriented Agent backend run request."""
model: AgentBackendModelConfig
execution_context: ExecutionContext
workflow_node_job_prompt: str
user_prompt: str
agent_soul_prompt: str | None = None
purpose: RunPurpose = "workflow_node"
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
suspend_on_exit: bool = False
metadata: dict[str, JsonValue] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
@field_validator("workflow_node_job_prompt", "user_prompt")
@classmethod
def _reject_blank_prompt(cls, value: str) -> str:
if not value.strip():
raise ValueError("prompt must not be blank")
return value
class AgentBackendRunRequestBuilder:
"""Converts API product state into the public ``dify-agent`` run protocol."""
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
"""Build a workflow Agent Node run request without defining another wire schema."""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
layers.append(
RunLayerSpec(
name=AGENT_SOUL_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "agent_soul"},
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
)
)
layers.extend(
[
RunLayerSpec(
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "workflow_node_job"},
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
),
RunLayerSpec(
name=WORKFLOW_USER_PROMPT_LAYER_ID,
type=PLAIN_PROMPT_LAYER_TYPE_ID,
metadata={**run_input.metadata, "origin": "workflow_user_prompt"},
config=PromptLayerConfig(user=run_input.user_prompt),
),
RunLayerSpec(
name=DIFY_PLUGIN_CONTEXT_LAYER_ID,
type=DIFY_PLUGIN_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=DifyPluginLayerConfig(
tenant_id=run_input.model.tenant_id,
plugin_id=run_input.model.plugin_id,
user_id=run_input.model.user_id,
),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": DIFY_PLUGIN_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=DifyPluginLLMLayerConfig(
model_provider=run_input.model.model_provider,
model=run_input.model.model,
credentials=run_input.model.credentials,
model_settings=run_input.model.model_settings or None,
),
),
]
)
if run_input.output is not None:
layers.append(
RunLayerSpec(
name=DIFY_AGENT_OUTPUT_LAYER_ID,
type=DIFY_OUTPUT_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=DifyOutputLayerConfig(
json_schema=run_input.output.json_schema,
name=run_input.output.name,
description=run_input.output.description,
strict=run_input.output.strict,
),
)
)
return CreateRunRequest(
composition=RunComposition(layers=layers),
execution_context=run_input.execution_context,
purpose=run_input.purpose,
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
)
_SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key")
def redact_for_agent_backend_log(value: object) -> object:
"""Return a JSON-like copy with credential-bearing keys redacted for logs/tests."""
if isinstance(value, BaseModel):
return redact_for_agent_backend_log(value.model_dump(mode="json", warnings=False))
if isinstance(value, dict):
redacted: dict[object, object] = {}
for key, item in value.items():
key_text = str(key).lower()
if any(part in key_text for part in _SENSITIVE_KEY_PARTS):
redacted[key] = "[REDACTED]"
else:
redacted[key] = redact_for_agent_backend_log(item)
return redacted
if isinstance(value, list):
return [redact_for_agent_backend_log(item) for item in value]
return value

View File

@ -185,9 +185,9 @@ def transform_datasource_credentials(environment: str):
firecrawl_plugin_id = "langgenius/firecrawl_datasource" firecrawl_plugin_id = "langgenius/firecrawl_datasource"
jina_plugin_id = "langgenius/jina_datasource" jina_plugin_id = "langgenius/jina_datasource"
if environment == "online": if environment == "online":
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id) # pyright: ignore[reportPrivateUsage] notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id)
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id) # pyright: ignore[reportPrivateUsage] firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id)
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id) # pyright: ignore[reportPrivateUsage] jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id)
else: else:
notion_plugin_unique_identifier = None notion_plugin_unique_identifier = None
firecrawl_plugin_unique_identifier = None firecrawl_plugin_unique_identifier = None

View File

@ -14,6 +14,7 @@ from libs.rsa import generate_key_pair
from models import Tenant from models import Tenant
from models.model import App, AppMode, Conversation from models.model import App, AppMode, Conversation
from models.provider import Provider, ProviderModel from models.provider import Provider, ProviderModel
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,13 +24,16 @@ DB_UPGRADE_LOCK_TTL_SECONDS = 60
@click.command( @click.command(
"reset-encrypt-key-pair", "reset-encrypt-key-pair",
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. " help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
"After the reset, all LLM credentials will become invalid, " "After the reset, all LLM credentials and tool provider credentials "
"requiring re-entry." "(builtin / API / MCP) will be purged, requiring re-entry. "
"Only support SELF_HOSTED mode.", "Only support SELF_HOSTED mode.",
) )
@click.confirmation_option( @click.confirmation_option(
prompt=click.style( prompt=click.style(
"Are you sure you want to reset encrypt key pair? This operation cannot be rolled back!", fg="red" "Are you sure you want to reset encrypt key pair? "
"This will also purge builtin / API / MCP tool provider records for every tenant. "
"This operation cannot be rolled back!",
fg="red",
) )
) )
def reset_encrypt_key_pair(): def reset_encrypt_key_pair():
@ -53,6 +57,13 @@ def reset_encrypt_key_pair():
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id)) session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id)) session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
# Purge tool provider records that hold credentials encrypted under the
# tenant key. Leaving them in place causes /console/api/workspaces/current/
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
click.echo( click.echo(
click.style( click.style(
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.", f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",

View File

@ -1,6 +1,6 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, override
from pydantic.fields import FieldInfo from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
@ -25,6 +25,7 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource):
def __init__(self, settings_cls: type[BaseSettings]): def __init__(self, settings_cls: type[BaseSettings]):
super().__init__(settings_cls) super().__init__(settings_cls)
@override
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
raise NotImplementedError raise NotImplementedError
@ -90,6 +91,7 @@ class DifyConfig(
# Thanks for your concentration and consideration. # Thanks for your concentration and consideration.
@classmethod @classmethod
@override
def settings_customise_sources( def settings_customise_sources(
cls, cls,
settings_cls: type[BaseSettings], settings_cls: type[BaseSettings],

View File

@ -23,6 +23,12 @@ class EnterpriseFeatureConfig(BaseSettings):
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5 ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
) )
ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK: bool = Field(
default=False,
description="If disabled, credential policy check is only performed when saving workflows."
"This helps gain runtime performance by trading off consistency.",
)
class EnterpriseTelemetryConfig(BaseSettings): class EnterpriseTelemetryConfig(BaseSettings):
""" """

View File

@ -1,3 +1,4 @@
from configs.extra.agent_backend_config import AgentBackendConfig
from configs.extra.archive_config import ArchiveStorageConfig from configs.extra.archive_config import ArchiveStorageConfig
from configs.extra.notion_config import NotionConfig from configs.extra.notion_config import NotionConfig
from configs.extra.sentry_config import SentryConfig from configs.extra.sentry_config import SentryConfig
@ -5,6 +6,7 @@ from configs.extra.sentry_config import SentryConfig
class ExtraServiceConfig( class ExtraServiceConfig(
# place the configs in alphabet order # place the configs in alphabet order
AgentBackendConfig,
ArchiveStorageConfig, ArchiveStorageConfig,
NotionConfig, NotionConfig,
SentryConfig, SentryConfig,

View File

@ -0,0 +1,23 @@
from pydantic import Field
from pydantic_settings import BaseSettings
class AgentBackendConfig(BaseSettings):
"""
Configuration settings for the Agent backend runtime integration.
"""
AGENT_BACKEND_BASE_URL: str | None = Field(
description="Base URL for the Dify Agent backend service.",
default=None,
)
AGENT_BACKEND_USE_FAKE: bool = Field(
description="Use the deterministic in-process fake Agent backend client.",
default=False,
)
AGENT_BACKEND_FAKE_SCENARIO: str = Field(
description="Scenario used by the fake Agent backend client.",
default="success",
)

View File

@ -23,9 +23,9 @@ class SecurityConfig(BaseSettings):
""" """
SECRET_KEY: str = Field( SECRET_KEY: str = Field(
description="Secret key for secure session cookie signing." description="Secret key for secure session cookie signing. "
"Make sure you are changing this key for your deployment with a strong key." "Leave empty to let Dify generate a persistent key in the storage directory, "
"Generate a strong key using `openssl rand -base64 42` or set via the `SECRET_KEY` environment variable.", "or set a strong value via the `SECRET_KEY` environment variable.",
default="", default="",
) )
@ -520,6 +520,44 @@ class HttpConfig(BaseSettings):
def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]: def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]:
return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",") return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",")
OPENAPI_ENABLED: bool = Field(
description=(
"Enable the /openapi/v1/* endpoint group used by difyctl and other "
"programmatic clients. Set to true to activate; disabled by default."
),
validation_alias=AliasChoices("OPENAPI_ENABLED"),
default=False,
)
inner_OPENAPI_CORS_ALLOW_ORIGINS: str = Field(
description=(
"Comma-separated allowlist for /openapi/v1/* CORS. "
"Default empty = same-origin only. Browser-cookie routes within "
"the group reject cross-origin OPTIONS regardless of this list."
),
validation_alias=AliasChoices("OPENAPI_CORS_ALLOW_ORIGINS"),
default="",
)
@computed_field
def OPENAPI_CORS_ALLOW_ORIGINS(self) -> list[str]:
return [o for o in self.inner_OPENAPI_CORS_ALLOW_ORIGINS.split(",") if o]
inner_OPENAPI_KNOWN_CLIENT_IDS: str = Field(
description=(
"Comma-separated client_id values accepted at "
"POST /openapi/v1/oauth/device/code. New CLIs / SDKs added here "
"without code changes. Unknown client_id returns 400 unsupported_client."
),
validation_alias=AliasChoices("OPENAPI_KNOWN_CLIENT_IDS"),
default="difyctl",
)
@computed_field # type: ignore[misc]
@property
def OPENAPI_KNOWN_CLIENT_IDS(self) -> frozenset[str]:
return frozenset(c for c in self.inner_OPENAPI_KNOWN_CLIENT_IDS.split(",") if c)
HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field( HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field(
ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10 ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10
) )
@ -761,7 +799,7 @@ class WorkflowConfig(BaseSettings):
# GraphEngine Worker Pool Configuration # GraphEngine Worker Pool Configuration
GRAPH_ENGINE_MIN_WORKERS: PositiveInt = Field( GRAPH_ENGINE_MIN_WORKERS: PositiveInt = Field(
description="Minimum number of workers per GraphEngine instance", description="Minimum number of workers per GraphEngine instance",
default=1, default=3,
) )
GRAPH_ENGINE_MAX_WORKERS: PositiveInt = Field( GRAPH_ENGINE_MAX_WORKERS: PositiveInt = Field(
@ -895,6 +933,17 @@ class AuthConfig(BaseSettings):
default=86400, default=86400,
) )
ENABLE_OAUTH_BEARER: bool = Field(
description="Enable OAuth bearer authentication (device-flow + Service API /v1/* bearer middleware).",
default=True,
)
OPENAPI_RATE_LIMIT_PER_TOKEN: PositiveInt = Field(
description="Per-token rate limit on /openapi/v1/* (requests per minute). "
"Bucket keyed on sha256(token), shared across api replicas via Redis.",
default=60,
)
class ModerationConfig(BaseSettings): class ModerationConfig(BaseSettings):
""" """
@ -1137,6 +1186,18 @@ class MultiModalTransferConfig(BaseSettings):
) )
class OpsTraceConfig(BaseSettings):
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
default=60,
)
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field(
description="Delay in seconds between transient ops trace provider dispatch retry attempts.",
default=5,
)
class CeleryBeatConfig(BaseSettings): class CeleryBeatConfig(BaseSettings):
CELERY_BEAT_SCHEDULER_TIME: int = Field( CELERY_BEAT_SCHEDULER_TIME: int = Field(
description="Interval in days for Celery Beat scheduler execution, default to 1 day", description="Interval in days for Celery Beat scheduler execution, default to 1 day",
@ -1169,6 +1230,14 @@ class CeleryScheduleTasksConfig(BaseSettings):
description="Enable scheduled workflow run cleanup task", description="Enable scheduled workflow run cleanup task",
default=False, default=False,
) )
ENABLE_CLEAN_OAUTH_ACCESS_TOKENS_TASK: bool = Field(
description="Enable scheduled cleanup of revoked/expired OAuth access-token rows past retention.",
default=True,
)
OAUTH_ACCESS_TOKEN_RETENTION_DAYS: PositiveInt = Field(
description="Days to retain revoked OAuth access-token rows before deletion.",
default=30,
)
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: bool = Field( ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: bool = Field(
description="Enable mail clean document notify task", description="Enable mail clean document notify task",
default=False, default=False,
@ -1298,7 +1367,7 @@ class PositionConfig(BaseSettings):
class CollaborationConfig(BaseSettings): class CollaborationConfig(BaseSettings):
ENABLE_COLLABORATION_MODE: bool = Field( ENABLE_COLLABORATION_MODE: bool = Field(
description="Whether to enable collaboration mode features across the workspace", description="Whether to enable collaboration mode features across the workspace",
default=False, default=True,
) )
@ -1417,6 +1486,7 @@ class FeatureConfig(
ModelLoadBalanceConfig, ModelLoadBalanceConfig,
ModerationConfig, ModerationConfig,
MultiModalTransferConfig, MultiModalTransferConfig,
OpsTraceConfig,
PositionConfig, PositionConfig,
RagEtlConfig, RagEtlConfig,
RepositoryConfig, RepositoryConfig,

View File

@ -1,5 +1,5 @@
import os import os
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict, cast
from urllib.parse import parse_qsl, quote_plus from urllib.parse import parse_qsl, quote_plus
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
@ -50,28 +50,30 @@ from .vdb.vastbase_vector_config import VastbaseVectorConfig
from .vdb.vikingdb_config import VikingDBConfig from .vdb.vikingdb_config import VikingDBConfig
from .vdb.weaviate_config import WeaviateConfig from .vdb.weaviate_config import WeaviateConfig
_VALID_STORAGE_TYPE = Literal[
"opendal",
"s3",
"aliyun-oss",
"azure-blob",
"baidu-obs",
"clickzetta-volume",
"google-storage",
"huawei-obs",
"oci-storage",
"tencent-cos",
"volcengine-tos",
"supabase",
"local",
]
class StorageConfig(BaseSettings): class StorageConfig(BaseSettings):
STORAGE_TYPE: Literal[ STORAGE_TYPE: _VALID_STORAGE_TYPE = Field(
"opendal",
"s3",
"aliyun-oss",
"azure-blob",
"baidu-obs",
"clickzetta-volume",
"google-storage",
"huawei-obs",
"oci-storage",
"tencent-cos",
"volcengine-tos",
"supabase",
"local",
] = Field(
description="Type of storage to use." description="Type of storage to use."
" Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', " " Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', "
"'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', " "'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', "
"'volcengine-tos', 'supabase'. Default is 'opendal'.", "'volcengine-tos', 'supabase'. Default is 'opendal'.",
default="opendal", default=cast(_VALID_STORAGE_TYPE, "opendal"),
) )
STORAGE_LOCAL_PATH: str = Field( STORAGE_LOCAL_PATH: str = Field(
@ -114,7 +116,7 @@ class SQLAlchemyEngineOptionsDict(TypedDict):
pool_pre_ping: bool pool_pre_ping: bool
connect_args: dict[str, str] connect_args: dict[str, str]
pool_use_lifo: bool pool_use_lifo: bool
pool_reset_on_return: None pool_reset_on_return: Literal["commit", "rollback", None]
pool_timeout: int pool_timeout: int
@ -223,6 +225,11 @@ class DatabaseConfig(BaseSettings):
default=30, default=30,
) )
SQLALCHEMY_POOL_RESET_ON_RETURN: Literal["commit", "rollback", None] = Field(
description="Connection pool reset behavior on return. Options: 'commit', 'rollback', or None",
default="rollback",
)
RETRIEVAL_SERVICE_EXECUTORS: NonNegativeInt = Field( RETRIEVAL_SERVICE_EXECUTORS: NonNegativeInt = Field(
description="Number of processes for the retrieval service, default to CPU cores.", description="Number of processes for the retrieval service, default to CPU cores.",
default=os.cpu_count() or 1, default=os.cpu_count() or 1,
@ -252,7 +259,7 @@ class DatabaseConfig(BaseSettings):
"pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING, "pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING,
"connect_args": connect_args, "connect_args": connect_args,
"pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO, "pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO,
"pool_reset_on_return": None, "pool_reset_on_return": self.SQLALCHEMY_POOL_RESET_ON_RETURN,
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT, "pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
} }
return result return result

View File

@ -2,6 +2,7 @@ from typing import Literal, Protocol, cast
from urllib.parse import quote_plus, urlunparse from urllib.parse import quote_plus, urlunparse
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from pydantic.types import NonNegativeInt
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@ -70,6 +71,24 @@ class RedisPubSubConfig(BaseSettings):
default=600, default=600,
) )
PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field(
validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"),
description=(
"Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to "
"finish before returning. Bounds the tail latency between a terminal event being delivered to "
"an SSE client and the response stream actually closing.\n\n"
"The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout "
"for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for "
"the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() "
"return promptly while the daemon listener thread cleans itself up on the next poll "
"boundary - safe because the listener holds no critical state and exits within one poll "
"window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up "
"and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n"
"Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS."
),
default=2000,
)
def _build_default_pubsub_url(self) -> str: def _build_default_pubsub_url(self) -> str:
defaults = _redis_defaults(self) defaults = _redis_defaults(self)
if not defaults.REDIS_HOST or not defaults.REDIS_PORT: if not defaults.REDIS_HOST or not defaults.REDIS_PORT:

38
api/configs/secret_key.py Normal file
View File

@ -0,0 +1,38 @@
"""SECRET_KEY persistence helpers for runtime setup."""
from __future__ import annotations
import secrets
from extensions.ext_storage import storage
GENERATED_SECRET_KEY_FILENAME = ".dify_secret_key"
def resolve_secret_key(secret_key: str) -> str:
"""Return an explicit SECRET_KEY or a generated key persisted in storage."""
if secret_key:
return secret_key
return _load_or_create_secret_key()
def _load_or_create_secret_key() -> str:
try:
persisted_key = storage.load_once(GENERATED_SECRET_KEY_FILENAME).decode("utf-8").strip()
if persisted_key:
return persisted_key
except FileNotFoundError:
pass
generated_key = secrets.token_urlsafe(48)
try:
storage.save(GENERATED_SECRET_KEY_FILENAME, f"{generated_key}\n".encode())
except Exception as exc:
raise ValueError(
f"SECRET_KEY is not set and could not be generated at {GENERATED_SECRET_KEY_FILENAME}. "
"Set SECRET_KEY explicitly or make storage writable."
) from exc
return generated_key

91
api/conftest.py Normal file
View File

@ -0,0 +1,91 @@
"""Global pytest hooks for Dify backend tests.
This root conftest is loaded before package-specific conftests, which lets tests opt
into Docker-backed middleware before application modules read environment config.
It intentionally lives at the API root because pytest applies conftest.py files to
tests below their directory, and this setup is shared by api/tests and api/providers.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from tests.pytest_dify import (
DEFAULT_MIDDLEWARE_SERVICES,
DEFAULT_VDB_SERVICES,
DockerComposeStack,
build_middleware_stack,
build_vdb_stack,
ensure_backend_test_environment,
ensure_compose_env_files,
parse_services,
)
_REPO_ROOT = Path(__file__).resolve().parent.parent
_DIFY_COMPOSE_STACKS_KEY = pytest.StashKey[list[DockerComposeStack]]()
# This must run at import time because package-specific conftests can import the
# Flask app before pytest_configure hooks from this file are called.
ensure_backend_test_environment(_REPO_ROOT)
def pytest_addoption(parser: pytest.Parser) -> None:
group = parser.getgroup("dify")
group.addoption(
"--start-middleware",
action="store_true",
default=False,
help="Start the Docker middleware services needed by API integration tests.",
)
group.addoption(
"--middleware-services",
default=",".join(DEFAULT_MIDDLEWARE_SERVICES),
help="Comma-separated services from docker/docker-compose.middleware.yaml to start.",
)
group.addoption(
"--start-vdb",
action="store_true",
default=False,
help="Start vector-store Docker services for VDB integration tests.",
)
group.addoption(
"--vdb-services",
default=",".join(DEFAULT_VDB_SERVICES),
help="Comma-separated services from docker/docker-compose.yaml to start for VDB tests.",
)
def pytest_configure(config: pytest.Config) -> None:
config.stash[_DIFY_COMPOSE_STACKS_KEY] = []
def pytest_sessionstart(session: pytest.Session) -> None:
config = session.config
if hasattr(config, "workerinput"):
return
stacks: list[DockerComposeStack] = []
if config.getoption("start_middleware"):
ensure_compose_env_files(_REPO_ROOT)
stack = build_middleware_stack(_REPO_ROOT, parse_services(config.getoption("middleware_services")))
stack.up()
stacks.append(stack)
if config.getoption("start_vdb"):
ensure_compose_env_files(_REPO_ROOT)
stack = build_vdb_stack(_REPO_ROOT, parse_services(config.getoption("vdb_services")))
stack.up()
stacks.append(stack)
config.stash[_DIFY_COMPOSE_STACKS_KEY] = stacks
def pytest_unconfigure(config: pytest.Config) -> None:
if hasattr(config, "workerinput"):
return
stacks = config.stash.get(_DIFY_COMPOSE_STACKS_KEY, [])
for stack in reversed(stacks):
stack.down()

View File

@ -0,0 +1,211 @@
# API Schema Guide
This guide describes the expected Flask-RESTX + Pydantic pattern for controller request payloads, query
parameters, response schemas, and Swagger documentation.
## Principles
- Use Pydantic `BaseModel` for request bodies and query parameters.
- Use `fields.base.ResponseModel` for response DTOs.
- Keep runtime validation and Swagger documentation wired to the same Pydantic model.
- Prefer explicit validation and serialization in controller methods over Flask-RESTX marshalling.
- Do not add new Flask-RESTX `fields.*` dictionaries, `Namespace.model(...)` exports, or `@marshal_with(...)` for migrated or new endpoints.
- Do not use `@ns.expect(...)` for GET query parameters. Flask-RESTX documents that as a request body.
## Naming
- Request body models: use a `Payload` suffix.
- Example: `WorkflowRunPayload`, `DatasourceVariablesPayload`.
- Query parameter models: use a `Query` suffix.
- Example: `WorkflowRunListQuery`, `MessageListQuery`.
- Response models: use a `Response` suffix and inherit from `ResponseModel`.
- Example: `WorkflowRunDetailResponse`, `WorkflowRunNodeExecutionListResponse`.
- Use `ListResponse` or `PaginationResponse` for wrapper responses.
- Example: `WorkflowRunNodeExecutionListResponse`, `WorkflowRunPaginationResponse`.
- Keep these models near the controller when they are endpoint-specific. Move them to `fields/*_fields.py` only when shared by multiple controllers.
## Registering Models For Swagger
Use helpers from `controllers.common.schema`.
```python
from controllers.common.schema import (
query_params_from_model,
register_response_schema_models,
register_schema_models,
)
from libs.helper import dump_response
```
Register request payload and query models with `register_schema_models(...)`:
```python
register_schema_models(
console_ns,
WorkflowRunPayload,
WorkflowRunListQuery,
)
```
Register response models with `register_response_schema_models(...)`:
```python
register_response_schema_models(
console_ns,
WorkflowRunDetailResponse,
WorkflowRunPaginationResponse,
)
```
Response models are registered in Pydantic serialization mode. This matters when a response model uses
`validation_alias` to read internal object attributes but emits public API field names. For example, a response model
can validate from `inputs_dict` while documenting and serializing `inputs`.
## Request Bodies
For non-GET request bodies:
1. Define a Pydantic `Payload` model.
2. Register it with `register_schema_models(...)`.
3. Use `@ns.expect(ns.models[Payload.__name__])` for Swagger documentation.
4. Validate from `ns.payload or {}` inside the controller.
```python
class DraftWorkflowNodeRunPayload(BaseModel):
inputs: dict[str, Any]
query: str = ""
register_schema_models(console_ns, DraftWorkflowNodeRunPayload)
@console_ns.expect(console_ns.models[DraftWorkflowNodeRunPayload.__name__])
def post(self, app_model: App, node_id: str):
payload = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {})
result = service.run(..., inputs=payload.inputs, query=payload.query)
return dump_response(WorkflowRunNodeExecutionResponse, result)
```
## Query Parameters
For GET query parameters:
1. Define a Pydantic `Query` model.
2. Register it with `register_schema_models(...)` if it is referenced elsewhere in docs, or only use
`query_params_from_model(...)` if a body schema is not needed.
3. Use `@ns.doc(params=query_params_from_model(QueryModel))`.
4. Validate from `request.args.to_dict(flat=True)` or an explicit dict when type coercion is needed.
```python
class WorkflowRunListQuery(BaseModel):
last_id: str | None = Field(default=None, description="Last run ID for pagination")
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page (1-100)")
@console_ns.doc(params=query_params_from_model(WorkflowRunListQuery))
def get(self, app_model: App):
query = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True))
result = service.list(..., limit=query.limit, last_id=query.last_id)
return dump_response(WorkflowRunPaginationResponse, result)
```
Do not do this for GET query parameters:
```python
@console_ns.expect(console_ns.models[WorkflowRunListQuery.__name__])
def get(...):
...
```
That documents a GET request body and is not the expected contract.
## Responses
Response models should inherit from `ResponseModel`:
```python
class WorkflowRunNodeExecutionResponse(ResponseModel):
id: str
inputs: Any = Field(default=None, validation_alias="inputs_dict")
process_data: Any = Field(default=None, validation_alias="process_data_dict")
outputs: Any = Field(default=None, validation_alias="outputs_dict")
```
Document response models with `@ns.response(...)`:
```python
@console_ns.response(
200,
"Node run started successfully",
console_ns.models[WorkflowRunNodeExecutionResponse.__name__],
)
def post(...):
...
```
Serialize explicitly:
```python
return dump_response(WorkflowRunNodeExecutionResponse, workflow_node_execution)
```
`dump_response(...)` is the preferred response serialization helper for a single Pydantic response DTO. It validates
with `from_attributes=True` and returns `model_dump(mode="json")`, so SQLAlchemy models, plain objects, dictionaries,
Pydantic aliases, computed fields, and `datetime` values are serialized consistently.
For wrapper responses, pass a dictionary with the public wrapper fields:
```python
return dump_response(
WorkflowRunPaginationResponse,
{
"data": workflow_runs,
"page": page,
"limit": limit,
"has_more": has_more,
},
)
```
If the service can return `None`, translate that into the expected HTTP error before validation:
```python
workflow_run = service.get_workflow_run(...)
if workflow_run is None:
raise NotFound("Workflow run not found")
return dump_response(WorkflowRunDetailResponse, workflow_run)
```
Use manual `model_validate(...).model_dump(...)` only when the endpoint needs behavior that `dump_response(...)` does
not provide, such as returning a non-dict payload, intentionally excluding fields, or composing a `(body, status)` tuple.
## Legacy Flask-RESTX Patterns
Avoid adding these patterns to new or migrated endpoints:
- `ns.model(...)` for new request/response DTOs.
- Module-level exported RESTX model objects such as `workflow_run_detail_model`.
- `fields.Nested({...})` with raw inline dict field maps.
- `@marshal_with(...)` for response serialization.
- `@ns.expect(...)` for GET query params.
Existing legacy field dictionaries may remain where an endpoint has not yet been migrated. Keep that compatibility local
to the legacy area and avoid importing RESTX model objects from controllers.
## Verifying Swagger
For schema and documentation changes, run focused tests and generate Swagger JSON:
```bash
uv run --project . pytest tests/unit_tests/controllers/common/test_schema.py
uv run --project . pytest tests/unit_tests/commands/test_generate_swagger_specs.py tests/unit_tests/controllers/test_swagger.py
uv run --project . dev/generate_swagger_specs.py --output-dir /tmp/dify-openapi-check
```
Inspect affected endpoints with `jq`. Check that:
- GET parameters are `in: query`.
- Request bodies appear only where the endpoint has a body.
- Responses reference the expected `*Response` schema.
- Response schemas use public serialized names, not internal validation aliases like `inputs_dict`.

View File

@ -2,8 +2,9 @@ from __future__ import annotations
from typing import Any from typing import Any
from pydantic import BaseModel, ConfigDict, computed_field from pydantic import BaseModel, ConfigDict, Field, computed_field
from fields.base import ResponseModel
from graphon.file import helpers as file_helpers from graphon.file import helpers as file_helpers
from models.model import IconType from models.model import IconType
@ -19,6 +20,113 @@ class SystemParameters(BaseModel):
workflow_file_upload_limit: int workflow_file_upload_limit: int
class SimpleResultResponse(ResponseModel):
result: str
class SimpleResultMessageResponse(ResponseModel):
result: str
message: str
class SimpleMessageResponse(ResponseModel):
message: str
class SimpleDataResponse(ResponseModel):
data: str
class SimpleResultDataResponse(ResponseModel):
result: str
data: str
class SimpleResultStringListResponse(ResponseModel):
result: str
data: list[str]
class SimpleResultOptionalDataResponse(ResponseModel):
result: str
data: str | None = None
class AccessTokenData(ResponseModel):
access_token: str
class AccessTokenResultResponse(ResponseModel):
result: str
data: AccessTokenData
class VerificationTokenResponse(ResponseModel):
is_valid: bool
email: str
token: str
class LoginStatusResponse(ResponseModel):
logged_in: bool
app_logged_in: bool
class AccessModeResponse(ResponseModel):
access_mode: str = Field(serialization_alias="accessMode", validation_alias="accessMode")
class BooleanResultResponse(ResponseModel):
result: bool
class SuccessResponse(ResponseModel):
success: bool
class UsageCheckResponse(ResponseModel):
is_using: bool
class UsageCountResponse(ResponseModel):
is_using: bool
count: int
class IndexInfoResponse(ResponseModel):
welcome: str
api_version: str
server_version: str
class AvatarUrlResponse(ResponseModel):
avatar_url: str
class TextContentResponse(ResponseModel):
content: str
class AllowedExtensionsResponse(ResponseModel):
allowed_extensions: list[str]
class UrlResponse(ResponseModel):
url: str
class RedirectUrlResponse(ResponseModel):
redirect_url: str
class ApiBaseUrlResponse(ResponseModel):
api_base_url: str
class NewAppResponse(ResponseModel):
new_app_id: str
class Parameters(BaseModel): class Parameters(BaseModel):
opening_statement: str | None = None opening_statement: str | None = None
suggested_questions: list[str] suggested_questions: list[str]

View File

@ -36,6 +36,24 @@ class FileInfo(BaseModel):
size: int size: int
def decode_remote_url(url: str, query_string: bytes | str = b"") -> str:
decoded_url = urllib.parse.unquote(url)
if isinstance(query_string, bytes):
raw_query = query_string.decode()
else:
raw_query = query_string
if not raw_query:
return decoded_url
if decoded_url.endswith(("?", "&")):
separator = ""
elif urllib.parse.urlsplit(decoded_url).query:
separator = "&"
else:
separator = "?"
return f"{decoded_url}{separator}{raw_query}"
def guess_file_info_from_response(response: httpx.Response): def guess_file_info_from_response(response: httpx.Response):
url = str(response.url) url = str(response.url)
# Try to extract filename from URL # Try to extract filename from URL

View File

@ -1,6 +1,21 @@
import json
from pydantic import BaseModel, JsonValue from pydantic import BaseModel, JsonValue
class HumanInputFormSubmitPayload(BaseModel): class HumanInputFormSubmitPayload(BaseModel):
inputs: dict[str, JsonValue] inputs: dict[str, JsonValue]
action: str action: str
def stringify_form_default_values(values: dict[str, object]) -> dict[str, str]:
"""Serialize default values into strings expected by human-input form clients."""
result: dict[str, str] = {}
for key, value in values.items():
if value is None:
result[key] = ""
elif isinstance(value, (dict, list)):
result[key] = json.dumps(value, ensure_ascii=False)
else:
result[key] = str(value)
return result

View File

@ -1,6 +1,14 @@
"""Helpers for registering Pydantic models with Flask-RESTX namespaces.""" """Helpers for registering Pydantic models with Flask-RESTX namespaces.
Flask-RESTX treats `SchemaModel` bodies as opaque JSON schemas; it does not
promote Pydantic's nested `$defs` into top-level Swagger `definitions`.
These helpers keep that translation centralized so models registered through
`register_schema_models` emit resolvable Swagger 2.0 references.
"""
from collections.abc import Mapping
from enum import StrEnum from enum import StrEnum
from typing import Any, Literal, NotRequired, TypedDict
from flask_restx import Namespace from flask_restx import Namespace
from pydantic import BaseModel, TypeAdapter from pydantic import BaseModel, TypeAdapter
@ -8,10 +16,89 @@ from pydantic import BaseModel, TypeAdapter
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: QueryParamDoc = TypedDict(
"""Register a single BaseModel with a namespace for Swagger documentation.""" "QueryParamDoc",
{
"in": NotRequired[str],
"type": NotRequired[str],
"items": NotRequired[dict[str, object]],
"required": NotRequired[bool],
"description": NotRequired[str],
"enum": NotRequired[list[object]],
"default": NotRequired[object],
"minimum": NotRequired[int | float],
"maximum": NotRequired[int | float],
"minLength": NotRequired[int],
"maxLength": NotRequired[int],
"minItems": NotRequired[int],
"maxItems": NotRequired[int],
},
)
namespace.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None:
"""Register a JSON schema and promote any nested Pydantic `$defs`."""
schema = _swagger_2_compatible_schema(schema)
nested_definitions = schema.get("$defs")
schema_to_register = dict(schema)
if isinstance(nested_definitions, dict):
schema_to_register.pop("$defs")
namespace.schema_model(name, schema_to_register)
if not isinstance(nested_definitions, dict):
return
for nested_name, nested_schema in nested_definitions.items():
if isinstance(nested_schema, dict):
_register_json_schema(namespace, nested_name, nested_schema)
JsonSchemaMode = Literal["validation", "serialization"]
def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode: JsonSchemaMode) -> None:
_register_json_schema(
namespace,
model.__name__,
model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0, mode=mode),
)
def _swagger_2_compatible_schema(value: Any) -> Any:
if isinstance(value, list):
return [_swagger_2_compatible_schema(item) for item in value]
if not isinstance(value, dict):
return value
converted = {key: _swagger_2_compatible_schema(child) for key, child in value.items()}
any_of = value.get("anyOf")
if not isinstance(any_of, list):
return converted
non_null_candidates = [
candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null"
]
has_null_candidate = any(isinstance(candidate, Mapping) and candidate.get("type") == "null" for candidate in any_of)
if not has_null_candidate or len(non_null_candidates) != 1:
return converted
non_null_schema = _swagger_2_compatible_schema(dict(non_null_candidates[0]))
if not isinstance(non_null_schema, dict):
return converted
converted.pop("anyOf", None)
converted.update(non_null_schema)
converted["x-nullable"] = True
return converted
def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
"""Register a BaseModel and its nested schema definitions for Swagger documentation."""
_register_schema_model(namespace, model, mode="validation")
def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None: def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None:
@ -21,6 +108,19 @@ def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> No
register_schema_model(namespace, model) register_schema_model(namespace, model)
def register_response_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
"""Register a BaseModel using its serialized response shape."""
_register_schema_model(namespace, model, mode="serialization")
def register_response_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None:
"""Register multiple response BaseModels using their serialized response shape."""
for model in models:
register_response_schema_model(namespace, model)
def get_or_create_model(model_name: str, field_def): def get_or_create_model(model_name: str, field_def):
# Import lazily to avoid circular imports between console controllers and schema helpers. # Import lazily to avoid circular imports between console controllers and schema helpers.
from controllers.console import console_ns from controllers.console import console_ns
@ -34,15 +134,114 @@ def get_or_create_model(model_name: str, field_def):
def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None: def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None:
"""Register multiple StrEnum with a namespace.""" """Register multiple StrEnum with a namespace."""
for model in models: for model in models:
namespace.schema_model( _register_json_schema(
model.__name__, TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) namespace,
model.__name__,
TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
) )
def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]:
"""Build Flask-RESTX query parameter docs from a flat Pydantic model.
`Namespace.expect()` treats Pydantic schema models as request bodies, so GET
endpoints should keep runtime validation on the Pydantic model and feed this
derived mapping to `Namespace.doc(params=...)` for Swagger documentation.
"""
schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
properties = schema.get("properties", {})
if not isinstance(properties, Mapping):
return {}
required = schema.get("required", [])
required_names = set(required) if isinstance(required, list) else set()
params: dict[str, QueryParamDoc] = {}
for name, property_schema in properties.items():
if not isinstance(name, str) or not isinstance(property_schema, Mapping):
continue
params[name] = _query_param_from_property(property_schema, required=name in required_names)
return params
def _query_param_from_property(property_schema: Mapping[str, Any], *, required: bool) -> QueryParamDoc:
param_schema = _nullable_property_schema(property_schema)
param_doc: QueryParamDoc = {"in": "query", "required": required}
description = param_schema.get("description")
if isinstance(description, str):
param_doc["description"] = description
schema_type = param_schema.get("type")
if isinstance(schema_type, str) and schema_type in {"array", "boolean", "integer", "number", "string"}:
param_doc["type"] = schema_type
if schema_type == "array":
items = param_schema.get("items")
if isinstance(items, Mapping):
item_type = items.get("type")
if isinstance(item_type, str):
param_doc["items"] = {"type": item_type}
enum = param_schema.get("enum")
if isinstance(enum, list):
param_doc["enum"] = enum
default = param_schema.get("default")
if default is not None:
param_doc["default"] = default
minimum = param_schema.get("minimum")
if isinstance(minimum, int | float):
param_doc["minimum"] = minimum
maximum = param_schema.get("maximum")
if isinstance(maximum, int | float):
param_doc["maximum"] = maximum
min_length = param_schema.get("minLength")
if isinstance(min_length, int):
param_doc["minLength"] = min_length
max_length = param_schema.get("maxLength")
if isinstance(max_length, int):
param_doc["maxLength"] = max_length
min_items = param_schema.get("minItems")
if isinstance(min_items, int):
param_doc["minItems"] = min_items
max_items = param_schema.get("maxItems")
if isinstance(max_items, int):
param_doc["maxItems"] = max_items
return param_doc
def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str, Any]:
any_of = property_schema.get("anyOf")
if not isinstance(any_of, list):
return property_schema
non_null_candidates = [
candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null"
]
if len(non_null_candidates) == 1:
return {**property_schema, **non_null_candidates[0]}
return property_schema
__all__ = [ __all__ = [
"DEFAULT_REF_TEMPLATE_SWAGGER_2_0", "DEFAULT_REF_TEMPLATE_SWAGGER_2_0",
"get_or_create_model", "get_or_create_model",
"query_params_from_model",
"register_enum_models", "register_enum_models",
"register_response_schema_model",
"register_response_schema_models",
"register_schema_model", "register_schema_model",
"register_schema_models", "register_schema_models",
] ]

View File

@ -33,7 +33,6 @@ for module_name in RESOURCE_MODULES:
# Ensure resource modules are imported so route decorators are evaluated. # Ensure resource modules are imported so route decorators are evaluated.
# Import other controllers # Import other controllers
from . import ( from . import (
admin,
apikey, apikey,
extension, extension,
feature, feature,
@ -45,6 +44,8 @@ from . import (
spec, spec,
version, version,
) )
from .agent import composer as agent_composer
from .agent import roster as agent_roster
# Import app controllers # Import app controllers
from .app import ( from .app import (
@ -117,7 +118,7 @@ from .explore import (
saved_message, saved_message,
trial, trial,
) )
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport] from .socketio import workflow as socketio_workflow
# Import tag controllers # Import tag controllers
from .tag import tags from .tag import tags
@ -142,10 +143,11 @@ api.add_namespace(console_ns)
__all__ = [ __all__ = [
"account", "account",
"activate", "activate",
"admin",
"advanced_prompt_template", "advanced_prompt_template",
"agent", "agent",
"agent_composer",
"agent_providers", "agent_providers",
"agent_roster",
"annotation", "annotation",
"api", "api",
"apikey", "apikey",

View File

@ -1,72 +1,11 @@
import csv
import io
from collections.abc import Callable from collections.abc import Callable
from functools import wraps from functools import wraps
from typing import cast
from flask import request from flask import request
from flask_restx import Resource from werkzeug.exceptions import Unauthorized
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
from configs import dify_config from configs import dify_config
from constants.languages import supported_language
from controllers.console import console_ns
from controllers.console.wraps import only_edition_cloud
from core.db.session_factory import session_factory
from extensions.ext_database import db
from libs.token import extract_access_token from libs.token import extract_access_token
from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp
from services.billing_service import BillingService, LangContentDict
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class InsertExploreAppPayload(BaseModel):
app_id: str = Field(...)
desc: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
custom_disclaimer: str | None = None
language: str = Field(...)
category: str = Field(...)
position: int = Field(...)
can_trial: bool = Field(default=False)
trial_limit: int = Field(default=0)
@field_validator("language")
@classmethod
def validate_language(cls, value: str) -> str:
return supported_language(value)
class InsertExploreBannerPayload(BaseModel):
category: str = Field(...)
title: str = Field(...)
description: str = Field(...)
img_src: str = Field(..., alias="img-src")
language: str = Field(default="en-US")
link: str = Field(...)
sort: int = Field(...)
@field_validator("language")
@classmethod
def validate_language(cls, value: str) -> str:
return supported_language(value)
model_config = {"populate_by_name": True}
console_ns.schema_model(
InsertExploreAppPayload.__name__,
InsertExploreAppPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
InsertExploreBannerPayload.__name__,
InsertExploreBannerPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@ -84,361 +23,3 @@ def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return view(*args, **kwargs) return view(*args, **kwargs)
return decorated return decorated
@console_ns.route("/admin/insert-explore-apps")
class InsertExploreAppListApi(Resource):
@console_ns.doc("insert_explore_app")
@console_ns.doc(description="Insert or update an app in the explore list")
@console_ns.expect(console_ns.models[InsertExploreAppPayload.__name__])
@console_ns.response(200, "App updated successfully")
@console_ns.response(201, "App inserted successfully")
@console_ns.response(404, "App not found")
@only_edition_cloud
@admin_required
def post(self):
payload = InsertExploreAppPayload.model_validate(console_ns.payload)
app = db.session.execute(select(App).where(App.id == payload.app_id)).scalar_one_or_none()
if not app:
raise NotFound(f"App '{payload.app_id}' is not found")
site = app.site
if not site:
desc = payload.desc or ""
copy_right = payload.copyright or ""
privacy_policy = payload.privacy_policy or ""
custom_disclaimer = payload.custom_disclaimer or ""
else:
desc = site.description or payload.desc or ""
copy_right = site.copyright or payload.copyright or ""
privacy_policy = site.privacy_policy or payload.privacy_policy or ""
custom_disclaimer = site.custom_disclaimer or payload.custom_disclaimer or ""
with session_factory.create_session() as session:
recommended_app = session.execute(
select(RecommendedApp).where(RecommendedApp.app_id == payload.app_id)
).scalar_one_or_none()
if not recommended_app:
recommended_app = RecommendedApp(
app_id=app.id,
description=desc,
copyright=copy_right,
privacy_policy=privacy_policy,
custom_disclaimer=custom_disclaimer,
language=payload.language,
category=payload.category,
position=payload.position,
)
db.session.add(recommended_app)
if payload.can_trial:
trial_app = db.session.execute(
select(TrialApp).where(TrialApp.app_id == payload.app_id)
).scalar_one_or_none()
if not trial_app:
db.session.add(
TrialApp(
app_id=payload.app_id,
tenant_id=app.tenant_id,
trial_limit=payload.trial_limit,
)
)
else:
trial_app.trial_limit = payload.trial_limit
app.is_public = True
db.session.commit()
return {"result": "success"}, 201
else:
recommended_app.description = desc
recommended_app.copyright = copy_right
recommended_app.privacy_policy = privacy_policy
recommended_app.custom_disclaimer = custom_disclaimer
recommended_app.language = payload.language
recommended_app.category = payload.category
recommended_app.position = payload.position
if payload.can_trial:
trial_app = db.session.execute(
select(TrialApp).where(TrialApp.app_id == payload.app_id)
).scalar_one_or_none()
if not trial_app:
db.session.add(
TrialApp(
app_id=payload.app_id,
tenant_id=app.tenant_id,
trial_limit=payload.trial_limit,
)
)
else:
trial_app.trial_limit = payload.trial_limit
app.is_public = True
db.session.commit()
return {"result": "success"}, 200
@console_ns.route("/admin/insert-explore-apps/<uuid:app_id>")
class InsertExploreAppApi(Resource):
@console_ns.doc("delete_explore_app")
@console_ns.doc(description="Remove an app from the explore list")
@console_ns.doc(params={"app_id": "Application ID to remove"})
@console_ns.response(204, "App removed successfully")
@only_edition_cloud
@admin_required
def delete(self, app_id):
with session_factory.create_session() as session:
recommended_app = session.execute(
select(RecommendedApp).where(RecommendedApp.app_id == str(app_id))
).scalar_one_or_none()
if not recommended_app:
return {"result": "success"}, 204
with session_factory.create_session() as session:
app = session.execute(select(App).where(App.id == recommended_app.app_id)).scalar_one_or_none()
if app:
app.is_public = False
with session_factory.create_session() as session:
installed_apps = (
session.execute(
select(InstalledApp).where(
InstalledApp.app_id == recommended_app.app_id,
InstalledApp.tenant_id != InstalledApp.app_owner_tenant_id,
)
)
.scalars()
.all()
)
for installed_app in installed_apps:
session.delete(installed_app)
trial_app = session.execute(
select(TrialApp).where(TrialApp.app_id == recommended_app.app_id)
).scalar_one_or_none()
if trial_app:
session.delete(trial_app)
db.session.delete(recommended_app)
db.session.commit()
return {"result": "success"}, 204
@console_ns.route("/admin/insert-explore-banner")
class InsertExploreBannerApi(Resource):
@console_ns.doc("insert_explore_banner")
@console_ns.doc(description="Insert an explore banner")
@console_ns.expect(console_ns.models[InsertExploreBannerPayload.__name__])
@console_ns.response(201, "Banner inserted successfully")
@only_edition_cloud
@admin_required
def post(self):
payload = InsertExploreBannerPayload.model_validate(console_ns.payload)
banner = ExporleBanner(
content={
"category": payload.category,
"title": payload.title,
"description": payload.description,
"img-src": payload.img_src,
},
link=payload.link,
sort=payload.sort,
language=payload.language,
)
db.session.add(banner)
db.session.commit()
return {"result": "success"}, 201
@console_ns.route("/admin/delete-explore-banner/<uuid:banner_id>")
class DeleteExploreBannerApi(Resource):
@console_ns.doc("delete_explore_banner")
@console_ns.doc(description="Delete an explore banner")
@console_ns.doc(params={"banner_id": "Banner ID to delete"})
@console_ns.response(204, "Banner deleted successfully")
@only_edition_cloud
@admin_required
def delete(self, banner_id):
banner = db.session.execute(select(ExporleBanner).where(ExporleBanner.id == banner_id)).scalar_one_or_none()
if not banner:
raise NotFound(f"Banner '{banner_id}' is not found")
db.session.delete(banner)
db.session.commit()
return {"result": "success"}, 204
class LangContentPayload(BaseModel):
lang: str = Field(..., description="Language tag: 'zh' | 'en' | 'jp'")
title: str = Field(...)
subtitle: str | None = Field(default=None)
body: str = Field(...)
title_pic_url: str | None = Field(default=None)
class UpsertNotificationPayload(BaseModel):
notification_id: str | None = Field(default=None, description="Omit to create; supply UUID to update")
contents: list[LangContentPayload] = Field(..., min_length=1)
start_time: str | None = Field(default=None, description="RFC3339, e.g. 2026-03-01T00:00:00Z")
end_time: str | None = Field(default=None, description="RFC3339, e.g. 2026-03-20T23:59:59Z")
frequency: str = Field(default="once", description="'once' | 'every_page_load'")
status: str = Field(default="active", description="'active' | 'inactive'")
class BatchAddNotificationAccountsPayload(BaseModel):
notification_id: str = Field(...)
user_email: list[str] = Field(..., description="List of account email addresses")
console_ns.schema_model(
UpsertNotificationPayload.__name__,
UpsertNotificationPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
BatchAddNotificationAccountsPayload.__name__,
BatchAddNotificationAccountsPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
@console_ns.route("/admin/upsert_notification")
class UpsertNotificationApi(Resource):
@console_ns.doc("upsert_notification")
@console_ns.doc(
description=(
"Create or update an in-product notification. "
"Supply notification_id to update an existing one; omit it to create a new one. "
"Pass at least one language variant in contents (zh / en / jp)."
)
)
@console_ns.expect(console_ns.models[UpsertNotificationPayload.__name__])
@console_ns.response(200, "Notification upserted successfully")
@only_edition_cloud
@admin_required
def post(self):
payload = UpsertNotificationPayload.model_validate(console_ns.payload)
result = BillingService.upsert_notification(
contents=[cast(LangContentDict, c.model_dump()) for c in payload.contents],
frequency=payload.frequency,
status=payload.status,
notification_id=payload.notification_id,
start_time=payload.start_time,
end_time=payload.end_time,
)
return {"result": "success", "notification_id": result.get("notificationId")}, 200
@console_ns.route("/admin/batch_add_notification_accounts")
class BatchAddNotificationAccountsApi(Resource):
@console_ns.doc("batch_add_notification_accounts")
@console_ns.doc(
description=(
"Register target accounts for a notification by email address. "
'JSON body: {"notification_id": "...", "user_email": ["a@example.com", ...]}. '
"File upload: multipart/form-data with a 'file' field (CSV or TXT, one email per line) "
"plus a 'notification_id' field. "
"Emails that do not match any account are silently skipped."
)
)
@console_ns.response(200, "Accounts added successfully")
@only_edition_cloud
@admin_required
def post(self):
from models.account import Account
if "file" in request.files:
notification_id = request.form.get("notification_id", "").strip()
if not notification_id:
raise BadRequest("notification_id is required.")
emails = self._parse_emails_from_file()
else:
payload = BatchAddNotificationAccountsPayload.model_validate(console_ns.payload)
notification_id = payload.notification_id
emails = payload.user_email
if not emails:
raise BadRequest("No valid email addresses provided.")
# Resolve emails → account IDs in chunks to avoid large IN-clause
account_ids: list[str] = []
chunk_size = 500
for i in range(0, len(emails), chunk_size):
chunk = emails[i : i + chunk_size]
rows = db.session.execute(select(Account.id, Account.email).where(Account.email.in_(chunk))).all()
account_ids.extend(str(row.id) for row in rows)
if not account_ids:
raise BadRequest("None of the provided emails matched an existing account.")
# Send to dify-saas in batches of 1000
total_count = 0
batch_size = 1000
for i in range(0, len(account_ids), batch_size):
batch = account_ids[i : i + batch_size]
result = BillingService.batch_add_notification_accounts(
notification_id=notification_id,
account_ids=batch,
)
total_count += result.get("count", 0)
return {
"result": "success",
"emails_provided": len(emails),
"accounts_matched": len(account_ids),
"count": total_count,
}, 200
@staticmethod
def _parse_emails_from_file() -> list[str]:
"""Parse email addresses from an uploaded CSV or TXT file."""
file = request.files["file"]
if not file.filename:
raise BadRequest("Uploaded file has no filename.")
filename_lower = file.filename.lower()
if not filename_lower.endswith((".csv", ".txt")):
raise BadRequest("Invalid file type. Only CSV (.csv) and TXT (.txt) files are allowed.")
try:
content = file.read().decode("utf-8")
except UnicodeDecodeError:
try:
file.seek(0)
content = file.read().decode("gbk")
except UnicodeDecodeError:
raise BadRequest("Unable to decode the file. Please use UTF-8 or GBK encoding.")
emails: list[str] = []
if filename_lower.endswith(".csv"):
reader = csv.reader(io.StringIO(content))
for row in reader:
for cell in row:
cell = cell.strip()
if cell:
emails.append(cell)
else:
for line in content.splitlines():
line = line.strip()
if line:
emails.append(line)
# Deduplicate while preserving order
seen: set[str] = set()
unique_emails: list[str] = []
for email in emails:
if email.lower() not in seen:
seen.add(email.lower())
unique_emails.append(email)
return unique_emails

View File

@ -0,0 +1,3 @@
from . import composer, roster
__all__ = ["composer", "roster"]

View File

@ -0,0 +1,153 @@
from flask_restx import Resource
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from libs.login import current_account_with_tenant, login_required
from models.model import AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
register_schema_models(console_ns, ComposerSavePayload)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
class WorkflowAgentComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model, node_id: str):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def put(self, app_model, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
class WorkflowAgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
class WorkflowAgentComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model, node_id: str):
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/impact")
class WorkflowAgentComposerImpactApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
_, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
if not current_snapshot_id:
return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/save-to-roster")
class WorkflowAgentComposerSaveToRosterApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
class AgentAppComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model()
def put(self, app_model):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
class AgentAppComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def post(self, app_model):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
class AgentAppComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model):
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)

View File

@ -0,0 +1,130 @@
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from services.agent.roster_service import AgentRosterService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
class AgentInviteOptionsQuery(RosterListQuery):
app_id: str | None = Field(default=None, description="Workflow app id for in-current-workflow markers")
class AgentIdPath(BaseModel):
agent_id: str
register_schema_models(
console_ns,
AgentInviteOptionsQuery,
AgentIdPath,
RosterAgentCreatePayload,
RosterAgentUpdatePayload,
RosterListQuery,
)
def _agent_roster_service() -> AgentRosterService:
return AgentRosterService(db.session)
@console_ns.route("/agents")
class AgentRosterListApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
return _agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
)
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def post(self):
account, tenant_id = current_account_with_tenant()
payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {})
service = _agent_roster_service()
agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account.id, payload=payload)
return service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201
@console_ns.route("/agents/invite-options")
class AgentInviteOptionsApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True))
return _agent_roster_service().list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
)
@console_ns.route("/agents/<uuid:agent_id>")
class AgentRosterDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id):
_, tenant_id = current_account_with_tenant()
return _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id))
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def patch(self, agent_id):
account, tenant_id = current_account_with_tenant()
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
return _agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
)
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
def delete(self, agent_id):
account, tenant_id = current_account_with_tenant()
_agent_roster_service().archive_roster_agent(tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id)
return "", 204
@console_ns.route("/agents/<uuid:agent_id>/versions")
class AgentRosterVersionsApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id):
_, tenant_id = current_account_with_tenant()
return {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))}
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
class AgentRosterVersionDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id, version_id):
_, tenant_id = current_account_with_tenant()
return _agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
)

View File

@ -11,6 +11,7 @@ from werkzeug.exceptions import Forbidden
from controllers.common.schema import register_schema_models from controllers.common.schema import register_schema_models
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models.dataset import Dataset from models.dataset import Dataset
from models.enums import ApiTokenType from models.enums import ApiTokenType
@ -21,12 +22,6 @@ from . import console_ns
from .wraps import account_initialization_required, edit_permission_required, setup_required from .wraps import account_initialization_required, edit_permission_required, setup_required
def _to_timestamp(value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class ApiKeyItem(ResponseModel): class ApiKeyItem(ResponseModel):
id: str id: str
type: str type: str
@ -37,7 +32,7 @@ class ApiKeyItem(ResponseModel):
@field_validator("last_used_at", "created_at", mode="before") @field_validator("last_used_at", "created_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
class ApiKeyList(ResponseModel): class ApiKeyList(ResponseModel):
@ -151,7 +146,7 @@ class BaseApiKeyResource(Resource):
db.session.execute(delete(ApiToken).where(ApiToken.id == api_key_id)) db.session.execute(delete(ApiToken).where(ApiToken.id == api_key_id))
db.session.commit() db.session.commit()
return {"result": "success"}, 204 return "", 204
@console_ns.route("/apps/<uuid:resource_id>/api-keys") @console_ns.route("/apps/<uuid:resource_id>/api-keys")

View File

@ -34,7 +34,7 @@ class AdvancedPromptTemplateList(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
def get(self): def get(self):
args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True))
prompt_args: AdvancedPromptTemplateArgs = { prompt_args: AdvancedPromptTemplateArgs = {
"app_mode": args.app_mode, "app_mode": args.app_mode,
"model_mode": args.model_mode, "model_mode": args.model_mode,

View File

@ -2,6 +2,7 @@ from flask import request
from flask_restx import Resource, fields from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required from controllers.console.wraps import account_initialization_required, setup_required
@ -10,8 +11,6 @@ from libs.login import login_required
from models.model import AppMode from models.model import AppMode
from services.agent_service import AgentService from services.agent_service import AgentService
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class AgentLogQuery(BaseModel): class AgentLogQuery(BaseModel):
message_id: str = Field(..., description="Message UUID") message_id: str = Field(..., description="Message UUID")
@ -23,9 +22,7 @@ class AgentLogQuery(BaseModel):
return uuid_value(value) return uuid_value(value)
console_ns.schema_model( register_schema_models(console_ns, AgentLogQuery)
AgentLogQuery.__name__, AgentLogQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
)
@console_ns.route("/apps/<uuid:app_id>/agent/logs") @console_ns.route("/apps/<uuid:app_id>/agent/logs")
@ -44,6 +41,6 @@ class AgentLogApi(Resource):
@get_app_model(mode=[AppMode.AGENT_CHAT]) @get_app_model(mode=[AppMode.AGENT_CHAT])
def get(self, app_model): def get(self, app_model):
"""Get agent logs""" """Get agent logs"""
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))
return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id) return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id)

View File

@ -1,4 +1,5 @@
from typing import Any, Literal from typing import Any, Literal
from uuid import UUID
from flask import abort, make_response, request from flask import abort, make_response, request
from flask_restx import Resource from flask_restx import Resource
@ -33,8 +34,6 @@ from services.annotation_service import (
UpsertAnnotationArgs, UpsertAnnotationArgs,
) )
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class AnnotationReplyPayload(BaseModel): class AnnotationReplyPayload(BaseModel):
score_threshold: float = Field(..., description="Score threshold for annotation matching") score_threshold: float = Field(..., description="Score threshold for annotation matching")
@ -87,17 +86,6 @@ class AnnotationFilePayload(BaseModel):
return uuid_value(value) return uuid_value(value)
def reg(model: type[BaseModel]) -> None:
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
reg(AnnotationReplyPayload)
reg(AnnotationSettingUpdatePayload)
reg(AnnotationListQuery)
reg(CreateAnnotationPayload)
reg(UpdateAnnotationPayload)
reg(AnnotationReplyStatusQuery)
reg(AnnotationFilePayload)
register_schema_models( register_schema_models(
console_ns, console_ns,
Annotation, Annotation,
@ -105,6 +93,13 @@ register_schema_models(
AnnotationExportList, AnnotationExportList,
AnnotationHitHistory, AnnotationHitHistory,
AnnotationHitHistoryList, AnnotationHitHistoryList,
AnnotationReplyPayload,
AnnotationSettingUpdatePayload,
AnnotationListQuery,
CreateAnnotationPayload,
UpdateAnnotationPayload,
AnnotationReplyStatusQuery,
AnnotationFilePayload,
) )
@ -121,8 +116,7 @@ class AnnotationReplyActionApi(Resource):
@account_initialization_required @account_initialization_required
@cloud_edition_billing_resource_check("annotation") @cloud_edition_billing_resource_check("annotation")
@edit_permission_required @edit_permission_required
def post(self, app_id, action: Literal["enable", "disable"]): def post(self, app_id: UUID, action: Literal["enable", "disable"]):
app_id = str(app_id)
args = AnnotationReplyPayload.model_validate(console_ns.payload) args = AnnotationReplyPayload.model_validate(console_ns.payload)
match action: match action:
case "enable": case "enable":
@ -131,9 +125,9 @@ class AnnotationReplyActionApi(Resource):
"embedding_provider_name": args.embedding_provider_name, "embedding_provider_name": args.embedding_provider_name,
"embedding_model_name": args.embedding_model_name, "embedding_model_name": args.embedding_model_name,
} }
result = AppAnnotationService.enable_app_annotation(enable_args, app_id) result = AppAnnotationService.enable_app_annotation(enable_args, str(app_id))
case "disable": case "disable":
result = AppAnnotationService.disable_app_annotation(app_id) result = AppAnnotationService.disable_app_annotation(str(app_id))
return result, 200 return result, 200
@ -148,9 +142,8 @@ class AppAnnotationSettingDetailApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
def get(self, app_id): def get(self, app_id: UUID):
app_id = str(app_id) result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id))
result = AppAnnotationService.get_app_annotation_setting_by_app_id(app_id)
return result, 200 return result, 200
@ -166,14 +159,13 @@ class AppAnnotationSettingUpdateApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
def post(self, app_id, annotation_setting_id): def post(self, app_id: UUID, annotation_setting_id):
app_id = str(app_id)
annotation_setting_id = str(annotation_setting_id) annotation_setting_id = str(annotation_setting_id)
args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload) args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload)
setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold} setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold}
result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, setting_args) result = AppAnnotationService.update_app_annotation_setting(str(app_id), annotation_setting_id, setting_args)
return result, 200 return result, 200
@ -189,7 +181,7 @@ class AnnotationReplyActionStatusApi(Resource):
@account_initialization_required @account_initialization_required
@cloud_edition_billing_resource_check("annotation") @cloud_edition_billing_resource_check("annotation")
@edit_permission_required @edit_permission_required
def get(self, app_id, job_id, action): def get(self, app_id: UUID, job_id, action):
job_id = str(job_id) job_id = str(job_id)
app_annotation_job_key = f"{action}_app_annotation_job_{str(job_id)}" app_annotation_job_key = f"{action}_app_annotation_job_{str(job_id)}"
cache_result = redis_client.get(app_annotation_job_key) cache_result = redis_client.get(app_annotation_job_key)
@ -217,14 +209,13 @@ class AnnotationApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
def get(self, app_id): def get(self, app_id: UUID):
args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True))
page = args.page page = args.page
limit = args.limit limit = args.limit
keyword = args.keyword keyword = args.keyword
app_id = str(app_id) annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(str(app_id), page, limit, keyword)
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(app_id, page, limit, keyword)
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
response = AnnotationList( response = AnnotationList(
data=annotation_models, data=annotation_models,
@ -246,8 +237,7 @@ class AnnotationApi(Resource):
@account_initialization_required @account_initialization_required
@cloud_edition_billing_resource_check("annotation") @cloud_edition_billing_resource_check("annotation")
@edit_permission_required @edit_permission_required
def post(self, app_id): def post(self, app_id: UUID):
app_id = str(app_id)
args = CreateAnnotationPayload.model_validate(console_ns.payload) args = CreateAnnotationPayload.model_validate(console_ns.payload)
upsert_args: UpsertAnnotationArgs = {} upsert_args: UpsertAnnotationArgs = {}
if args.answer is not None: if args.answer is not None:
@ -258,15 +248,14 @@ class AnnotationApi(Resource):
upsert_args["message_id"] = args.message_id upsert_args["message_id"] = args.message_id
if args.question is not None: if args.question is not None:
upsert_args["question"] = args.question upsert_args["question"] = args.question
annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, app_id) annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, str(app_id))
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
def delete(self, app_id): def delete(self, app_id: UUID):
app_id = str(app_id)
# Use request.args.getlist to get annotation_ids array directly # Use request.args.getlist to get annotation_ids array directly
annotation_ids = request.args.getlist("annotation_id") annotation_ids = request.args.getlist("annotation_id")
@ -280,12 +269,12 @@ class AnnotationApi(Resource):
"message": "annotation_ids are required if the parameter is provided.", "message": "annotation_ids are required if the parameter is provided.",
}, 400 }, 400
result = AppAnnotationService.delete_app_annotations_in_batch(app_id, annotation_ids) AppAnnotationService.delete_app_annotations_in_batch(str(app_id), annotation_ids)
return result, 204 return "", 204
# If no annotation_ids are provided, handle clearing all annotations # If no annotation_ids are provided, handle clearing all annotations
else: else:
AppAnnotationService.clear_all_annotations(app_id) AppAnnotationService.clear_all_annotations(str(app_id))
return {"result": "success"}, 204 return "", 204
@console_ns.route("/apps/<uuid:app_id>/annotations/export") @console_ns.route("/apps/<uuid:app_id>/annotations/export")
@ -303,9 +292,8 @@ class AnnotationExportApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
def get(self, app_id): def get(self, app_id: UUID):
app_id = str(app_id) annotation_list = AppAnnotationService.export_annotation_list_by_app_id(str(app_id))
annotation_list = AppAnnotationService.export_annotation_list_by_app_id(app_id)
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
response_data = AnnotationExportList(data=annotation_models).model_dump(mode="json") response_data = AnnotationExportList(data=annotation_models).model_dump(mode="json")
@ -331,27 +319,23 @@ class AnnotationUpdateDeleteApi(Resource):
@account_initialization_required @account_initialization_required
@cloud_edition_billing_resource_check("annotation") @cloud_edition_billing_resource_check("annotation")
@edit_permission_required @edit_permission_required
def post(self, app_id, annotation_id): def post(self, app_id: UUID, annotation_id: UUID):
app_id = str(app_id)
annotation_id = str(annotation_id)
args = UpdateAnnotationPayload.model_validate(console_ns.payload) args = UpdateAnnotationPayload.model_validate(console_ns.payload)
update_args: UpdateAnnotationArgs = {} update_args: UpdateAnnotationArgs = {}
if args.answer is not None: if args.answer is not None:
update_args["answer"] = args.answer update_args["answer"] = args.answer
if args.question is not None: if args.question is not None:
update_args["question"] = args.question update_args["question"] = args.question
annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_id, annotation_id) annotation = AppAnnotationService.update_app_annotation_directly(update_args, str(app_id), str(annotation_id))
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
def delete(self, app_id, annotation_id): def delete(self, app_id: UUID, annotation_id: UUID):
app_id = str(app_id) AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id))
annotation_id = str(annotation_id) return "", 204
AppAnnotationService.delete_app_annotation(app_id, annotation_id)
return {"result": "success"}, 204
@console_ns.route("/apps/<uuid:app_id>/annotations/batch-import") @console_ns.route("/apps/<uuid:app_id>/annotations/batch-import")
@ -371,11 +355,9 @@ class AnnotationBatchImportApi(Resource):
@annotation_import_rate_limit @annotation_import_rate_limit
@annotation_import_concurrency_limit @annotation_import_concurrency_limit
@edit_permission_required @edit_permission_required
def post(self, app_id): def post(self, app_id: UUID):
from configs import dify_config from configs import dify_config
app_id = str(app_id)
# check file # check file
if "file" not in request.files: if "file" not in request.files:
raise NoFileUploadedError() raise NoFileUploadedError()
@ -391,9 +373,9 @@ class AnnotationBatchImportApi(Resource):
raise ValueError("Invalid file type. Only CSV files are allowed") raise ValueError("Invalid file type. Only CSV files are allowed")
# Check file size before processing # Check file size before processing
file.seek(0, 2) # Seek to end of file file.stream.seek(0, 2) # Seek to end of file
file_size = file.tell() file_size = file.stream.tell()
file.seek(0) # Reset to beginning file.stream.seek(0) # Reset to beginning
max_size_bytes = dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT * 1024 * 1024 max_size_bytes = dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT * 1024 * 1024
if file_size > max_size_bytes: if file_size > max_size_bytes:
@ -406,7 +388,7 @@ class AnnotationBatchImportApi(Resource):
if file_size == 0: if file_size == 0:
raise ValueError("The uploaded file is empty") raise ValueError("The uploaded file is empty")
return AppAnnotationService.batch_import_app_annotations(app_id, file) return AppAnnotationService.batch_import_app_annotations(str(app_id), file)
@console_ns.route("/apps/<uuid:app_id>/annotations/batch-import-status/<uuid:job_id>") @console_ns.route("/apps/<uuid:app_id>/annotations/batch-import-status/<uuid:job_id>")
@ -421,8 +403,7 @@ class AnnotationBatchImportStatusApi(Resource):
@account_initialization_required @account_initialization_required
@cloud_edition_billing_resource_check("annotation") @cloud_edition_billing_resource_check("annotation")
@edit_permission_required @edit_permission_required
def get(self, app_id, job_id): def get(self, app_id: UUID, job_id: UUID):
job_id = str(job_id)
indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}" indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}"
cache_result = redis_client.get(indexing_cache_key) cache_result = redis_client.get(indexing_cache_key)
if cache_result is None: if cache_result is None:
@ -456,13 +437,11 @@ class AnnotationHitHistoryListApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
def get(self, app_id, annotation_id): def get(self, app_id: UUID, annotation_id: UUID):
page = request.args.get("page", default=1, type=int) page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int) limit = request.args.get("limit", default=20, type=int)
app_id = str(app_id)
annotation_id = str(annotation_id)
annotation_hit_history_list, total = AppAnnotationService.get_annotation_hit_histories( annotation_hit_history_list, total = AppAnnotationService.get_annotation_hit_histories(
app_id, annotation_id, page, limit str(app_id), str(annotation_id), page, limit
) )
history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python( history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python(
annotation_hit_history_list, from_attributes=True annotation_hit_history_list, from_attributes=True

View File

@ -12,8 +12,9 @@ from sqlalchemy.orm import Session
from werkzeug.datastructures import MultiDict from werkzeug.datastructures import MultiDict
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse
from controllers.common.helpers import FileInfo from controllers.common.helpers import FileInfo
from controllers.common.schema import register_enum_models, register_schema_models from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.workspace.models import LoadBalancingPayload from controllers.console.workspace.models import LoadBalancingPayload
@ -25,6 +26,7 @@ from controllers.console.wraps import (
is_admin_or_owner_required, is_admin_or_owner_required,
setup_required, setup_required,
) )
from core.db.session_factory import session_factory
from core.ops.ops_trace_manager import OpsTraceManager from core.ops.ops_trace_manager import OpsTraceManager
from core.rag.entities import PreProcessingRule, Rule, Segmentation from core.rag.entities import PreProcessingRule, Rule, Segmentation
from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.rag.retrieval.retrieval_methods import RetrievalMethod
@ -32,12 +34,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus from graphon.enums import WorkflowExecutionStatus
from libs.helper import build_icon_url from libs.helper import build_icon_url, to_timestamp
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import App, DatasetPermissionEnum, Workflow from models import App, DatasetPermissionEnum, Workflow
from models.model import IconType from models.model import IconType
from services.app_dsl_service import AppDslService from services.app_dsl_service import AppDslService
from services.app_service import AppService from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import ( from services.entities.knowledge_entities.knowledge_entities import (
@ -176,12 +178,6 @@ class AppTracePayload(BaseModel):
type JSONValue = Any type JSONValue = Any
def _to_timestamp(value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class Tag(ResponseModel): class Tag(ResponseModel):
id: str id: str
name: str name: str
@ -198,7 +194,7 @@ class WorkflowPartial(ResponseModel):
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
class ModelConfigPartial(ResponseModel): class ModelConfigPartial(ResponseModel):
@ -212,7 +208,7 @@ class ModelConfigPartial(ResponseModel):
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
class ModelConfig(ResponseModel): class ModelConfig(ResponseModel):
@ -273,7 +269,7 @@ class ModelConfig(ResponseModel):
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
class Site(ResponseModel): class Site(ResponseModel):
@ -316,7 +312,7 @@ class Site(ResponseModel):
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
class DeletedTool(ResponseModel): class DeletedTool(ResponseModel):
@ -359,7 +355,7 @@ class AppPartial(ResponseModel):
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
class AppDetail(ResponseModel): class AppDetail(ResponseModel):
@ -389,7 +385,7 @@ class AppDetail(ResponseModel):
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
class AppDetailWithSite(AppDetail): class AppDetailWithSite(AppDetail):
@ -418,6 +414,7 @@ class AppExportResponse(ResponseModel):
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum) register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
register_schema_models( register_schema_models(
console_ns, console_ns,
@ -476,11 +473,18 @@ class AppListApi(Resource):
current_user, current_tenant_id = current_account_with_tenant() current_user, current_tenant_id = current_account_with_tenant()
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args)) args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
args_dict = args.model_dump() params = AppListParams(
page=args.page,
limit=args.limit,
mode=args.mode,
name=args.name,
tag_ids=args.tag_ids,
is_created_by_me=args.is_created_by_me,
)
# get app list # get app list
app_service = AppService() app_service = AppService()
app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, args_dict) app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params)
if not app_pagination: if not app_pagination:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200 return empty.model_dump(mode="json"), 200
@ -544,9 +548,17 @@ class AppListApi(Resource):
"""Create app""" """Create app"""
current_user, current_tenant_id = current_account_with_tenant() current_user, current_tenant_id = current_account_with_tenant()
args = CreateAppPayload.model_validate(console_ns.payload) args = CreateAppPayload.model_validate(console_ns.payload)
params = CreateAppParams(
name=args.name,
description=args.description,
mode=args.mode,
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
)
app_service = AppService() app_service = AppService()
app = app_service.create_app(current_tenant_id, args.model_dump(), current_user) app = app_service.create_app(current_tenant_id, params, current_user)
app_detail = AppDetail.model_validate(app, from_attributes=True) app_detail = AppDetail.model_validate(app, from_attributes=True)
return app_detail.model_dump(mode="json"), 201 return app_detail.model_dump(mode="json"), 201
@ -621,7 +633,7 @@ class AppApi(Resource):
app_service = AppService() app_service = AppService()
app_service.delete_app(app_model) app_service.delete_app(app_model)
return {"result": "success"}, 204 return "", 204
@console_ns.route("/apps/<uuid:app_id>/copy") @console_ns.route("/apps/<uuid:app_id>/copy")
@ -700,7 +712,7 @@ class AppExportApi(Resource):
@edit_permission_required @edit_permission_required
def get(self, app_model): def get(self, app_model):
"""Export app""" """Export app"""
args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = AppExportQuery.model_validate(request.args.to_dict(flat=True))
payload = AppExportResponse( payload = AppExportResponse(
data=AppDslService.export_dsl( data=AppDslService.export_dsl(
@ -714,6 +726,7 @@ class AppExportApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/publish-to-creators-platform") @console_ns.route("/apps/<uuid:app_id>/publish-to-creators-platform")
class AppPublishToCreatorsPlatformApi(Resource): class AppPublishToCreatorsPlatformApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RedirectUrlResponse.__name__])
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@ -839,9 +852,11 @@ class AppTraceApi(Resource):
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
def get(self, app_id): @get_app_model
def get(self, app_model):
"""Get app trace""" """Get app trace"""
app_trace_config = OpsTraceManager.get_app_tracing_config(app_id=app_id) with session_factory.create_session() as session:
app_trace_config = OpsTraceManager.get_app_tracing_config(app_model.id, session)
return app_trace_config return app_trace_config
@ -849,18 +864,23 @@ class AppTraceApi(Resource):
@console_ns.doc(description="Update app tracing configuration") @console_ns.doc(description="Update app tracing configuration")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[AppTracePayload.__name__]) @console_ns.expect(console_ns.models[AppTracePayload.__name__])
@console_ns.response(200, "Trace configuration updated successfully") @console_ns.response(
200,
"Trace configuration updated successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
def post(self, app_id): @get_app_model
def post(self, app_model):
# add app trace # add app trace
args = AppTracePayload.model_validate(console_ns.payload) args = AppTracePayload.model_validate(console_ns.payload)
OpsTraceManager.update_app_tracing_config( OpsTraceManager.update_app_tracing_config(
app_id=app_id, app_id=app_model.id,
enabled=args.enabled, enabled=args.enabled,
tracing_provider=args.tracing_provider, tracing_provider=args.tracing_provider,
) )

View File

@ -2,7 +2,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_models from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import ( from controllers.console.wraps import (
account_initialization_required, account_initialization_required,
@ -33,6 +33,7 @@ class AppImportPayload(BaseModel):
app_id: str | None = Field(None) app_id: str | None = Field(None)
register_enum_models(console_ns, ImportStatus)
register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult) register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult)

View File

@ -173,7 +173,7 @@ class TextModesApi(Resource):
@account_initialization_required @account_initialization_required
def get(self, app_model): def get(self, app_model):
try: try:
args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
response = AudioService.transcript_tts_voices( response = AudioService.transcript_tts_voices(
tenant_id=app_model.tenant_id, tenant_id=app_model.tenant_id,

View File

@ -7,6 +7,8 @@ from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import InternalServerError, NotFound from werkzeug.exceptions import InternalServerError, NotFound
import services import services
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import ( from controllers.console.app.error import (
AppUnavailableError, AppUnavailableError,
@ -37,7 +39,6 @@ from services.app_task_service import AppTaskService
from services.errors.llm import InvokeRateLimitError from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class BaseMessagePayload(BaseModel): class BaseMessagePayload(BaseModel):
@ -65,13 +66,8 @@ class ChatMessagePayload(BaseMessagePayload):
return uuid_value(value) return uuid_value(value)
console_ns.schema_model( register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
CompletionMessagePayload.__name__, register_response_schema_models(console_ns, SimpleResultResponse)
CompletionMessagePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
ChatMessagePayload.__name__, ChatMessagePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
)
# define completion message api for user # define completion message api for user
@ -130,7 +126,7 @@ class CompletionMessageStopApi(Resource):
@console_ns.doc("stop_completion_message") @console_ns.doc("stop_completion_message")
@console_ns.doc(description="Stop a running completion message generation") @console_ns.doc(description="Stop a running completion message generation")
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"}) @console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"})
@console_ns.response(200, "Task stopped successfully") @console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@ -211,7 +207,7 @@ class ChatMessageStopApi(Resource):
@console_ns.doc("stop_chat_message") @console_ns.doc("stop_chat_message")
@console_ns.doc(description="Stop a running chat message generation") @console_ns.doc(description="Stop a running chat message generation")
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"}) @console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"})
@console_ns.response(200, "Task stopped successfully") @console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required

View File

@ -29,9 +29,6 @@ from fields.conversation_fields import (
from fields.conversation_fields import ( from fields.conversation_fields import (
ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse, ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse,
) )
from fields.conversation_fields import (
ResultResponse,
)
from libs.datetime_utils import naive_utc_now, parse_time_range from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import Conversation, EndUser, Message, MessageAnnotation from models import Conversation, EndUser, Message, MessageAnnotation
@ -39,8 +36,6 @@ from models.model import AppMode
from services.conversation_service import ConversationService from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError from services.errors.conversation import ConversationNotExistsError
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class BaseConversationQuery(BaseModel): class BaseConversationQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword") keyword: str | None = Field(default=None, description="Search keyword")
@ -70,15 +65,6 @@ class ChatConversationQuery(BaseConversationQuery):
) )
console_ns.schema_model(
CompletionConversationQuery.__name__,
CompletionConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
ChatConversationQuery.__name__,
ChatConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
register_schema_models( register_schema_models(
console_ns, console_ns,
CompletionConversationQuery, CompletionConversationQuery,
@ -88,7 +74,8 @@ register_schema_models(
ConversationMessageDetailResponse, ConversationMessageDetailResponse,
ConversationWithSummaryPaginationResponse, ConversationWithSummaryPaginationResponse,
ConversationDetailResponse, ConversationDetailResponse,
ResultResponse, CompletionConversationQuery,
ChatConversationQuery,
) )
@ -107,7 +94,7 @@ class CompletionConversationApi(Resource):
@edit_permission_required @edit_permission_required
def get(self, app_model): def get(self, app_model):
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True))
query = sa.select(Conversation).where( query = sa.select(Conversation).where(
Conversation.app_id == app_model.id, Conversation.mode == "completion", Conversation.is_deleted.is_(False) Conversation.app_id == app_model.id, Conversation.mode == "completion", Conversation.is_deleted.is_(False)
@ -203,7 +190,7 @@ class CompletionConversationDetailApi(Resource):
except ConversationNotExistsError: except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.") raise NotFound("Conversation Not Exists.")
return ResultResponse(result="success").model_dump(mode="json"), 204 return "", 204
@console_ns.route("/apps/<uuid:app_id>/chat-conversations") @console_ns.route("/apps/<uuid:app_id>/chat-conversations")
@ -221,7 +208,7 @@ class ChatConversationApi(Resource):
@edit_permission_required @edit_permission_required
def get(self, app_model): def get(self, app_model):
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True))
subquery = ( subquery = (
sa.select(Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id")) sa.select(Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id"))
@ -356,7 +343,7 @@ class ChatConversationDetailApi(Resource):
except ConversationNotExistsError: except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.") raise NotFound("Conversation Not Exists.")
return ResultResponse(result="success").model_dump(mode="json"), 204 return "", 204
def _get_conversation(app_model, conversation_id): def _get_conversation(app_model, conversation_id):

View File

@ -16,6 +16,7 @@ from controllers.console.wraps import account_initialization_required, setup_req
from extensions.ext_database import db from extensions.ext_database import db
from fields._value_type_serializer import serialize_value_type from fields._value_type_serializer import serialize_value_type
from fields.base import ResponseModel from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.login import login_required from libs.login import login_required
from models import ConversationVariable from models import ConversationVariable
from models.model import AppMode from models.model import AppMode
@ -25,12 +26,6 @@ class ConversationVariablesQuery(BaseModel):
conversation_id: str = Field(..., description="Conversation ID to filter variables") conversation_id: str = Field(..., description="Conversation ID to filter variables")
def _to_timestamp(value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class ConversationVariableResponse(ResponseModel): class ConversationVariableResponse(ResponseModel):
id: str id: str
name: str name: str
@ -65,7 +60,7 @@ class ConversationVariableResponse(ResponseModel):
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
class PaginatedConversationVariableResponse(ResponseModel): class PaginatedConversationVariableResponse(ResponseModel):
@ -100,7 +95,7 @@ class ConversationVariablesApi(Resource):
@account_initialization_required @account_initialization_required
@get_app_model(mode=AppMode.ADVANCED_CHAT) @get_app_model(mode=AppMode.ADVANCED_CHAT)
def get(self, app_model): def get(self, app_model):
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True))
stmt = ( stmt = (
select(ConversationVariable) select(ConversationVariable)

View File

@ -3,6 +3,7 @@ from collections.abc import Sequence
from flask_restx import Resource from flask_restx import Resource
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import ( from controllers.console.app.error import (
CompletionRequestError, CompletionRequestError,
@ -19,13 +20,12 @@ from core.helper.code_executor.python3.python3_code_provider import Python3CodeP
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator from core.llm_generator.llm_generator import LLMGenerator
from extensions.ext_database import db from extensions.ext_database import db
from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.errors.invoke import InvokeError from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import App from models import App
from services.workflow_service import WorkflowService from services.workflow_service import WorkflowService
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class InstructionGeneratePayload(BaseModel): class InstructionGeneratePayload(BaseModel):
flow_id: str = Field(..., description="Workflow/Flow ID") flow_id: str = Field(..., description="Workflow/Flow ID")
@ -41,16 +41,16 @@ class InstructionTemplatePayload(BaseModel):
type: str = Field(..., description="Instruction template type") type: str = Field(..., description="Instruction template type")
def reg(cls: type[BaseModel]): register_enum_models(console_ns, LLMMode)
console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) register_schema_models(
console_ns,
RuleGeneratePayload,
reg(RuleGeneratePayload) RuleCodeGeneratePayload,
reg(RuleCodeGeneratePayload) RuleStructuredOutputPayload,
reg(RuleStructuredOutputPayload) InstructionGeneratePayload,
reg(InstructionGeneratePayload) InstructionTemplatePayload,
reg(InstructionTemplatePayload) ModelConfig,
reg(ModelConfig) )
@console_ns.route("/rule-generate") @console_ns.route("/rule-generate")

View File

@ -13,6 +13,7 @@ from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models.enums import AppMCPServerStatus from models.enums import AppMCPServerStatus
from models.model import AppMCPServer from models.model import AppMCPServer
@ -30,12 +31,6 @@ class MCPServerUpdatePayload(BaseModel):
status: str | None = Field(default=None, description="Server status") status: str | None = Field(default=None, description="Server status")
def _to_timestamp(value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class AppMCPServerResponse(ResponseModel): class AppMCPServerResponse(ResponseModel):
id: str id: str
name: str name: str
@ -59,7 +54,7 @@ class AppMCPServerResponse(ResponseModel):
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value) return to_timestamp(value)
register_schema_models(console_ns, MCPServerCreatePayload, MCPServerUpdatePayload, AppMCPServerResponse) register_schema_models(console_ns, MCPServerCreatePayload, MCPServerUpdatePayload, AppMCPServerResponse)

View File

@ -9,7 +9,8 @@ from sqlalchemy import exists, func, select
from werkzeug.exceptions import InternalServerError, NotFound from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.schema import register_schema_models from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import ( from controllers.console.app.error import (
CompletionRequestError, CompletionRequestError,
@ -37,10 +38,9 @@ from fields.conversation_fields import (
JSONValue, JSONValue,
MessageFile, MessageFile,
format_files_contained, format_files_contained,
to_timestamp,
) )
from graphon.model_runtime.errors.invoke import InvokeError from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import uuid_value from libs.helper import to_timestamp, uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models.enums import FeedbackFromSource, FeedbackRating from models.enums import FeedbackFromSource, FeedbackRating
@ -144,9 +144,7 @@ class MessageDetailResponse(ResponseModel):
@field_validator("created_at", mode="before") @field_validator("created_at", mode="before")
@classmethod @classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None: def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime): return to_timestamp(value)
return to_timestamp(value)
return value
class MessageInfiniteScrollPaginationResponse(ResponseModel): class MessageInfiniteScrollPaginationResponse(ResponseModel):
@ -165,6 +163,7 @@ register_schema_models(
MessageDetailResponse, MessageDetailResponse,
MessageInfiniteScrollPaginationResponse, MessageInfiniteScrollPaginationResponse,
) )
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/apps/<uuid:app_id>/chat-messages") @console_ns.route("/apps/<uuid:app_id>/chat-messages")
@ -250,7 +249,7 @@ class MessageFeedbackApi(Resource):
@console_ns.doc(description="Create or update message feedback (like/dislike)") @console_ns.doc(description="Create or update message feedback (like/dislike)")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__]) @console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
@console_ns.response(200, "Feedback updated successfully") @console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "Message not found") @console_ns.response(404, "Message not found")
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@get_app_model @get_app_model

View File

@ -5,14 +5,15 @@ from flask_restx import Resource, fields
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required from controllers.console.wraps import account_initialization_required, setup_required
from libs.login import login_required from libs.login import login_required
from models import App
from services.ops_service import OpsService from services.ops_service import OpsService
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class TraceProviderQuery(BaseModel): class TraceProviderQuery(BaseModel):
tracing_provider: str = Field(..., description="Tracing provider name") tracing_provider: str = Field(..., description="Tracing provider name")
@ -23,13 +24,7 @@ class TraceConfigPayload(BaseModel):
tracing_config: dict[str, Any] = Field(..., description="Tracing configuration data") tracing_config: dict[str, Any] = Field(..., description="Tracing configuration data")
console_ns.schema_model( register_schema_models(console_ns, TraceProviderQuery, TraceConfigPayload)
TraceProviderQuery.__name__,
TraceProviderQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
TraceConfigPayload.__name__, TraceConfigPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
)
@console_ns.route("/apps/<uuid:app_id>/trace-config") @console_ns.route("/apps/<uuid:app_id>/trace-config")
@ -49,11 +44,14 @@ class TraceAppConfigApi(Resource):
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
def get(self, app_id): @get_app_model
def get(self, app_model: App):
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
try: try:
trace_config = OpsService.get_tracing_app_config(app_id=app_id, tracing_provider=args.tracing_provider) trace_config = OpsService.get_tracing_app_config(
app_id=app_model.id, tracing_provider=args.tracing_provider
)
if not trace_config: if not trace_config:
return {"has_not_configured": True} return {"has_not_configured": True}
return trace_config return trace_config
@ -71,13 +69,14 @@ class TraceAppConfigApi(Resource):
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
def post(self, app_id): @get_app_model
def post(self, app_model: App):
"""Create a new trace app configuration""" """Create a new trace app configuration"""
args = TraceConfigPayload.model_validate(console_ns.payload) args = TraceConfigPayload.model_validate(console_ns.payload)
try: try:
result = OpsService.create_tracing_app_config( result = OpsService.create_tracing_app_config(
app_id=app_id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
) )
if not result: if not result:
raise TracingConfigIsExist() raise TracingConfigIsExist()
@ -96,13 +95,14 @@ class TraceAppConfigApi(Resource):
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
def patch(self, app_id): @get_app_model
def patch(self, app_model: App):
"""Update an existing trace app configuration""" """Update an existing trace app configuration"""
args = TraceConfigPayload.model_validate(console_ns.payload) args = TraceConfigPayload.model_validate(console_ns.payload)
try: try:
result = OpsService.update_tracing_app_config( result = OpsService.update_tracing_app_config(
app_id=app_id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
) )
if not result: if not result:
raise TracingConfigNotExist() raise TracingConfigNotExist()
@ -119,14 +119,15 @@ class TraceAppConfigApi(Resource):
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
def delete(self, app_id): @get_app_model
def delete(self, app_model: App):
"""Delete an existing trace app configuration""" """Delete an existing trace app configuration"""
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
try: try:
result = OpsService.delete_tracing_app_config(app_id=app_id, tracing_provider=args.tracing_provider) result = OpsService.delete_tracing_app_config(app_id=app_model.id, tracing_provider=args.tracing_provider)
if not result: if not result:
raise TracingConfigNotExist() raise TracingConfigNotExist()
return {"result": "success"}, 204 return "", 204
except Exception as e: except Exception as e:
raise BadRequest(str(e)) raise BadRequest(str(e))

View File

@ -5,6 +5,7 @@ from flask import abort, jsonify, request
from flask_restx import Resource, fields from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required from controllers.console.wraps import account_initialization_required, setup_required
@ -15,8 +16,6 @@ from libs.helper import convert_datetime_to_date
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import AppMode from models import AppMode
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class StatisticTimeRangeQuery(BaseModel): class StatisticTimeRangeQuery(BaseModel):
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
@ -30,10 +29,7 @@ class StatisticTimeRangeQuery(BaseModel):
return value return value
console_ns.schema_model( register_schema_models(console_ns, StatisticTimeRangeQuery)
StatisticTimeRangeQuery.__name__,
StatisticTimeRangeQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
@console_ns.route("/apps/<uuid:app_id>/statistics/daily-messages") @console_ns.route("/apps/<uuid:app_id>/statistics/daily-messages")
@ -54,7 +50,7 @@ class DailyMessageStatistic(Resource):
def get(self, app_model): def get(self, app_model):
account, _ = current_account_with_tenant() account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
converted_created_at = convert_datetime_to_date("created_at") converted_created_at = convert_datetime_to_date("created_at")
sql_query = f"""SELECT sql_query = f"""SELECT
@ -111,7 +107,7 @@ class DailyConversationStatistic(Resource):
def get(self, app_model): def get(self, app_model):
account, _ = current_account_with_tenant() account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
converted_created_at = convert_datetime_to_date("created_at") converted_created_at = convert_datetime_to_date("created_at")
sql_query = f"""SELECT sql_query = f"""SELECT
@ -167,7 +163,7 @@ class DailyTerminalsStatistic(Resource):
def get(self, app_model): def get(self, app_model):
account, _ = current_account_with_tenant() account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
converted_created_at = convert_datetime_to_date("created_at") converted_created_at = convert_datetime_to_date("created_at")
sql_query = f"""SELECT sql_query = f"""SELECT
@ -224,7 +220,7 @@ class DailyTokenCostStatistic(Resource):
def get(self, app_model): def get(self, app_model):
account, _ = current_account_with_tenant() account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
converted_created_at = convert_datetime_to_date("created_at") converted_created_at = convert_datetime_to_date("created_at")
sql_query = f"""SELECT sql_query = f"""SELECT
@ -284,7 +280,7 @@ class AverageSessionInteractionStatistic(Resource):
def get(self, app_model): def get(self, app_model):
account, _ = current_account_with_tenant() account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
converted_created_at = convert_datetime_to_date("c.created_at") converted_created_at = convert_datetime_to_date("c.created_at")
sql_query = f"""SELECT sql_query = f"""SELECT
@ -360,7 +356,7 @@ class UserSatisfactionRateStatistic(Resource):
def get(self, app_model): def get(self, app_model):
account, _ = current_account_with_tenant() account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
converted_created_at = convert_datetime_to_date("m.created_at") converted_created_at = convert_datetime_to_date("m.created_at")
sql_query = f"""SELECT sql_query = f"""SELECT
@ -426,7 +422,7 @@ class AverageResponseTimeStatistic(Resource):
def get(self, app_model): def get(self, app_model):
account, _ = current_account_with_tenant() account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
converted_created_at = convert_datetime_to_date("created_at") converted_created_at = convert_datetime_to_date("created_at")
sql_query = f"""SELECT sql_query = f"""SELECT
@ -482,7 +478,7 @@ class TokensPerSecondStatistic(Resource):
@account_initialization_required @account_initialization_required
def get(self, app_model): def get(self, app_model):
account, _ = current_account_with_tenant() account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
converted_created_at = convert_datetime_to_date("created_at") converted_created_at = convert_datetime_to_date("created_at")
sql_query = f"""SELECT sql_query = f"""SELECT

View File

@ -1,19 +1,25 @@
import json import json
import logging import logging
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from datetime import datetime
from typing import Any, NotRequired, TypedDict
from flask import abort, request from flask import abort, request
from flask_restx import Resource, fields, marshal, marshal_with from flask_restx import Resource, fields
from pydantic import BaseModel, Field, ValidationError, field_validator from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services import services
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.fields import NewAppResponse, SimpleResultResponse
from controllers.common.schema import (
register_response_schema_model,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.workflow_run import workflow_run_node_execution_model
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
@ -22,6 +28,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.file_access import DatabaseFileAccessController from core.app.file_access import DatabaseFileAccessController
from core.helper import encrypter
from core.helper.trace_id_helper import get_external_trace_id from core.helper.trace_id_helper import get_external_trace_id
from core.plugin.impl.exc import PluginInvokeError from core.plugin.impl.exc import PluginInvokeError
from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE
@ -34,17 +41,18 @@ from core.trigger.debug.event_selectors import (
from extensions.ext_database import db from extensions.ext_database import db
from extensions.ext_redis import redis_client from extensions.ext_redis import redis_client
from factories import file_factory, variable_factory from factories import file_factory, variable_factory
from fields.member_fields import simple_account_fields from fields.base import ResponseModel
from fields.online_user_fields import online_user_list_fields from fields.member_fields import SimpleAccount
from fields.workflow_fields import workflow_fields, workflow_pagination_fields from fields.workflow_run_fields import WorkflowRunNodeExecutionResponse
from graphon.enums import NodeType from graphon.enums import NodeType
from graphon.file import File from graphon.file import File
from graphon.file import helpers as file_helpers from graphon.file import helpers as file_helpers
from graphon.graph_engine.manager import GraphEngineManager from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.utils.encoders import jsonable_encoder from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.variables import SecretVariable, SegmentType, VariableBase
from libs import helper from libs import helper
from libs.datetime_utils import naive_utc_now from libs.datetime_utils import naive_utc_now
from libs.helper import TimestampField, uuid_value from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import App from models import App
from models.model import AppMode from models.model import AppMode
@ -56,48 +64,22 @@ from services.errors.llm import InvokeRateLimitError
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_file_access_controller = DatabaseFileAccessController() _file_access_controller = DatabaseFileAccessController()
LISTENING_RETRY_IN = 2000 LISTENING_RETRY_IN = 2000
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published" RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published"
MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS = 1000 MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS = 1000
WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE = 50 WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE = 50
ENVIRONMENT_VARIABLE_SUPPORTED_TYPES = (SegmentType.STRING, SegmentType.NUMBER, SegmentType.SECRET)
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
# Base models class EnvironmentVariableResponseDict(TypedDict):
simple_account_model = console_ns.model("SimpleAccount", simple_account_fields) value_type: str
id: NotRequired[str]
from fields.workflow_fields import pipeline_variable_fields, serialize_value_type name: NotRequired[str]
value: NotRequired[Any]
conversation_variable_model = console_ns.model( description: NotRequired[str | None]
"ConversationVariable",
{
"id": fields.String,
"name": fields.String,
"value_type": fields.String(attribute=serialize_value_type),
"value": fields.Raw,
"description": fields.String,
},
)
pipeline_variable_model = console_ns.model("PipelineVariable", pipeline_variable_fields)
# Workflow model with nested dependencies
workflow_fields_copy = workflow_fields.copy()
workflow_fields_copy["created_by"] = fields.Nested(simple_account_model, attribute="created_by_account")
workflow_fields_copy["updated_by"] = fields.Nested(
simple_account_model, attribute="updated_by_account", allow_null=True
)
workflow_fields_copy["conversation_variables"] = fields.List(fields.Nested(conversation_variable_model))
workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipeline_variable_model))
workflow_model = console_ns.model("Workflow", workflow_fields_copy)
# Workflow pagination model
workflow_pagination_fields_copy = workflow_pagination_fields.copy()
workflow_pagination_fields_copy["items"] = fields.List(fields.Nested(workflow_model), attribute="items")
workflow_pagination_model = console_ns.model("WorkflowPagination", workflow_pagination_fields_copy)
class SyncDraftWorkflowPayload(BaseModel): class SyncDraftWorkflowPayload(BaseModel):
@ -168,6 +150,110 @@ class WorkflowOnlineUsersPayload(BaseModel):
return list(dict.fromkeys(app_id.strip() for app_id in app_ids if app_id.strip())) return list(dict.fromkeys(app_id.strip() for app_id in app_ids if app_id.strip()))
class WorkflowConversationVariableResponse(ResponseModel):
id: str
name: str
value_type: str
value: Any = Field(json_schema_extra={"type": "object"})
description: str
@field_validator("value_type", mode="before")
@classmethod
def _serialize_value_type(cls, value: Any) -> str:
if hasattr(value, "exposed_type"):
return str(value.exposed_type())
return str(value)
class PipelineVariableResponse(ResponseModel):
label: str
variable: str
type: str
belong_to_node_id: str
max_length: int | None = None
required: bool
unit: str | None = None
default_value: Any = Field(default=None, json_schema_extra={"type": "object"})
options: list[str] | None = None
placeholder: str | None = None
tooltips: str | None = None
allowed_file_types: list[str] | None = None
allowed_file_extensions: list[str] | None = Field(
default=None, validation_alias=AliasChoices("allowed_file_extensions", "allow_file_extension")
)
allowed_file_upload_methods: list[str] | None = Field(
default=None, validation_alias=AliasChoices("allowed_file_upload_methods", "allow_file_upload_methods")
)
class WorkflowEnvironmentVariableResponse(ResponseModel):
value_type: str
id: str
name: str
value: Any = Field(json_schema_extra={"type": "object"})
description: str
class WorkflowResponse(ResponseModel):
id: str
graph: dict[str, Any] = Field(validation_alias=AliasChoices("graph_dict", "graph"))
features: dict[str, Any] = Field(validation_alias=AliasChoices("features_dict", "features"))
hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash"))
version: str
marked_name: str
marked_comment: str
created_by: SimpleAccount | None = Field(
default=None, validation_alias=AliasChoices("created_by_account", "created_by")
)
created_at: int
updated_by: SimpleAccount | None = Field(
default=None, validation_alias=AliasChoices("updated_by_account", "updated_by")
)
updated_at: int
tool_published: bool
environment_variables: list[WorkflowEnvironmentVariableResponse]
conversation_variables: list[WorkflowConversationVariableResponse]
rag_pipeline_variables: list[PipelineVariableResponse]
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int:
timestamp = to_timestamp(value)
if timestamp is None:
raise ValueError("timestamp is required")
return timestamp
@field_validator("environment_variables", mode="before")
@classmethod
def _serialize_environment_variables(cls, value: Any) -> list[Any]:
if value is None:
return []
return [_serialize_environment_variable(item) for item in value]
class WorkflowPaginationResponse(ResponseModel):
items: list[WorkflowResponse]
page: int
limit: int
has_more: bool
class WorkflowOnlineUser(ResponseModel):
user_id: str
username: str
avatar: str | None = None
class WorkflowOnlineUsersByApp(ResponseModel):
app_id: str
users: list[WorkflowOnlineUser]
class WorkflowOnlineUsersResponse(ResponseModel):
data: list[WorkflowOnlineUsersByApp]
class DraftWorkflowTriggerRunPayload(BaseModel): class DraftWorkflowTriggerRunPayload(BaseModel):
node_id: str node_id: str
@ -176,25 +262,38 @@ class DraftWorkflowTriggerRunAllPayload(BaseModel):
node_ids: list[str] node_ids: list[str]
def reg(cls: type[BaseModel]): register_schema_models(
console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) console_ns,
SyncDraftWorkflowPayload,
AdvancedChatWorkflowRunPayload,
reg(SyncDraftWorkflowPayload) IterationNodeRunPayload,
reg(AdvancedChatWorkflowRunPayload) LoopNodeRunPayload,
reg(IterationNodeRunPayload) DraftWorkflowRunPayload,
reg(LoopNodeRunPayload) DraftWorkflowNodeRunPayload,
reg(DraftWorkflowRunPayload) PublishWorkflowPayload,
reg(DraftWorkflowNodeRunPayload) DefaultBlockConfigQuery,
reg(PublishWorkflowPayload) ConvertToWorkflowPayload,
reg(DefaultBlockConfigQuery) WorkflowListQuery,
reg(ConvertToWorkflowPayload) WorkflowUpdatePayload,
reg(WorkflowListQuery) WorkflowFeaturesPayload,
reg(WorkflowUpdatePayload) WorkflowOnlineUsersPayload,
reg(WorkflowFeaturesPayload) DraftWorkflowTriggerRunPayload,
reg(WorkflowOnlineUsersPayload) DraftWorkflowTriggerRunAllPayload,
reg(DraftWorkflowTriggerRunPayload) )
reg(DraftWorkflowTriggerRunAllPayload) register_response_schema_model(console_ns, WorkflowRunNodeExecutionResponse)
register_response_schema_models(
console_ns,
WorkflowConversationVariableResponse,
PipelineVariableResponse,
WorkflowEnvironmentVariableResponse,
WorkflowResponse,
WorkflowPaginationResponse,
WorkflowOnlineUser,
WorkflowOnlineUsersByApp,
WorkflowOnlineUsersResponse,
NewAppResponse,
SimpleResultResponse,
)
# TODO(QuantumGhost): Refactor existing node run API to handle file parameter parsing # TODO(QuantumGhost): Refactor existing node run API to handle file parameter parsing
@ -216,18 +315,56 @@ def _parse_file(workflow: Workflow, files: list[dict] | None = None) -> Sequence
return file_objs return file_objs
def _serialize_environment_variable(value: Any) -> EnvironmentVariableResponseDict | Any:
match value:
case SecretVariable():
return {
"id": value.id,
"name": value.name,
"value": encrypter.full_mask_token(),
"value_type": value.value_type.value,
"description": value.description,
}
case VariableBase():
return {
"id": value.id,
"name": value.name,
"value": value.value,
"value_type": str(value.value_type.exposed_type()),
"description": value.description,
}
case dict():
value_type_str = value.get("value_type")
if not isinstance(value_type_str, str):
raise TypeError(
f"unexpected type for value_type field, value={value_type_str}, type={type(value_type_str)}"
)
value_type = SegmentType(value_type_str).exposed_type()
if value_type not in ENVIRONMENT_VARIABLE_SUPPORTED_TYPES:
raise ValueError(f"Unsupported environment variable value type: {value_type}")
return value
case _:
return value
@console_ns.route("/apps/<uuid:app_id>/workflows/draft") @console_ns.route("/apps/<uuid:app_id>/workflows/draft")
class DraftWorkflowApi(Resource): class DraftWorkflowApi(Resource):
@console_ns.doc("get_draft_workflow") @console_ns.doc("get_draft_workflow")
@console_ns.doc(description="Get draft workflow for an application") @console_ns.doc(description="Get draft workflow for an application")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Draft workflow retrieved successfully", workflow_model) @console_ns.response(
200,
"Draft workflow retrieved successfully",
console_ns.models[WorkflowResponse.__name__],
)
@console_ns.response(404, "Draft workflow not found") @console_ns.response(404, "Draft workflow not found")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_model)
@edit_permission_required @edit_permission_required
def get(self, app_model: App): def get(self, app_model: App):
""" """
@ -240,8 +377,8 @@ class DraftWorkflowApi(Resource):
if not workflow: if not workflow:
raise DraftWorkflowNotExist() raise DraftWorkflowNotExist()
# return workflow, if not found, return None (initiate graph by frontend) # return workflow, if not found, return 404
return workflow return dump_response(WorkflowResponse, workflow)
@setup_required @setup_required
@login_required @login_required
@ -540,9 +677,12 @@ class HumanInputDeliveryTestPayload(BaseModel):
) )
reg(HumanInputFormPreviewPayload) register_schema_models(
reg(HumanInputFormSubmitPayload) console_ns,
reg(HumanInputDeliveryTestPayload) HumanInputFormPreviewPayload,
HumanInputFormSubmitPayload,
HumanInputDeliveryTestPayload,
)
@console_ns.route("/apps/<uuid:app_id>/advanced-chat/workflows/draft/human-input/nodes/<string:node_id>/form/preview") @console_ns.route("/apps/<uuid:app_id>/advanced-chat/workflows/draft/human-input/nodes/<string:node_id>/form/preview")
@ -732,7 +872,7 @@ class WorkflowTaskStopApi(Resource):
@console_ns.doc("stop_workflow_task") @console_ns.doc("stop_workflow_task")
@console_ns.doc(description="Stop running workflow task") @console_ns.doc(description="Stop running workflow task")
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID"}) @console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID"})
@console_ns.response(200, "Task stopped successfully") @console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "Task not found") @console_ns.response(404, "Task not found")
@console_ns.response(403, "Permission denied") @console_ns.response(403, "Permission denied")
@setup_required @setup_required
@ -760,14 +900,17 @@ class DraftWorkflowNodeRunApi(Resource):
@console_ns.doc(description="Run draft workflow node") @console_ns.doc(description="Run draft workflow node")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.expect(console_ns.models[DraftWorkflowNodeRunPayload.__name__]) @console_ns.expect(console_ns.models[DraftWorkflowNodeRunPayload.__name__])
@console_ns.response(200, "Node run started successfully", workflow_run_node_execution_model) @console_ns.response(
200,
"Node run started successfully",
console_ns.models[WorkflowRunNodeExecutionResponse.__name__],
)
@console_ns.response(403, "Permission denied") @console_ns.response(403, "Permission denied")
@console_ns.response(404, "Node not found") @console_ns.response(404, "Node not found")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_run_node_execution_model)
@edit_permission_required @edit_permission_required
def post(self, app_model: App, node_id: str): def post(self, app_model: App, node_id: str):
""" """
@ -799,7 +942,9 @@ class DraftWorkflowNodeRunApi(Resource):
files=files, files=files,
) )
return workflow_node_execution return WorkflowRunNodeExecutionResponse.model_validate(
workflow_node_execution, from_attributes=True
).model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/workflows/publish") @console_ns.route("/apps/<uuid:app_id>/workflows/publish")
@ -807,13 +952,15 @@ class PublishedWorkflowApi(Resource):
@console_ns.doc("get_published_workflow") @console_ns.doc("get_published_workflow")
@console_ns.doc(description="Get published workflow for an application") @console_ns.doc(description="Get published workflow for an application")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Published workflow retrieved successfully", workflow_model) @console_ns.response(
@console_ns.response(404, "Published workflow not found") 200,
"Published workflow retrieved successfully, or null if not found",
console_ns.models[WorkflowResponse.__name__],
)
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_model)
@edit_permission_required @edit_permission_required
def get(self, app_model: App): def get(self, app_model: App):
""" """
@ -824,7 +971,10 @@ class PublishedWorkflowApi(Resource):
workflow = workflow_service.get_published_workflow(app_model=app_model) workflow = workflow_service.get_published_workflow(app_model=app_model)
# return workflow, if not found, return None # return workflow, if not found, return None
return workflow if workflow is None:
return None
return dump_response(WorkflowResponse, workflow)
@console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__]) @console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__])
@setup_required @setup_required
@ -902,7 +1052,7 @@ class DefaultBlockConfigApi(Resource):
""" """
Get default block config Get default block config
""" """
args = DefaultBlockConfigQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = DefaultBlockConfigQuery.model_validate(request.args.to_dict(flat=True))
filters = None filters = None
if args.q: if args.q:
@ -922,7 +1072,11 @@ class ConvertToWorkflowApi(Resource):
@console_ns.doc("convert_to_workflow") @console_ns.doc("convert_to_workflow")
@console_ns.doc(description="Convert application to workflow mode") @console_ns.doc(description="Convert application to workflow mode")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Application converted to workflow successfully") @console_ns.response(
200,
"Application converted to workflow successfully",
console_ns.models[NewAppResponse.__name__],
)
@console_ns.response(400, "Application cannot be converted") @console_ns.response(400, "Application cannot be converted")
@console_ns.response(403, "Permission denied") @console_ns.response(403, "Permission denied")
@setup_required @setup_required
@ -959,7 +1113,11 @@ class WorkflowFeaturesApi(Resource):
@console_ns.doc("update_workflow_features") @console_ns.doc("update_workflow_features")
@console_ns.doc(description="Update draft workflow features") @console_ns.doc(description="Update draft workflow features")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Workflow features updated successfully") @console_ns.response(
200,
"Workflow features updated successfully",
console_ns.models[SimpleResultResponse.__name__],
)
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@ -983,7 +1141,11 @@ class PublishedAllWorkflowApi(Resource):
@console_ns.doc("get_all_published_workflows") @console_ns.doc("get_all_published_workflows")
@console_ns.doc(description="Get all published workflows for an application") @console_ns.doc(description="Get all published workflows for an application")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Published workflows retrieved successfully", workflow_pagination_model) @console_ns.response(
200,
"Published workflows retrieved successfully",
console_ns.models[WorkflowPaginationResponse.__name__],
)
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@ -995,7 +1157,7 @@ class PublishedAllWorkflowApi(Resource):
""" """
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True))
page = args.page page = args.page
limit = args.limit limit = args.limit
user_id = args.user_id user_id = args.user_id
@ -1015,14 +1177,14 @@ class PublishedAllWorkflowApi(Resource):
user_id=user_id, user_id=user_id,
named_only=named_only, named_only=named_only,
) )
serialized_workflows = marshal(workflows, workflow_fields_copy) return WorkflowPaginationResponse.model_validate(
{
return { "items": workflows,
"items": serialized_workflows, "page": page,
"page": page, "limit": limit,
"limit": limit, "has_more": has_more,
"has_more": has_more, }
} ).model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/workflows/<string:workflow_id>/restore") @console_ns.route("/apps/<uuid:app_id>/workflows/<string:workflow_id>/restore")
@ -1068,14 +1230,13 @@ class WorkflowByIdApi(Resource):
@console_ns.doc(description="Update workflow by ID") @console_ns.doc(description="Update workflow by ID")
@console_ns.doc(params={"app_id": "Application ID", "workflow_id": "Workflow ID"}) @console_ns.doc(params={"app_id": "Application ID", "workflow_id": "Workflow ID"})
@console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__]) @console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__])
@console_ns.response(200, "Workflow updated successfully", workflow_model) @console_ns.response(200, "Workflow updated successfully", console_ns.models[WorkflowResponse.__name__])
@console_ns.response(404, "Workflow not found") @console_ns.response(404, "Workflow not found")
@console_ns.response(403, "Permission denied") @console_ns.response(403, "Permission denied")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_model)
@edit_permission_required @edit_permission_required
def patch(self, app_model: App, workflow_id: str): def patch(self, app_model: App, workflow_id: str):
""" """
@ -1109,7 +1270,7 @@ class WorkflowByIdApi(Resource):
if not workflow: if not workflow:
raise NotFound("Workflow not found") raise NotFound("Workflow not found")
return workflow return dump_response(WorkflowResponse, workflow)
@setup_required @setup_required
@login_required @login_required
@ -1143,14 +1304,17 @@ class DraftWorkflowNodeLastRunApi(Resource):
@console_ns.doc("get_draft_workflow_node_last_run") @console_ns.doc("get_draft_workflow_node_last_run")
@console_ns.doc(description="Get last run result for draft workflow node") @console_ns.doc(description="Get last run result for draft workflow node")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.response(200, "Node last run retrieved successfully", workflow_run_node_execution_model) @console_ns.response(
200,
"Node last run retrieved successfully",
console_ns.models[WorkflowRunNodeExecutionResponse.__name__],
)
@console_ns.response(404, "Node last run not found") @console_ns.response(404, "Node last run not found")
@console_ns.response(403, "Permission denied") @console_ns.response(403, "Permission denied")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_run_node_execution_model)
def get(self, app_model: App, node_id: str): def get(self, app_model: App, node_id: str):
srv = WorkflowService() srv = WorkflowService()
workflow = srv.get_draft_workflow(app_model) workflow = srv.get_draft_workflow(app_model)
@ -1163,7 +1327,7 @@ class DraftWorkflowNodeLastRunApi(Resource):
) )
if node_exec is None: if node_exec is None:
raise NotFound("last run not found") raise NotFound("last run not found")
return node_exec return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/trigger/run") @console_ns.route("/apps/<uuid:app_id>/workflows/draft/trigger/run")
@ -1391,12 +1555,16 @@ class DraftWorkflowTriggerRunAllApi(Resource):
@console_ns.route("/apps/workflows/online-users") @console_ns.route("/apps/workflows/online-users")
class WorkflowOnlineUsersApi(Resource): class WorkflowOnlineUsersApi(Resource):
@console_ns.expect(console_ns.models[WorkflowOnlineUsersPayload.__name__]) @console_ns.expect(console_ns.models[WorkflowOnlineUsersPayload.__name__])
@console_ns.response(
200,
"Workflow online users retrieved successfully",
console_ns.models[WorkflowOnlineUsersResponse.__name__],
)
@console_ns.doc("get_workflow_online_users") @console_ns.doc("get_workflow_online_users")
@console_ns.doc(description="Get workflow online users") @console_ns.doc(description="Get workflow online users")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@marshal_with(online_user_list_fields)
def post(self): def post(self):
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {}) args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
@ -1439,10 +1607,18 @@ class WorkflowOnlineUsersApi(Resource):
if not isinstance(user_info, dict): if not isinstance(user_info, dict):
continue continue
user_id = user_info.get("user_id")
username = user_info.get("username")
if not isinstance(user_id, str) or not isinstance(username, str):
continue
avatar = user_info.get("avatar") avatar = user_info.get("avatar")
if avatar is not None and not isinstance(avatar, str):
avatar = None
if isinstance(avatar, str) and avatar and not avatar.startswith(("http://", "https://")): if isinstance(avatar, str) and avatar and not avatar.startswith(("http://", "https://")):
try: try:
user_info["avatar"] = file_helpers.get_signed_file_url(avatar) avatar = file_helpers.get_signed_file_url(avatar)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"Failed to sign workflow online user avatar; using original value. " "Failed to sign workflow online user avatar; using original value. "
@ -1452,7 +1628,7 @@ class WorkflowOnlineUsersApi(Resource):
exc, exc,
) )
users.append(user_info) users.append({"user_id": user_id, "username": username, "avatar": avatar})
results.append({"app_id": app_id, "users": users}) results.append({"app_id": app_id, "users": users})
return {"data": results} return WorkflowOnlineUsersResponse.model_validate({"data": results}).model_dump(mode="json")

View File

@ -16,6 +16,7 @@ from fields.base import ResponseModel
from fields.end_user_fields import SimpleEndUser from fields.end_user_fields import SimpleEndUser
from fields.member_fields import SimpleAccount from fields.member_fields import SimpleAccount
from graphon.enums import WorkflowExecutionStatus from graphon.enums import WorkflowExecutionStatus
from libs.helper import to_timestamp
from libs.login import login_required from libs.login import login_required
from models import App from models import App
from models.model import AppMode from models.model import AppMode
@ -82,9 +83,7 @@ class WorkflowRunForLogResponse(ResponseModel):
@field_validator("created_at", "finished_at", mode="before") @field_validator("created_at", "finished_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime): return to_timestamp(value)
return int(value.timestamp())
return value
class WorkflowRunForArchivedLogResponse(ResponseModel): class WorkflowRunForArchivedLogResponse(ResponseModel):
@ -117,9 +116,7 @@ class WorkflowAppLogPartialResponse(ResponseModel):
@field_validator("created_at", mode="before") @field_validator("created_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime): return to_timestamp(value)
return int(value.timestamp())
return value
class WorkflowArchivedLogPartialResponse(ResponseModel): class WorkflowArchivedLogPartialResponse(ResponseModel):
@ -133,9 +130,7 @@ class WorkflowArchivedLogPartialResponse(ResponseModel):
@field_validator("created_at", mode="before") @field_validator("created_at", mode="before")
@classmethod @classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime): return to_timestamp(value)
return int(value.timestamp())
return value
class WorkflowAppLogPaginationResponse(ResponseModel): class WorkflowAppLogPaginationResponse(ResponseModel):
@ -185,7 +180,7 @@ class WorkflowAppLogApi(Resource):
""" """
Get workflow app logs Get workflow app logs
""" """
args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True))
# get paginate workflow app logs # get paginate workflow app logs
workflow_app_service = WorkflowAppService() workflow_app_service = WorkflowAppService()
@ -228,7 +223,7 @@ class WorkflowArchivedLogApi(Resource):
""" """
Get workflow archived logs Get workflow archived logs
""" """
args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True))
workflow_app_service = WorkflowAppService() workflow_app_service = WorkflowAppService()
with sessionmaker(db.engine, expire_on_commit=False).begin() as session: with sessionmaker(db.engine, expire_on_commit=False).begin() as session:

View File

@ -1,29 +1,22 @@
import logging import logging
from datetime import datetime
from flask_restx import Resource, marshal_with from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter from pydantic import BaseModel, Field, TypeAdapter, computed_field, field_validator
from controllers.common.schema import register_schema_models from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from fields.base import ResponseModel
from fields.member_fields import AccountWithRole from fields.member_fields import AccountWithRole
from fields.workflow_comment_fields import ( from libs.helper import build_avatar_url, dump_response, to_timestamp
workflow_comment_basic_fields,
workflow_comment_create_fields,
workflow_comment_detail_fields,
workflow_comment_reply_create_fields,
workflow_comment_reply_update_fields,
workflow_comment_resolve_fields,
workflow_comment_update_fields,
)
from libs.login import current_user, login_required from libs.login import current_user, login_required
from models import App from models import App
from services.account_service import TenantService from services.account_service import TenantService
from services.workflow_comment_service import WorkflowCommentService from services.workflow_comment_service import WorkflowCommentService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class WorkflowCommentCreatePayload(BaseModel): class WorkflowCommentCreatePayload(BaseModel):
@ -52,24 +45,159 @@ class WorkflowCommentMentionUsersPayload(BaseModel):
users: list[AccountWithRole] users: list[AccountWithRole]
for model in ( class WorkflowCommentAccount(ResponseModel):
id: str
name: str
email: str
avatar: str | None = Field(default=None, exclude=True)
@computed_field(return_type=str | None) # type: ignore[prop-decorator]
@property
def avatar_url(self) -> str | None:
return build_avatar_url(self.avatar)
class WorkflowCommentReply(ResponseModel):
id: str
content: str
created_by: str
created_by_account: WorkflowCommentAccount | None = None
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class WorkflowCommentMention(ResponseModel):
mentioned_user_id: str
mentioned_user_account: WorkflowCommentAccount | None = None
reply_id: str | None = None
class WorkflowCommentBasic(ResponseModel):
id: str
position_x: float
position_y: float
content: str
created_by: str
created_by_account: WorkflowCommentAccount | None = None
created_at: int | None = None
updated_at: int | None = None
resolved: bool
resolved_at: int | None = None
resolved_by: str | None = None
resolved_by_account: WorkflowCommentAccount | None = None
reply_count: int
mention_count: int
participants: list[WorkflowCommentAccount]
@field_validator("created_at", "updated_at", "resolved_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class WorkflowCommentBasicList(ResponseModel):
data: list[WorkflowCommentBasic]
class WorkflowCommentDetail(ResponseModel):
id: str
position_x: float
position_y: float
content: str
created_by: str
created_by_account: WorkflowCommentAccount | None = None
created_at: int | None = None
updated_at: int | None = None
resolved: bool
resolved_at: int | None = None
resolved_by: str | None = None
resolved_by_account: WorkflowCommentAccount | None = None
replies: list[WorkflowCommentReply]
mentions: list[WorkflowCommentMention]
@field_validator("created_at", "updated_at", "resolved_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class WorkflowCommentCreate(ResponseModel):
id: str
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class WorkflowCommentUpdate(ResponseModel):
id: str
updated_at: int | None = None
@field_validator("updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class WorkflowCommentResolve(ResponseModel):
id: str
resolved: bool
resolved_at: int | None = None
resolved_by: str | None = None
@field_validator("resolved_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class WorkflowCommentReplyCreate(ResponseModel):
id: str
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class WorkflowCommentReplyUpdate(ResponseModel):
id: str
updated_at: int | None = None
@field_validator("updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
register_schema_models(
console_ns,
AccountWithRole,
WorkflowCommentMentionUsersPayload,
WorkflowCommentCreatePayload, WorkflowCommentCreatePayload,
WorkflowCommentUpdatePayload, WorkflowCommentUpdatePayload,
WorkflowCommentReplyPayload, WorkflowCommentReplyPayload,
):
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
register_schema_models(console_ns, AccountWithRole, WorkflowCommentMentionUsersPayload)
workflow_comment_basic_model = console_ns.model("WorkflowCommentBasic", workflow_comment_basic_fields)
workflow_comment_detail_model = console_ns.model("WorkflowCommentDetail", workflow_comment_detail_fields)
workflow_comment_create_model = console_ns.model("WorkflowCommentCreate", workflow_comment_create_fields)
workflow_comment_update_model = console_ns.model("WorkflowCommentUpdate", workflow_comment_update_fields)
workflow_comment_resolve_model = console_ns.model("WorkflowCommentResolve", workflow_comment_resolve_fields)
workflow_comment_reply_create_model = console_ns.model(
"WorkflowCommentReplyCreate", workflow_comment_reply_create_fields
) )
workflow_comment_reply_update_model = console_ns.model( register_response_schema_models(
"WorkflowCommentReplyUpdate", workflow_comment_reply_update_fields console_ns,
WorkflowCommentAccount,
WorkflowCommentReply,
WorkflowCommentMention,
WorkflowCommentBasic,
WorkflowCommentBasicList,
WorkflowCommentDetail,
WorkflowCommentCreate,
WorkflowCommentUpdate,
WorkflowCommentResolve,
WorkflowCommentReplyCreate,
WorkflowCommentReplyUpdate,
) )
@ -80,28 +208,26 @@ class WorkflowCommentListApi(Resource):
@console_ns.doc("list_workflow_comments") @console_ns.doc("list_workflow_comments")
@console_ns.doc(description="Get all comments for a workflow") @console_ns.doc(description="Get all comments for a workflow")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Comments retrieved successfully", workflow_comment_basic_model) @console_ns.response(200, "Comments retrieved successfully", console_ns.models[WorkflowCommentBasicList.__name__])
@login_required @login_required
@setup_required @setup_required
@account_initialization_required @account_initialization_required
@get_app_model() @get_app_model()
@marshal_with(workflow_comment_basic_model, envelope="data")
def get(self, app_model: App): def get(self, app_model: App):
"""Get all comments for a workflow.""" """Get all comments for a workflow."""
comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id) comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id)
return comments return WorkflowCommentBasicList.model_validate({"data": comments}).model_dump(mode="json")
@console_ns.doc("create_workflow_comment") @console_ns.doc("create_workflow_comment")
@console_ns.doc(description="Create a new workflow comment") @console_ns.doc(description="Create a new workflow comment")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[WorkflowCommentCreatePayload.__name__]) @console_ns.expect(console_ns.models[WorkflowCommentCreatePayload.__name__])
@console_ns.response(201, "Comment created successfully", workflow_comment_create_model) @console_ns.response(201, "Comment created successfully", console_ns.models[WorkflowCommentCreate.__name__])
@login_required @login_required
@setup_required @setup_required
@account_initialization_required @account_initialization_required
@get_app_model() @get_app_model()
@marshal_with(workflow_comment_create_model)
@edit_permission_required @edit_permission_required
def post(self, app_model: App): def post(self, app_model: App):
"""Create a new workflow comment.""" """Create a new workflow comment."""
@ -117,7 +243,7 @@ class WorkflowCommentListApi(Resource):
mentioned_user_ids=payload.mentioned_user_ids, mentioned_user_ids=payload.mentioned_user_ids,
) )
return result, 201 return dump_response(WorkflowCommentCreate, result), 201
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>") @console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>")
@ -127,30 +253,28 @@ class WorkflowCommentDetailApi(Resource):
@console_ns.doc("get_workflow_comment") @console_ns.doc("get_workflow_comment")
@console_ns.doc(description="Get a specific workflow comment") @console_ns.doc(description="Get a specific workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.response(200, "Comment retrieved successfully", workflow_comment_detail_model) @console_ns.response(200, "Comment retrieved successfully", console_ns.models[WorkflowCommentDetail.__name__])
@login_required @login_required
@setup_required @setup_required
@account_initialization_required @account_initialization_required
@get_app_model() @get_app_model()
@marshal_with(workflow_comment_detail_model)
def get(self, app_model: App, comment_id: str): def get(self, app_model: App, comment_id: str):
"""Get a specific workflow comment.""" """Get a specific workflow comment."""
comment = WorkflowCommentService.get_comment( comment = WorkflowCommentService.get_comment(
tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id
) )
return comment return dump_response(WorkflowCommentDetail, comment)
@console_ns.doc("update_workflow_comment") @console_ns.doc("update_workflow_comment")
@console_ns.doc(description="Update a workflow comment") @console_ns.doc(description="Update a workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.expect(console_ns.models[WorkflowCommentUpdatePayload.__name__]) @console_ns.expect(console_ns.models[WorkflowCommentUpdatePayload.__name__])
@console_ns.response(200, "Comment updated successfully", workflow_comment_update_model) @console_ns.response(200, "Comment updated successfully", console_ns.models[WorkflowCommentUpdate.__name__])
@login_required @login_required
@setup_required @setup_required
@account_initialization_required @account_initialization_required
@get_app_model() @get_app_model()
@marshal_with(workflow_comment_update_model)
@edit_permission_required @edit_permission_required
def put(self, app_model: App, comment_id: str): def put(self, app_model: App, comment_id: str):
"""Update a workflow comment.""" """Update a workflow comment."""
@ -167,7 +291,7 @@ class WorkflowCommentDetailApi(Resource):
mentioned_user_ids=payload.mentioned_user_ids, mentioned_user_ids=payload.mentioned_user_ids,
) )
return result return dump_response(WorkflowCommentUpdate, result)
@console_ns.doc("delete_workflow_comment") @console_ns.doc("delete_workflow_comment")
@console_ns.doc(description="Delete a workflow comment") @console_ns.doc(description="Delete a workflow comment")
@ -187,7 +311,7 @@ class WorkflowCommentDetailApi(Resource):
user_id=current_user.id, user_id=current_user.id,
) )
return {"result": "success"}, 204 return "", 204
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve") @console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
@ -197,12 +321,11 @@ class WorkflowCommentResolveApi(Resource):
@console_ns.doc("resolve_workflow_comment") @console_ns.doc("resolve_workflow_comment")
@console_ns.doc(description="Resolve a workflow comment") @console_ns.doc(description="Resolve a workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.response(200, "Comment resolved successfully", workflow_comment_resolve_model) @console_ns.response(200, "Comment resolved successfully", console_ns.models[WorkflowCommentResolve.__name__])
@login_required @login_required
@setup_required @setup_required
@account_initialization_required @account_initialization_required
@get_app_model() @get_app_model()
@marshal_with(workflow_comment_resolve_model)
@edit_permission_required @edit_permission_required
def post(self, app_model: App, comment_id: str): def post(self, app_model: App, comment_id: str):
"""Resolve a workflow comment.""" """Resolve a workflow comment."""
@ -213,7 +336,7 @@ class WorkflowCommentResolveApi(Resource):
user_id=current_user.id, user_id=current_user.id,
) )
return comment return dump_response(WorkflowCommentResolve, comment)
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies") @console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
@ -224,12 +347,11 @@ class WorkflowCommentReplyApi(Resource):
@console_ns.doc(description="Add a reply to a workflow comment") @console_ns.doc(description="Add a reply to a workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__]) @console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__])
@console_ns.response(201, "Reply created successfully", workflow_comment_reply_create_model) @console_ns.response(201, "Reply created successfully", console_ns.models[WorkflowCommentReplyCreate.__name__])
@login_required @login_required
@setup_required @setup_required
@account_initialization_required @account_initialization_required
@get_app_model() @get_app_model()
@marshal_with(workflow_comment_reply_create_model)
@edit_permission_required @edit_permission_required
def post(self, app_model: App, comment_id: str): def post(self, app_model: App, comment_id: str):
"""Add a reply to a workflow comment.""" """Add a reply to a workflow comment."""
@ -247,7 +369,7 @@ class WorkflowCommentReplyApi(Resource):
mentioned_user_ids=payload.mentioned_user_ids, mentioned_user_ids=payload.mentioned_user_ids,
) )
return result, 201 return dump_response(WorkflowCommentReplyCreate, result), 201
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>") @console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>")
@ -258,12 +380,11 @@ class WorkflowCommentReplyDetailApi(Resource):
@console_ns.doc(description="Update a comment reply") @console_ns.doc(description="Update a comment reply")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"}) @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"})
@console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__]) @console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__])
@console_ns.response(200, "Reply updated successfully", workflow_comment_reply_update_model) @console_ns.response(200, "Reply updated successfully", console_ns.models[WorkflowCommentReplyUpdate.__name__])
@login_required @login_required
@setup_required @setup_required
@account_initialization_required @account_initialization_required
@get_app_model() @get_app_model()
@marshal_with(workflow_comment_reply_update_model)
@edit_permission_required @edit_permission_required
def put(self, app_model: App, comment_id: str, reply_id: str): def put(self, app_model: App, comment_id: str, reply_id: str):
"""Update a comment reply.""" """Update a comment reply."""
@ -284,7 +405,7 @@ class WorkflowCommentReplyDetailApi(Resource):
mentioned_user_ids=payload.mentioned_user_ids, mentioned_user_ids=payload.mentioned_user_ids,
) )
return reply return dump_response(WorkflowCommentReplyUpdate, reply)
@console_ns.doc("delete_workflow_comment_reply") @console_ns.doc("delete_workflow_comment_reply")
@console_ns.doc(description="Delete a comment reply") @console_ns.doc(description="Delete a comment reply")
@ -310,7 +431,7 @@ class WorkflowCommentReplyDetailApi(Resource):
user_id=current_user.id, user_id=current_user.id,
) )
return {"result": "success"}, 204 return "", 204
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/mention-users") @console_ns.route("/apps/<uuid:app_id>/workflow/comments/mention-users")

View File

@ -8,6 +8,7 @@ from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import ( from controllers.console.app.error import (
DraftWorkflowNotExist, DraftWorkflowNotExist,
@ -33,7 +34,6 @@ from services.workflow_service import WorkflowService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_file_access_controller = DatabaseFileAccessController() _file_access_controller = DatabaseFileAccessController()
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class WorkflowDraftVariableListQuery(BaseModel): class WorkflowDraftVariableListQuery(BaseModel):
@ -56,21 +56,12 @@ class EnvironmentVariableUpdatePayload(BaseModel):
environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow") environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow")
console_ns.schema_model( register_schema_models(
WorkflowDraftVariableListQuery.__name__, console_ns,
WorkflowDraftVariableListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), WorkflowDraftVariableListQuery,
) WorkflowDraftVariableUpdatePayload,
console_ns.schema_model( ConversationVariableUpdatePayload,
WorkflowDraftVariableUpdatePayload.__name__, EnvironmentVariableUpdatePayload,
WorkflowDraftVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
ConversationVariableUpdatePayload.__name__,
ConversationVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
EnvironmentVariableUpdatePayload.__name__,
EnvironmentVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
) )
@ -260,7 +251,7 @@ class WorkflowVariableCollectionApi(Resource):
""" """
Get draft workflow Get draft workflow
""" """
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True))
# fetch draft workflow by app_model # fetch draft workflow by app_model
workflow_service = WorkflowService() workflow_service = WorkflowService()

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