Compare commits

...

669 Commits

Author SHA1 Message Date
twwu
8e35448ccb fix(web): clarify Builder composer disabled placeholder 2026-09-29 17:45:41 +08:00
twwu
8d3d358bab fix(web): restore Builder composer after interaction submission 2026-09-29 17:34:06 +08:00
twwu
39cbe0e627 Merge branch 'main' into feat/dify-builder 2026-09-29 11:52:13 +08:00
Stephen Zhou
be8744264f perf(api): reuse controller OpenAPI documents in schema tests (#43120) 2026-09-29 03:28:43 +00:00
Wu Tianwei
0fec636da5 fix(web): protect workspace admins in resource access (#43118) 2026-09-29 03:07:15 +00:00
Stephen Zhou
ce27ef9dc3 ci: reduce main CI orchestration scheduling overhead (#43114) 2026-09-29 03:05:39 +00:00
Stephen Zhou
f7e5946d81 ci: overlap core E2E startup with web build preparation (#43116) 2026-09-29 02:54:26 +00:00
yyh
90f7fbd468 feat(ui): redesign date and time pickers (#43090) 2026-09-29 02:14:10 +00:00
Harsh Kashyap
1545517fe8 ci(test): gate new session-mock assignments in api unit tests (#42854)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harsh@harshkashyap.dev>
2026-09-29 02:11:45 +00:00
KUNAL KAKDE
1b9ddf9ae5 docs(api): align audio-to-text supported formats in service-openapi (#41891)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-29 02:09:09 +00:00
Denis Ermakov
9bb9935f44 refactor(models): remove implicit-session Account tenant setters (#43064) 2026-09-29 02:03:34 +00:00
Denis Ermakov
1ff3d3db68 refactor(console): dep-inject the activation payload with @model_validate (#43056) 2026-09-29 02:01:34 +00:00
Denis Ermakov
18fea7ff0d chore(api): remove redundant str() on StringUUID ids (#43061) 2026-09-29 02:00:16 +00:00
Stephen Zhou
357f697b16 chore(web): upgrade vinext and Vite+ to 1.0.0 (#43110) 2026-09-29 01:53:48 +00:00
twwu
add8845cb7 fix(web): remove unused app creation translations 2026-09-28 22:24:43 +08:00
杨俊波
e6f1d77ed5 feat: tool fallback observability (#42596)
Co-authored-by: liuns-yang <liuns-yang@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-09-28 11:10:48 +00:00
Yunlu Wen
4f8bf32b72 feat: support host style in archive s3 client (#43066) 2026-09-28 11:06:38 +00:00
twwu
cf9b886f9b fix(web): localize Builder form copy and show read-only responses 2026-09-28 18:21:59 +08:00
invalid w
d8187b6e51 chore(web): resolve eslint error for node/panel components in CustomNode renderer (#42470) 2026-09-28 09:58:53 +00:00
Stephen Zhou
51940eac9c perf(web): lazy-load SVG code block previews (#42951) 2026-09-28 09:26:33 +00:00
林玮 (Jade Lin)
005f019a80 feat: support New Agent templates and trials (#42837)
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-09-28 09:21:50 +00:00
Stephen Zhou
5f6bb5e6d3 perf(workflow): lazy load node configuration panels (#42970) 2026-09-28 09:07:08 +00:00
Joel
7020120197 chore: improve Custom Endpoint accessibility (SNP-876) (#43060) 2026-09-28 08:59:34 +00:00
Harsh Kashyap
fe5dfd756f fix(web): skip shiki WASM engine load for plain-text fences (#42957)
Co-authored-by: Harsh Kashyap <harsh@harshkashyap.dev>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-09-28 08:54:54 +00:00
twwu
5857b6b850 Merge branch 'main' into feat/dify-builder
# Conflicts:
#	web/app/components/app/create-app-modal/__tests__/index.spec.tsx
#	web/app/components/app/create-app-modal/index.tsx
#	web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx
#	web/app/components/workflow-app/components/workflow-header/index.tsx
#	web/i18n/resources.ts
2026-09-28 16:49:21 +08:00
Stephen Zhou
4808976d0c fix(web): reset frontend session after logout (#43055) 2026-09-28 08:17:49 +00:00
Joel
1b21b8220d chore(web): focus node panel on keyboard open (SNP-877) (#43047) 2026-09-28 07:52:39 +00:00
Denis Ermakov
bf55d1f6a6 refactor: pass db.session explicitly to instruction_modify_legacy (#37403) (#43045) 2026-09-28 07:38:08 +00:00
Keith
855842410a refactor(models): remove implicit-session AppModelConfig.annotation_reply_dict (#43024) 2026-09-28 07:36:44 +00:00
huhupy
33b2f09325 fix(web): correct knowledge document information fields (#42885) 2026-09-28 07:32:51 +00:00
非法操作
4713c3853b refactor(api): bound suggested question runtime sessions (#42968) 2026-09-28 07:31:24 +00:00
非法操作
89e6b71b2d refactor(api): migrate console suggested questions to shared runtime (#43033) 2026-09-28 07:31:17 +00:00
平衡世界的BOY
27cdf50c7d fix(web): improve snippet run panel accessibility (#43028) 2026-09-28 07:00:30 +00:00
Joel
2b88685fbd chore: improve a11y of trigger integration, agent strategy and extension page (#43023) 2026-09-28 06:41:17 +00:00
yyh
52dcf83de6 refactor(web): scope app UI sessions to their owners (#42984) 2026-09-28 06:39:19 +00:00
yyh
a4a18e0652 refactor(web): separate AppIcon display from picker triggers (#43041) 2026-09-28 06:34:04 +00:00
林玮 (Jade Lin)
6441436b0c feat(api): include Site settings in App DSL (#42863) 2026-09-28 06:17:44 +00:00
Byron.wang
0c5f2507d0 refactor(api): separate app MCP server service and persistence (#42850) 2026-09-28 06:03:58 +00:00
qielanci
2515cf5b48 fix(api): use plain Session in MCP controller to avoid closed transactionFix/mcp plain session (#43038) 2026-09-28 05:44:11 +00:00
Stephen Zhou
5283462286 chore(deps): update compatible workspace dependencies (#43031) 2026-09-28 04:37:37 +00:00
yyh
f95edc85f5 refactor(web): consume generated app detail contracts (#42982) 2026-09-28 04:35:06 +00:00
twwu
5340d22240 feat(web): update Studio empty state creation 2026-09-28 12:05:58 +08:00
Stephen Zhou
f3107fee3f fix(tooling): resolve standalone TypeScript checks (#43029) 2026-09-28 03:36:07 +00:00
yyh
c42ee2fbac refactor(api): align app model config write contract (#42979) 2026-09-28 03:22:37 +00:00
Stephen Zhou
fc5053edf0 chore(web): upgrade vinext and align Vite+ configuration (#43021) 2026-09-28 03:04:45 +00:00
Taranum Wasu
d8ec14027e fix(api): mark async workflow trigger log FAILED when dispatch raises (#42148) (#43009)
Co-authored-by: Taranum01 <taranum01@users.noreply.github.com>
2026-09-28 02:57:05 +00:00
非法操作
96f444e3c6 refactor(api): separate history queries from prompt rendering (#42967) 2026-09-28 02:51:58 +00:00
非法操作
9d4629c946 refactor(api): share installed app suggested questions runtime (#42933) 2026-09-28 02:51:38 +00:00
twwu
7d594b22c7 Merge branch 'main' into feat/dify-builder
# Conflicts:
#	api/openapi/markdown/console-openapi.md
#	api/services/app_service.py
#	web/app/components/app/create-app-modal/index.tsx
#	web/app/components/workflow/index.tsx
2026-09-28 10:37:53 +08:00
Stephen Zhou
11f6a350f1 fix(tooling): align Vite+ configuration and upgrade toolchain (#43019) 2026-09-28 02:32:25 +00:00
huoex1222-hub
1366cadbb4 fix(api): report batch count as the progress denominator in Vector (#43005)
Co-authored-by: huoenxi <15811355218@139.com>
2026-09-28 02:29:58 +00:00
henrydev23
174a908786 chore(api): remove unnecessary str calls in openapi controllers (#42931) 2026-09-28 02:17:48 +00:00
dependabot[bot]
2062d5e892 chore(deps-dev): bump the dev group in /api with 7 updates (#43017)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 02:13:04 +00:00
非法操作
fb7b00365c refactor(api): separate suggested question model preparation and invocation (#42966) 2026-09-28 02:07:36 +00:00
非法操作
1c535791cb refactor(api): use HTTPStatus throughout Explore controllers (#42891) 2026-09-28 02:07:12 +00:00
dependabot[bot]
090f7d923c chore(deps): bump the github-actions-dependencies group with 3 updates (#43013)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 02:07:08 +00:00
非法操作
3dce05e09e refactor(api): group installed and trial app services (#42878) 2026-09-28 02:07:02 +00:00
jeongeun
a154a4d674 refactor(api): inject TextToSpeechVoiceQuery into agent TTS voices via the decorator (#43006)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:54:28 +00:00
ly303550688
934662cc48 fix(api): resolve ob_vector_memory_limit_percentage by index (#42846)
Co-authored-by: yangl <303550688@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-28 01:52:22 +00:00
Keith
b470071c4c chore: remove mypy from backend type checks (#42917) 2026-09-28 01:52:05 +00:00
yyh
d6c96ce1a5 refactor(web): redesign icon picker and clarify autocomplete highlighting (#43007) 2026-09-28 01:35:36 +00:00
Taranum Wasu
60744157d8 fix(api): fall back to tool name when MCP tool title is null (#42453) (#43010)
Co-authored-by: Taranum01 <taranum01@users.noreply.github.com>
2026-09-28 01:35:22 +00:00
dependabot[bot]
43f64090e1 chore(deps): bump the storage group across 1 directory with 4 updates (#42813)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 01:29:39 +00:00
dependabot[bot]
12f65f887a chore(deps): bump google-cloud-aiplatform from 2.1.3 to 2.2.0 in /api in the google group (#43012)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 01:27:11 +00:00
Sourav Rajvi
725611b2e9 test: drop redundant created_at/updated_at in unit tests (#42989) (#42990) 2026-09-26 18:10:34 +00:00
destire-mio
e6fa1a1dd0 refactor(models): pass session into Conversation.app (#42975)
Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>
2026-09-26 17:29:35 +00:00
yyh
414cce2f70 refactor(web): align hotkeys with command ownership (#42974) 2026-09-26 12:37:36 +00:00
Stefan Yoshovski
7a7dc6011a fix(agent-v2): use icon_url for image avatars in the configure preview (#42977)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-09-26 12:36:08 +00:00
Bruce-Yii
3960b57102 fix(web): resolve pdf worker url under the configured base path (#42956)
Co-authored-by: Bruce-Yii <298228875+Bruce-Yii@users.noreply.github.com>
2026-09-26 05:32:07 +00:00
yyh
698ecd3e62 refactor(dify-ui): narrow checkbox public API (#42953) 2026-09-26 04:52:26 +00:00
yyh
f4602cc1fe refactor(web): refine detail sidebar states and preview (#42938) 2026-09-25 15:46:20 +00:00
yyh
2d813b2a87 fix(web): preserve detail sidebar state during SSR (#42936) 2026-09-25 15:46:19 +00:00
Andy yang
d86435ee1e fix(workflow): coerce non-uuid variable ids and warn on skipped branches (#42750)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-09-25 08:16:41 +00:00
chenbd123
f6a1878ceb fix(api): improve Vastbase vector store with Graph_Index and BM25 full-text (#42922)
Co-authored-by: chenbd123 <188698064+chenbd123@users.noreply.github.com>
2026-09-25 16:13:50 +08:00
haochen
e5b5a1cc4c fix(mail): preserve send results when SMTP cleanup fails (#42903)
Co-authored-by: Zhangchenhao-111 <285911435+Zhangchenhao-111@users.noreply.github.com>
2026-09-25 02:34:20 +00:00
Mitsunao Miyamoto
6824611958 docs(ja-JP): fix grammar and mistranslations in Japanese README (#42895) 2026-09-25 02:30:57 +00:00
Mitsunao Miyamoto
672d2e2bb7 fix(i18n): fix typos and untranslated strings in Japanese translations (#42893) 2026-09-25 02:30:42 +00:00
林玮 (Jade Lin)
0755abe47f fix(agent): allow package imports with missing plugins (#42913) 2026-09-25 02:30:27 +00:00
zyileven
50589420b1 fix(web): preserve model context when adding load-balancing credentials (#42910) 2026-09-25 02:28:51 +00:00
gn
90e590e48d chore(api): remove unnecessary str() calls in controllers/openapi/workspaces (#42932) 2026-09-25 02:09:31 +00:00
yyh
a7f890129e fix(web): isolate home template cards beneath sticky header (#42906) 2026-09-24 11:54:41 +00:00
Joel
18e0cb102b fix(web): improve data source integrations accessibility (SNP-872) (#42911) 2026-09-24 09:47:16 +00:00
twwu
44b9bb2da3 fix(web): remove unused Dify Builder translations 2026-09-24 17:26:34 +08:00
twwu
06ba1a2880 Merge branch 'main' into feat/dify-builder
# Conflicts:
#	api/controllers/console/app/app.py
#	api/services/app_service.py
2026-09-24 17:13:03 +08:00
yyh
7ee0f6af7e refactor(web): remove synthetic app detail SSO field (#42888) 2026-09-24 09:06:19 +00:00
twwu
1e062c9a8c fix(web): align Dify Builder selection cards and test action copy 2026-09-24 17:00:14 +08:00
yyh
54c2928734 refactor(web): scope app info modal state to its view (#42882) 2026-09-24 08:53:50 +00:00
yyh
ef33df5e22 refactor(api): tighten app detail response schema (#42876) 2026-09-24 08:53:49 +00:00
Yunlu Wen
edf1a57b7b refactor: move rbac enable check to check_dataset_permission (#42907)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-24 08:41:12 +00:00
twwu
4ed5b86706 fix: refine Dify Builder conversation and form feedback 2026-09-24 16:25:08 +08:00
Joel
4c2f022bb2 fix(web): improve a11y of tool integrations (#42898) 2026-09-24 07:59:07 +00:00
Stephen Zhou
bba6aad652 perf(web): streamline vinext i18n build analysis (#42902) 2026-09-24 07:53:42 +00:00
github-actions[bot]
0e4ecfa890 chore(i18n): sync translations with en-US (#42890)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-09-24 07:20:07 +00:00
Wu Tianwei
b3e3540c43 refactor: improve Dify Builder session UX (#42760) 2026-09-24 15:18:32 +08:00
Asuka Minato
a405b00a3f test: run workflow archiver coverage on SQLite (#40718) 2026-09-24 07:18:13 +00:00
yyh
a1622aea28 fix(i18n): pin sync source and link originating PR (#42896) 2026-09-24 07:12:56 +00:00
Joel
102497957d fix(web): improve model provider accessibility (#42866) 2026-09-24 06:59:59 +00:00
呆萌闷油瓶
74af4f2308 perf(api): reduce knowledge retrieval latency (#42673) 2026-09-24 06:50:36 +00:00
Stephen Zhou
989ba1a78f perf(web): isolate roster upload and generation translations (#42879) 2026-09-24 06:28:48 +00:00
Byron.wang
635143cb88 refactor(api): thin console app controllers and separate application boundaries (#42671)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-24 06:17:38 +00:00
twwu
f589611a1e Merge branch 'main' into feat/dify-builder 2026-09-24 12:49:29 +08:00
Stephen Zhou
24c6815889 perf(web): split workflow translations by rendering boundary (#42877) 2026-09-24 04:24:20 +00:00
Stephen Zhou
ddb9f1ba8c perf(web): split shared translation namespaces by feature (#42874) 2026-09-24 04:24:20 +00:00
Stephen Zhou
b9f1ddaff4 perf(web): avoid implicit i18n namespace loading (#42873) 2026-09-24 04:24:19 +00:00
Asuka Minato
97c0aaa005 test: remove duplicate node output integration coverage (#40717) 2026-09-24 03:37:45 +00:00
Asuka Minato
98b7e92191 test: run draft variable coverage on SQLite (#40716) 2026-09-24 03:37:25 +00:00
yyh
8778c6b985 fix(web): close agent slash menu on immediate focus exit (#42867) 2026-09-24 03:35:59 +00:00
yyh
b7d4cbf00f fix(deps): upgrade cron-parser to 5.7.0 (#42865) 2026-09-24 03:12:34 +00:00
Joel
570317ecde fix(web): hide divider-only Markdown content (#42860) 2026-09-24 03:08:38 +00:00
非法操作
bd08f6b0e3 refactor(api): migrate installed app audio to shared runtime (#42056) 2026-09-24 02:56:38 +00:00
Stephen Zhou
8785f45e93 chore(deps): enable automatic pnpm deduplication (#42862) 2026-09-24 02:49:56 +00:00
落尘
5df8a4c89c fix(api): close failed SSE connection before MCP fallback (#42761) 2026-09-24 02:44:23 +00:00
Stephen Zhou
8300811b8c perf(web): replace runtime pinyin with provider initials lookup (#42861) 2026-09-24 02:32:05 +00:00
Charles Yao
90b1e668cb Merge origin/feat/dify-builder into the Builder fix branch
Brings in 104 commits from origin (main merges plus unrelated refactors).
PR #42760's Builder UX refactor has not landed, and only
api/tests/unit_tests/core/workflow/test_node_factory.py overlaps with this
branch's work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 19:28:38 -07:00
Stephen Zhou
28ed561740 ci(web): highlight Vinext bundle size changes (#42858) 2026-09-24 02:04:37 +00:00
非法操作
ff27ef544e refactor(web): migrate account name and avatar updates to profile API (#42843) 2026-09-24 02:02:50 +00:00
Stephen Zhou
7feebe3405 fix(web): defer Amplitude SDK loading until analytics consent (#42857) 2026-09-24 01:48:27 +00:00
Stephen Zhou
2f9fa672d0 ci(web): skip bundle comments for minor size changes (#42856) 2026-09-24 01:28:45 +00:00
非法操作
70e973b9ab refactor(api): isolate installed app messages (#42016) 2026-09-24 01:08:56 +00:00
Nehal kanak kuniyil thottathil
580a4165f2 refactor(api): inject InstalledAppsListQuery via the decorator (#42769)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-09-23 20:21:11 +00:00
Dorian
7cc0898f58 refactor(models): pass session into Conversation.to_dict and Message.to_dict (#42834)
Co-authored-by: Dorian <dorian0326@users.noreply.github.com>
2026-09-23 19:47:44 +00:00
Stephen Zhou
e43ea0986e ci(web): report Vinext bundles using Rolldown analysis (#42849) 2026-09-23 14:06:57 +00:00
Stephen Zhou
e9550e50d0 fix(web): reduce chart and syntax highlighting bundles (#42845) 2026-09-23 12:32:31 +00:00
Sourav Rajvi
200da319f2 refactor(web): defer tldts load for MCP favicon URL blur (#42486) 2026-09-23 11:48:40 +00:00
非法操作
4144850837 refactor(api): use route decorators in trial controller (#42815) 2026-09-23 11:47:53 +00:00
Stephen Zhou
d3d3248e3b fix(web): lazy-load markdown chart and music renderers (#42840) 2026-09-23 11:32:00 +00:00
Weike Zhang
f832cff60c fix(api): keep creator access to datasets created via console upload and RAG pipeline (#42507)
Co-authored-by: weike-zhang <wangweike@gmail.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-09-23 11:27:54 +00:00
非法操作
53aa73e416 refactor(api): isolate installed app conversations (#41971) 2026-09-23 11:11:33 +00:00
Stephen Zhou
16100f839f fix(web): preserve i18n translation type safety (#42839) 2026-09-23 11:04:16 +00:00
Joel
67c0157d00 fix(web): improve dataset document segment accessibility (#42833)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-09-23 10:56:41 +00:00
Stephen Zhou
eb7235f17c refactor(web): generate type-safe i18n resources (#42835) 2026-09-23 10:25:38 +00:00
yyh
5f66253fab fix(explore): improve template preview states and accessibility (#42830) 2026-09-23 09:57:19 +00:00
zyileven
699489b95f fix(web): localize workflow statuses across logs and run details (#42699)
Co-authored-by: XiaoBa Yu <xb1823725853@gmail.com>
2026-09-23 09:34:51 +00:00
非法操作
d1391c2ed5 refactor(api): isolate installed app management (#41957) 2026-09-23 09:24:22 +00:00
Joel
518bd238f5 fix: improve document list accessibility (#42826) 2026-09-23 09:22:34 +00:00
twwu
da87af8a7c Merge branch 'main' into feat/dify-builder
# Conflicts:
#	web/app/components/app/create-app-modal/index.tsx
#	web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx
#	web/app/components/workflow-app/components/workflow-main.tsx
#	web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts
#	web/app/components/workflow-app/hooks/use-DSL.ts
#	web/app/components/workflow/panel/workflow-preview.tsx
#	web/app/components/workflow/run/index.tsx
#	web/i18n/locales/ar-TN/app.json
#	web/i18n/locales/az-AZ/app.json
#	web/i18n/locales/de-DE/app.json
#	web/i18n/locales/en-US/app.json
#	web/i18n/locales/es-ES/app.json
#	web/i18n/locales/fa-IR/app.json
#	web/i18n/locales/fr-FR/app.json
#	web/i18n/locales/hi-IN/app.json
#	web/i18n/locales/id-ID/app.json
#	web/i18n/locales/it-IT/app.json
#	web/i18n/locales/ja-JP/app.json
#	web/i18n/locales/ko-KR/app.json
#	web/i18n/locales/lo-LA/app.json
#	web/i18n/locales/nl-NL/app.json
#	web/i18n/locales/pl-PL/app.json
#	web/i18n/locales/pt-BR/app.json
#	web/i18n/locales/ro-RO/app.json
#	web/i18n/locales/ru-RU/app.json
#	web/i18n/locales/sl-SI/app.json
#	web/i18n/locales/th-TH/app.json
#	web/i18n/locales/tr-TR/app.json
#	web/i18n/locales/uk-UA/app.json
#	web/i18n/locales/vi-VN/app.json
#	web/i18n/locales/zh-Hans/app.json
#	web/i18n/locales/zh-Hant/app.json
2026-09-23 17:06:56 +08:00
XiaoBa
f6561d8cc0 fix(api): enforce dataset operation permissions (#42758) 2026-09-23 09:03:16 +00:00
Taranum Wasu
486bcb0b08 fix(tools): translate PluginNotFoundError to ToolProviderNotFoundError (#41837)
Co-authored-by: Taranum01 <taranumwasu@users.noreply.github.com>
2026-09-23 08:59:58 +00:00
yyh
236b90a90c fix(home): search templates by name and description (#42828) 2026-09-23 08:57:02 +00:00
林玮 (Jade Lin)
6f636b2f49 fix(api): resolve provider credential aliases (#42790) 2026-09-23 08:53:03 +00:00
林玮 (Jade Lin)
11883543d1 fix(api): include node plugins in DSL dependencies (#42816) 2026-09-23 08:53:02 +00:00
Byron.wang
8b7f903cff refactor(api): centralize API key management (#42489)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-23 08:38:48 +00:00
yyh
1a09485013 chore(deps): upgrade shadcn lint and cn (#42818) 2026-09-23 08:35:55 +00:00
Wu Tianwei
efac2084e1 fix(workflow): sync imported graph with collaboration state (#42817) 2026-09-23 08:31:05 +00:00
Stephen Zhou
7f4a5c2413 refactor(web): load and stream translation namespaces on demand (#42812) 2026-09-23 08:30:08 +00:00
非法操作
f0078b7935 refactor(api): migrate installed app workflow and task control (#41912) 2026-09-23 08:25:37 +00:00
Joel
14d8eecea6 fix: improve dataset settings accessibility (#42796) 2026-09-23 08:19:22 +00:00
非法操作
bc94f7ead5 refactor(api): migrate installed app chat generation (#41905) 2026-09-23 08:01:39 +00:00
Neo1Noir
0a428a52e6 fix(web): remove a Code node output variable once the removal is confirmed (#42678) 2026-09-23 07:52:25 +00:00
非法操作
3bf5f2c47e refactor(api): isolate installed app completion generation (#41894) 2026-09-23 07:38:37 +00:00
Byron.wang
584b62adc5 fix(test): return inserted primary keys from add_texts (#41037) 2026-09-23 07:34:04 +00:00
LII
286ee05711 test: sync Redis subscriptions before publishing in test_topic_isolation (#42674)
Co-authored-by: GyberExperiment <themacroeconomicdao@tuta.io>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-09-23 07:20:45 +00:00
-LAN-
3113343c4d refactor(rag): remove unused splitter tokenization (#42804) 2026-09-23 07:20:39 +00:00
-LAN-
1d76929d5a test(api): remove duplicate and implementation-only coverage (#42803) 2026-09-23 07:18:02 +00:00
dependabot[bot]
401490c033 chore(deps-dev): bump the dev group in /api with 15 updates (#42616)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-09-23 07:13:17 +00:00
Wu Tianwei
2fa4c57026 fix(web): gray preview-only agent cards (#42809) 2026-09-23 07:06:10 +00:00
Joel
79e962d664 fix: improve retrieval testing accessibility (#42785) 2026-09-23 07:04:49 +00:00
非法操作
9daff34bb5 refactor(api): migrate explore task stop admission (#41874) 2026-09-23 06:53:44 +00:00
yyh
4af693ccef fix(agent): initialize model in new agent snapshot (#42781) 2026-09-23 06:51:40 +00:00
Byron.wang
16d3302bff refactor(api): isolate OAuth device flow orchestration (#41361) 2026-09-23 06:36:48 +00:00
Charles Yao
64127c17e2 fix(dify-builder): Fix explains the guard's verdict instead of implying it
Parity for the two semantic guards, which Task 8 committed to for exactly this
reason: a guard Edit honours and Fix merely implies is worse than no guard,
because the inconsistency is invisible to whoever reads only one of them.

The hole was not the one I reported at the end of round 2. Probed against the
literal S6 batch, `fix.propose_repair` already refuses to hand on a batch
carrying any rejection -- unlike Edit it never had a fallback to the first
attempt, so the condemned batch was never written. What it did have was a BLIND
surface: the engine-grounded reason was discarded and `_no_fix()` returned the
generic "No safe automatic fix found", which the handler then stored on
`fc.risk` and showed to nobody. The human at the gate was told a fix could not
be found and given nothing to act on -- while the guard had just described the
aggregator, the node and the selector that would have made a manual fix obvious.

So: `_no_fix()` takes an optional reason (one dead-end path, not a second), a
`would_run_wrong` verdict fills it in, and `handle_propose` renders it on an
ErrorCard beside the reply line -- the same division of labour Edit's refusal
uses. The verdict is read off the structured `would_run_wrong` list, never off
the rejection prose, and deliberately NOT off `rejections` at large: those
inline whole `intent.args` and belong in a re-prompt, not on a card, and a
structural refusal says nothing about the batch that remains. A purely
structural refusal therefore still ends at the generic sentence, as before.

The `would_run_wrong` check is written as its own line rather than left to the
broader rejection check it currently duplicates. That redundancy is a property
of the line below it, not of the guard: if Fix is ever loosened the way Edit
was, this is what still holds -- and it is what carries the reason either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:14:26 -07:00
Charles Yao
4082cceb24 fix(dify-builder): never write a batch the agent has already judged wrong
The two semantic guards describe defects the ENGINE ACCEPTS: a new branch wired
into a variable-aggregator that was never told about it, an array re-sent with
an element quietly dropped. Both drafts start, both run green, and neither
produces a `DraftWouldNotStartError` for `apply_repair` to veto. So the guards
had one hole left, and it was in the agent's own fallback:
`build_edit_intents` spent its corrective re-prompt on the guard's reason and
then, if the retry yielded nothing usable, returned the FIRST attempt's
applicable intents -- the very batch the guard had condemned. The guard fired
and the silently-wrong draft was written anyway.

`vet_intents` now carries `would_run_wrong` alongside `rejections`: the subset
of refusals the engine does not make. The two kinds fail differently and a
caller has to tell them apart without reading the text -- a structural refusal
DROPPED its intent, so what survives is a batch nobody judged wrong and keeping
it is a deliberate choice; a semantic verdict drops nothing and condemns what
remains. Reading that off the prose would be the same mistake as keying control
flow on the model's prose.

`build_edit_intents` therefore raises `ProposalWouldRunWrongError` instead of
returning a batch still carrying one, whichever attempt it came from. Nothing is
written and nothing is attempted. `handle_plan_approval` catches it into the
same `_change_not_applied` recovery a refused write gets: the plan stays at its
gate, the reason goes on an error card and into `last_edit_rejection` for the
next attempt.

Surfacing is a real exit now and not the dead end it would have been before this
plan: Task 7 gave the gate a revert and a keep-adjusting action, and Task 6
carries the refusal into the next proposal, so the user can change the rules and
the retry is not blind.

`ProposalWouldRunWrongError` is deliberately neither a `DraftWouldNotStartError`
nor a `ValueError`: the draft WOULD start, and a broad `except ValueError`
around a port write must not swallow it as if the write had been attempted.

`_change_not_applied` takes the activity to mark failed, defaulting to the write
where the usual refusal happens. This exit gives up during "prepare", so failing
the write would have shown a LATER step failed while an earlier one was still
running, with the step between never resolving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:14:25 -07:00
yyh
792d154608 refactor(web): remove redundant root locale fallback (#42791) 2026-09-23 06:11:43 +00:00
yyh
d08f554ff4 fix(app): align package import and export feedback (#42732) 2026-09-23 06:11:21 +00:00
Charles Yao
10a6e0b9ef fix(dify-builder): a re-route is a change, and a comment is not a claim
Four small things the whole-branch review found, none of which changes what a
guard decides.

`_change_not_applied`'s docstring said the gate's other two exits "both clear
this store". Task 7's fix round moved the clear to `handle_impact_analysis` and
made it conditional on the rules actually changing, so neither
continue_adjusting nor revert clears it -- and two tests assert exactly that. A
maintainer trusting the comment would re-add the clear to the revert path,
which is the bug Task 7 removed. Reworded to name the sole, conditional clear
and the two tests that pin it.

`already_present_predicate` keyed a connect on `(from, to)` alone, so a repair
that re-routes an arm of a branch between the same pair of nodes matched the
edge already there and was dropped as a no-op -- in the dry run and at
`apply_repair`, which runs the same predicate. The handle is now part of the
key on both sides, defaulting to "source" the way graphon reads a missing one.

`_build_node`'s defaults merge was shallow, so `model: {provider, name}`
replaced the whole default block and the node was refused for `model.mode` --
spending the one corrective re-prompt on a field the default was carrying. It
now merges one level into a dict-valued default, with one exclusion that the
suite caught immediately: a block whose `type` the caller did not write is left
alone, because `type` reinterprets its siblings and completing a partial
`authorization` from the default produces a node that looks authorized, sends
no credential, and is written in silence.

`_withheld_args` redacted a `value` shaped like a whole node `data`, which is
not what `set_node_config` sends: `path="headers"` with a header line reached
the retry prompt unredacted. The value is now also redacted as what it is --
the value at that path -- by wrapping it back into its own nesting and running
the one redaction profile over it, so `credentials` stays the single definition
of what a credential is. Unreachable with a stored secret today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:11:17 -07:00
Charles Yao
0b77b01da6 fix(dify-builder): a whole-array write must not drop an element's identity
Asked to ADD a case to an if-else, the live S6 edit re-sent the whole `cases`
array -- the only way to append one -- and regenerated the case it was not
asked about: the condition's `id` and `varType` were lost and `= 60` became
`≥ 60`. The engine accepts all of it (graphon's `Condition` has no `id` field
at all, and `≥ 60` is a perfectly valid condition), `diff_graphs` reports one
line, and the user's threshold has silently changed meaning.

`vet_intents` now refuses a `set_node_config` that replaces an existing array
with another array from which an element identity (`id`, `case_id`) present
before is absent after, naming the ids that went missing. The comparison walks
the whole subtree, because the live clobber was one level down: both `case_id`s
survived and the surviving case's CONDITIONS were regenerated.

The check is scoped by (node type, path) to the arrays where those keys MEAN
something, each verified against the engine: if-else `cases` and
`cases.*.conditions` (`Case.case_id` is required and is the edge sourceHandle),
question-classifier `classes` (`ClassConfig.id` is required and documented as
the routing handle), and knowledge-retrieval
`metadata_filtering_conditions.conditions` (the canvas mints an id per
condition and a regenerated filter silently changes what is retrieved).
Unscoped, the rule refused an ordinary edit: an llm `prompt_template` message's
`id` is a frontend uuid the canvas REGENERATES when absent and graphon's
`ChatModelMessage` has no field for, so rewriting a prompt -- the commonest
Builder request there is -- looked exactly like the S6 clobber and parked the
user's change. Inside a scoped array the walk stays recursive, which is what
catches S6's nested condition id.

The op schema already tells the model to re-send existing elements
byte-identical; this is the half that does not depend on it obeying. The
neighbouring shapes stay untouched: an append that repeats every existing
element, a change addressed by index (which writes a scalar, never an array),
and an array whose elements never carried an identity -- a variable-aggregator's
`variables`, which the rejoin guard requires be re-sent whole.

A DELETION is not a clobber either. Removing an if-else case is a legitimate
edit and a whole-array write is the only way to express one, so a guard that
refused it would make the feature unusable -- loudly, but unusably. The two
shapes are distinguishable without asking the model what it meant, which is the
only reason the exception exists: a deletion leaves every element it KEEPS
byte-identical to one of the originals, each claimed once, and keeps fewer than
it was given; a regeneration loses an identity in the same write that modified
or added something else. The live S6 batch is the second kind and is still
refused, as is the mixed write that deletes one element while editing another.

The comparison runs against a HEALED copy of `before`, because `after` is the
dry run's already-healed graph: a draft holding `">="` or a JSON `60` -- the
ESQ1-303 shape -- would otherwise make a survivor the model re-sent verbatim
look modified, and a genuine deletion would be refused for a difference the
heal set introduced. Healing can only make two elements more equal, so it can
only reduce false rejections.

`graph_ops.value_at_path` is the read half of `apply_set_node_config`, through
the same `_resolve_path` walk, so the before/after comparison cannot disagree
with the write about which slot a path names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:11:17 -07:00
Charles Yao
9407e8e7d3 fix(dify-builder): a new branch must feed the aggregator it rejoins
The plan's last silent failure: a batch wires a new arm into an existing
variable-aggregator and never extends that aggregator's `variables`. The
aggregator stays engine-VALID -- its existing selectors still resolve, so
`preflight_errors` has nothing to say and no single intent owns the defect --
while graphon's `VariableAggregatorNode._run` walks the selectors, finds
nothing for the new arm and returns SUCCEEDED with `outputs={}`. End still
runs, so `run_finished_without_output` is False and the Builder reports
"All checks passed" over a workflow that produces nothing.

`vet_intents` now refuses a batch that adds an edge `N -> A` where `A` is a
variable-aggregator and nothing on the new arm feeds it, naming `A`, `N` and
the selector to append. Keyed on the batch's own effect on the graph and on
node data, never on model prose.

Kept narrow so it cannot false-reject. It fires only on an edge THIS batch
added (an aggregator the user already wired badly keeps its exemption) and only
when the target is an aggregator. "Feeds it" is read generously in one
direction and strictly in the other:

- generously about WHICH node: a selector rooted at any node the new edges
  chain back through, not just the edge's immediate source, because a branch of
  more than one node is an ordinary shape and all of them run when that arm
  runs. The walk follows only the batch's OWN new edges -- that is the
  soundness argument, since walking pre-existing ones would cross into the
  graph's exclusive branches, where the S6 aggregator's existing selectors sit;
- strictly about WHICH NAME: the selector has to name a variable the node
  actually publishes. `["node7", "output"]` on a node that publishes `text` is
  exactly as unresolvable as no selector at all and the run skips it in the
  same silence. A type whose published set is not knowable -- a tool's outputs
  are whatever the tool returns -- accepts anything: this refuses a name it
  KNOWS is wrong, never one it merely cannot confirm.

Both selector homes are scanned (`variables` and `advanced_settings.groups`),
since graphon reads one or the other depending on `group_enabled`.

Which variable a node publishes was prose in Edit's rejoin clause; it is now
two tables in `graph_prompt` -- the one name to suggest and the whole set to
accept -- read off graphon's own `NodeRunResult.outputs`, so the sentence the
model reads and the sentence the guard writes cannot name different variables.
A code node is read off its own declared `outputs`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:07:24 -07:00
Xin Zhang
e11a20ace4 fix: handle missing optional tracing dependencies (#42724) 2026-09-23 05:58:01 +00:00
yyh
1aafa481f3 test(e2e): align Agent v2 publish scenarios with guidance (#42787) 2026-09-23 05:35:43 +00:00
yyh
9c210ea670 fix(web): keep copy tooltip visible after click (#42789) 2026-09-23 05:35:28 +00:00
非法操作
0c36f4a104 refactor(api): remove legacy Baichuan prompt handling (#42779) 2026-09-23 05:12:46 +00:00
Stephen Zhou
5f899e428a fix(web): declare translation namespace dependencies (#42784) 2026-09-23 04:43:00 +00:00
Charles Yao
bf6372fcb1 fix(dify-builder): the fix agent knows a branch connect needs a handle
Fix's op schema listed `connect: {from_node, to_node}` and its graph view
printed no handles, so any repair that wired something from an if-else /
question-classifier / human-input node fell back to the "source" handle.
`graph_ops.apply_connect` refuses that, the refusal spent the single
corrective re-prompt, and `propose_repair` returned `_no_fix()` -- the
repair could not be proposed at all, however right it was. Edit was given
the handles in 197cd9c5ad; Fix never was.

The rendering both agents need now lives once in `agent/graph_prompt.py`
(`node_line` / `edge_line`, plus the op list, the operator literals, the
redaction sentinel and the config block), so a branch node looks the same
in either prompt. Edit keeps the one clause that is its own: follow a new
branch to where it rejoins the graph. Edit's prompt text and rendering are
unchanged byte for byte.

The shared schema tells the model to re-send an array's existing elements
byte-identical "as GRAPH shows them", which is only honest if what GRAPH
showed is whole. `fix._culprit_config` used to hand the prompt a Python
repr cut at 1500 characters, so a long `cases` array arrived severed
mid-value and would have been completed from imagination, obediently. It
now renders through the same block Edit uses: whole keys are dropped to
fit the budget and then named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:40:32 -07:00
yyh
232f29c355 chore: migrate workspace to Vitest 5 (#42782) 2026-09-23 04:13:42 +00:00
Charles Yao
43f22f6280 fix(dify-builder): a rejected edit can be reverted or adjusted, not only re-approved
edit.plan_approval offered exactly one action, approve_plan, while the card
its own refusal emitted said "Adjust it and approve again" -- an affordance
that did not exist. A chat message kept the PC at the gate and never touched
edit_rules, so an approval the engine refused had no exit at all.

The gate now also offers Continue adjusting and Discard this change plan,
admitted end to end: _ACTIONS_FOR renders them, _BACKEND_ACTIONS_FOR
[EDIT_PLAN_APPROVAL] admits re_fix and undo, and handle_plan_approval routes
both past the early return that used to swallow every non-approve kind. The
discard reuses perform_revert against the checkpoint handle_impact_analysis
already mints; Continue adjusting reuses handle_review's re_fix body, now
shared as _resume_adjusting, which returns to edit.impact_analysis with the
edit-rules form repopulated and unfrozen.

last_edit_rejection is cleared where its cause is: in handle_impact_analysis,
and only when the submitted payload actually changes fc.edit_rules. A stored
refusal goes stale precisely when the rules it refused stop being the rules,
and nowhere else -- so the actions that merely route the user to the form do
not clear it, and neither does edit.reverted's Retry. A revert taken at the
gate writes nothing, which makes it the one path reaching edit.reverted with
a refusal still true of the restored draft; clearing there would have made
Retry re-propose the identical plan blind.

Copy corrected in strings.py and handlers_edit.py to name the three buttons
that now exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:11:06 -07:00
平衡世界的BOY
1bbb604d32 fix(web): make human input button style selector accessible (#42747) 2026-09-23 03:42:22 +00:00
Charles Yao
59cc7043a3 fix(dify-builder): a rejected edit tells the next attempt what the engine said
At edit.plan_approval the gate re-reads an UNCHANGED draft, so edit_rules and
graph are byte-identical on every re-approval, and _change_not_applied stored
nothing about WHY the port refused the write. The live F4 user approved the
same plan three times and got a byte-identical card each time.

Add DifyBuilderContext.last_edit_rejection: the raw text of the
DraftWouldNotStartError/ValueError the port raised, capped at 2000 chars,
stored by _change_not_applied and passed into build_edit_intents, which quotes
it into the user prompt as a "PREVIOUS ATTEMPT REJECTED BY THE ENGINE" block.
It is engine-sourced by construction -- a PreflightError's every message is
built by re-validating a credentials.redact_node_config copy of the node, and
graph_ops' ValueErrors carry only paths, ids and type names -- so no model
prose enters. Nothing branches on it. Cleared on a successful write, on a new
edit goal, and by the recovery reset.

Close the silent false success that left, rather than only recording it.
new_preflight_problems now takes the ids whose NODE DATA the batch wrote and
gives them no exemption at all: a node this batch wrote must be startable, not
merely no worse than it was. Keyed on locations alone, a repair that swaps one
bad value for another at the same field path -- or fixes one of three missing
fields -- produced nothing new, was written, and the card said "Applied the
changes" over a draft that dies at Graph.init. Since a repair's culprit is by
definition already invalid, that was Fix's main line. Every OTHER node keeps
the location-keyed exemption, so a defect the user left on the canvas still
cannot veto an unrelated change.

That set is deliberately narrower than a change set, on both sides (graph_ops
.DryRun gains the field filter_applicable already computed and discarded;
apply_repair keeps a separate written_nodes beside changed_nodes). connect is
excluded, because apply_connect reports both endpoints and an edge cannot
change either one's validate_node_config verdict -- counting them would let
merely wiring an already-broken node veto the batch. The heal is excluded too,
because heal_nodes_for_preflight scans the whole graph and a node it merely
normalized is not one this batch is answerable for.

Also, from the reviews of preflight.py: route an intent's config/value through
redact_node_config before a structural rejection line inlines it; key a
non-ValidationError crash on its truncated message as well as its class, and
keep that message out of the text the model is shown; and correct the
_CRASH_LOCATION comment -- verified against graphon, the rule is a config key
present AND type absent-or-invalid.

Finally, stop the docstrings overclaiming: redaction covers authorization,
headers and params, and a secret typed into a request body value is NOT
covered and reaches the stored text. Widening the profile is the wrong fix --
it shares the sentinel guard that refuses any write carrying REDACTED, so it
would make http-request bodies uneditable. Recorded in credentials.py as a
follow-up needing a separate persistence-only profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 20:40:15 -07:00
Joel
960bea8bae fix(web): initialize empty agent models from workspace defaults (#42736) 2026-09-23 02:51:25 +00:00
Samuel K Quansah
654a49d1bc fix(otel): fix typo Convertions -> conventions in ext_otel resource comment (#42771) 2026-09-23 10:51:22 +08:00
非法操作
75ba8d1dc4 refactor(api): isolate installed app admission for saved messages and parameters (#41872) 2026-09-23 02:33:34 +00:00
Charles Yao
c4ac3d49bf fix(dify-builder): a proposal is engine-checked before it reaches the gate
`filter_applicable` dry-ran STRUCTURE only. In the live F4 Edit four intents
came back "applicable, 0 rejected", the batch then died at `apply_repair`'s
preflight, and the one corrective re-prompt could never fire -- it is keyed on
`rejected` being non-empty. Every re-approval re-sent a byte-identical prompt
and got a byte-identical batch back.

`filter_applicable` now returns its dry-run graph (`DryRun` NamedTuple) after
running it through the shared `heal_nodes_for_preflight` -- the same
normalizers, at the same point in the sequence, the write chokepoint runs. A
dry run that healed less would report a defect the write was going to fix; one
that healed more would pass a batch the write refuses. It also skips the
intents the port drops as already-present, via a predicate both now share, so
a re-sent duplicate `create_node` no longer burns the re-prompt.

`preflight.vet_intents` then puts that graph through `validate_node_config` and
feeds any NEW problem into the existing re-prompt, quoting the engine's own
message. `new_preflight_problems` is the one definition of that rule, shared
with `apply_repair` so the dry run and the write cannot answer differently.

It is keyed on the set of pydantic error LOCATIONS per node, which is the only
key that gets both halves right. Node identity exempts an already-refused node
from every later verdict -- and since a Fix repair targets a node that is by
definition already invalid, that would leave `propose_repair` with no node-data
coverage on its own culprit. Message text is too fine: pydantic quotes a
truncated repr of the input, so filling one of three missing fields rewrites
the other two messages and an exact-text diff refuses the repair that improved
the node.

The refusal text is rebuilt from a credential-redacted copy of the node. The
verdict still comes from the real one; only the words the model is shown are
withheld, because a model-level pydantic error echoes the node's whole `data`
and that is where a live bearer token sits.

The retry budget is unchanged: one corrective re-prompt, then Edit's existing
fallback to the first attempt's applicable intents and Fix's `_no_fix()`.

Also folds in the Task 1 review: the generator's postprocess calls the shared
heal set plus `derive_if_else_var_types` instead of re-spelling three
normalizers inline, and the valid-operator no-op sweep covers the
list-operator container so `FilterOperator` gets its own check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:23:12 -07:00
yyh
d566192843 fix(web): upgrade Next.js to 16.3.6 for CVE-2026-94545 (#42772) 2026-09-23 02:22:15 +00:00
非法操作
c592610ed3 refactor: isolate saved message persistence (#41857) 2026-09-23 02:05:01 +00:00
Fatih Farhat Asshidiq
2480e5a274 docs: add Indonesian translation of README (docs/id-ID/README.md) (#42768)
Signed-off-by: imanecdoche <kazokuhairy@gmail.com>
2026-09-23 09:46:33 +08:00
yetuge
f547ef8726 docs(api): align node execution repository docstrings with signatures (#42763) 2026-09-23 09:41:49 +08:00
yetuge
639d410108 docs(tasks): align trigger task docstrings with signatures (#42765) 2026-09-23 09:41:39 +08:00
twwu
c67aa9b4ea Merge branch 'main' into feat/dify-builder 2026-09-23 09:37:08 +08:00
Charles Yao
661fab1dda fix(dify-builder): the edit agent patches a case instead of rewriting the array
The S6 edit rewrote an if-else's whole `cases` array to add one branch, and
in doing so silently turned `= 60` into `>= 60`, dropped every condition's
`id`/`varType`, and never touched the variable-aggregator the new arm feeds.
Task 3 gave the agent sight of the current config; this tells it what a
correct mutation of that config looks like.

`_OP_SCHEMA` now teaches, verified against `graph_ops._resolve_path` and
graphon rather than assumed:

* an all-digit path segment indexes an array, so ONE element changes by
  index (`cases.1.conditions.0.value`) with its siblings untouched;
* an index past the end of an array is rejected -- there is no append -- so
  ADDING an element means re-sending the whole array with every existing
  element byte-identical and the new one appended;
* the engine's own 21 comparison literals, and that the comparison forms are
  the unicode `≥ ≤ ≠`. `>=`/`<=` are healed deterministically at the port, so
  there the prompt is merely stricter; the equality forms are ambiguous
  between string `is`/`is not` and number `=`/`≠`, are deliberately not
  healed, and the refusal that follows costs the whole batch;
* a new branch must be followed to where it rejoins: if it feeds an existing
  variable-aggregator, that node's `variables` must gain the new node's
  selector -- with the new node's REAL output variable, which differs by type
  (`llm` publishes `text`, `template-transform` `output`, a tool
  `text`/`files`/`json`), and the aggregator's existing selectors as the
  anchor. A hardcoded name would have recreated the very bug this clause
  fixes: `VariableAggregatorNode._run` skips a selector it cannot resolve in
  silence, which is triage cause (d), the one that reports "All checks
  passed" on empty output;
* the redaction sentinel is never handed back.

One consolidated test, not four substring assertions on a prompt constant: a
bare `assert "<phrase>" in _OP_SCHEMA` pins a wording and asserts nothing, so
the clauses with behaviour behind them are checked against that behaviour --
`_resolve_path` for indexed-vs-appended paths, graphon's `Condition` for the
operator literals. The operator guard parses `_COMPARISON_OPERATORS` and
compares the tuple to graphon's `Literal` exactly, in both directions, since
a substring check over 3kB of prose matches `=`/`is`/`in` anywhere and could
only ever catch an addition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:14:10 -07:00
yyh
c9e59a5590 fix(web): use concise accessible names for infotips (#42751)
Co-authored-by: zhsama <torvalds@linux.do>
2026-09-23 01:02:21 +00:00
Charles Yao
2458bffd23 fix(dify-builder): the edit agent sees the nodes it is asked to change
The Edit prompt showed each node as title + type + handles and never its
`data`, so an LLM asked to add one if-else branch had nothing to copy from:
it regenerated the whole `cases` array, silently turned `= 60` into `>= 60`
and dropped `id` and `varType`. `diff_graphs` reported the lot as one line,
"cases updated". Triage edit-branch-failure-2026-09-22 cause (c).

`_graph_context` now inlines the JSON `data` of the nodes impact analysis
named, plus their direct neighbours -- and one hop further past a BRANCH
target's successors, because a branch's arms reconverge. In the live
failure the if-else fed two template-transforms that fed a
variable-aggregator, and that aggregator's `variables` is what says whether
the new arm reaches the End node at all; at one hop it rendered as a bare
one-liner the model could not extend, which is cause (d) and ends in "All
checks passed" on empty output. The extra hop is taken only past a branch,
so a long linear chain does not drag the whole graph into the prompt.
Every other node keeps today's one-line form.

Keys still sitting at their type's default are subtracted, with
`node_defaults` as the sole source of what "default" means: without that an
http-request node's two dozen method/auth/timeout/retry keys crowd the
field the edit is actually about out of the prompt. A config over the
1500-character budget sheds whole trailing keys and then names them, rather
than cutting the JSON mid-string where a purpose field that happened to
serialize last would vanish without a trace.

That subtraction is also what would have un-hidden every credential in the
node: a live bearer token never equals the `no-auth` / `""` default, and
Edit renders the target AND its neighbours, so the exposure would be wider
than Fix's one culprit node. `credentials.redact_node_config` therefore
withholds the `authorization` config and every non-empty `headers` /
`params` value behind an exact sentinel, keeping the KEYS visible so the
model can still reason about which header exists. Every non-empty value,
not the suspicious-looking ones: deciding by name means a denylist, and a
denylist misses `X-Company-Token`.

Fix had the same leak and it is closed here too, not scheduled: a failing
http-request node is one of the likeliest culprits there is, and
`fix._culprit_config` inlined its whole `data` raw.

Redaction alone would be worse than the leak -- the model reads the
placeholder, rewrites `headers` around it and destroys a working
credential -- so `validate_intent_args` refuses any `value` or `config`
carrying the sentinel. That is the one chokepoint both the
`filter_applicable` dry run and `apply_repair`'s write loop pass every
intent through, so the write is rejected with a reason the existing
corrective re-prompt can read and the stored secret stands. The refusal is
keyed on the exact generated string, never on model prose. It also covers
`create_node` / `insert_between`, which carry node data by the same route.
A rewrite that merely OMITS the credential line still passes, and the
comment beside the guard records why that is accepted: it fails loudly at
the next verify run, where the leak closed here was silent.

`build_edit_intents` grows two keyword-only, defaulted parameters:
`edit_target_node_ids`, threaded from `fc.edit_target_node_ids` by
`handle_plan_approval` and used here, and `last_edit_rejection`, declared
but not yet read so the task that feeds a refused write back into the
prompt does not have to change this method's arity a second time. Both
default to empty, so an agent that passes neither gets today's behaviour.

`edit_target_node_ids` is typed `Sequence[str]` rather than `list[str]`:
mypy rejects `list[str] = ()` as an incompatible default, and widening the
annotation keeps the `()` default and the keyword-only shape intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:58:29 -07:00
Charles Yao
71cb5688ac fix(dify-builder): a default may fill a blank, never invent a purpose
The question-classifier default shipped two `classes` with blank names. That
turns a loud preflight refusal into a node that is written, reported as a
success, and silently classifies into two meaningless categories -- and the
fabricated ids `["1", "2"]` pin `declared_branch_handles`, so a semantic
`connect` is dropped while the create survives. That is the failure shape the
registry exists to eliminate.

Draw the line explicitly and apply it to every entry: a default may supply a
field whose empty value is semantically NEUTRAL -- a container, a mode, a config
or auth block -- but never one that carries the node's PURPOSE, where an empty
value produces a node that RUNS and yields nothing. Dropped, with the engine
behaviour each empty value would have produced:

  end.outputs                  End runs OK, so run_finished_without_output is
                               False -> "All checks passed" on an empty result
  answer.answer                streams nothing
  template-transform.template  renders ""
  variable-aggregator.variables  SUCCEEDED, outputs={} -- triage cause (d)
  question-classifier.classes  two blank categories + fabricated handle ids
  question-classifier.query_variable_selector  classifies `query or ""`
  knowledge-retrieval.dataset_ids  retrieves from no dataset, succeeds empty

The whole `if-else` entry goes too, though nothing is gained or lost by it: its
one case has an empty `conditions` list that graphon combines with `all([])`, so
it always matches -- but `iter_cases()` synthesizes that identical case when
`cases` is absent, so dropping the entry does not cure it. It only stops us
writing a fabricated always-true condition that makes the node look configured.

An empty value that fails LOUDLY is not in that category: there is no silence to
protect against, and dropping it would pay the full batch-refusal cost for
nothing. Verified loud, so these stay -- `llm.model` (never launches, "model not
configured"), `llm.prompt_template` (empty messages are filtered out, leaving
zero, so graphon raises `NoPromptFoundError`), `code.code` (the sandbox runner
calls `main(**inputs_obj)` with no `main` defined), `code.outputs` (graphon
raises `CodeNodeError("Not all output parameters are validated.")` for any key
the code returns), and `http-request.url` (`Executor._init_url` raises
`InvalidURLError("url is required")` before any request goes out).
`template-transform.variables` stays too -- it is structural, and it is the
exact field the live F4 failure was missing.

The placeholder agent genuinely means "no dataset, no output", so it now says
so itself instead of borrowing a default that would say it for every caller.

The engine sweep is re-pinned in both directions: the fields the engine reports
missing from a node built from a default ALONE must be exactly the purpose
fields the registry refuses to fabricate -- no more (a structural regression)
and no fewer (a silent-success regression).

Also: name the `filter_applicable` test after what it actually asserts, since
that function does not return its working graph; and reword the comment in
`graph_normalizers` that quoted a bare `type` marker, one reflow away from
being read as a type comment and silently killing the type check again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:28:05 -07:00
Charles Yao
9871fe7b88 fix(workflow): a prose comment must not open with a type-comment marker
A comment line introduced in 342cfc8545 wrapped so that it began with
``# type: number"``, which mypy reads as a PEP 484 type comment. It fails
to parse, mypy reports "Invalid syntax" and then stops — "errors prevented
further checking" — so the whole module and everything downstream of it
went unchecked. No runtime effect; the rewrap moves ``type:`` off the
start of the line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:53:39 -07:00
Charles Yao
9f766e876a fix(dify-builder): a created node carries the fields its type requires
An Edit LLM writes only the fields it was thinking about. graphon requires a
handful per node type with no default, so `create_node{template-transform,
{title, template}}` produced a node the draft preflight refused -- and the
refusal killed the WHOLE repair batch, parking the session at
`edit.plan_approval` with an identical card on every approval (triage
`edit-branch-failure-2026-09-22` cause (b)).

Extend the node-type default registry to every type the Build/Edit/Fix agents
may create -- template-transform, code, http-request, variable-aggregator,
answer, question-classifier, if-else, tool -- each taken from the matching
`web/app/components/workflow/nodes/<type>/default.ts` and each cited alongside
the graphon field it covers. Add `default_config_or_empty`, a non-raising
lookup: a type the registry does not cover contributes no defaults rather than
aborting the edit that is creating it.

Merge those defaults UNDER the caller's config at `graph_ops._build_node`, the
one construction point every created node passes through. A field the caller
supplied always wins; only a missing one is filled. That covers Build, Edit and
Fix, `create_node` and `insert_between`, and the `filter_applicable` dry run,
so the dry run sees exactly the node the port will write.

Defaults fill MISSING required fields; a field that is present but WRONG stays
`graph_normalizers.heal_nodes_for_preflight`'s job.

Two editor-only keys are deliberately not copied. `_targetBranches` (if-else,
question-classifier) is frontend state the canvas regenerates from
`cases`/`classes`, which is also where `declared_branch_handles` reads handles
from. A caller-supplied `cases`/`classes` wins the merge while a default
`_targetBranches` would not, so shipping it would attach a stale branch list to
every Builder-created branch node.

Move the registry from `services/dify_builder/` to `core/dify_builder/`: it was
imported by `core/dify_builder/placeholder_agent.py`, against the constraint
that `core/` must not import from `services/`. The dependency now points the
legal way, and the module stays pure -- copy, typing and graphon only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:52:09 -07:00
Charles Yao
342cfc8545 fix(workflow): an ASCII comparison operator becomes the engine's own
An LLM writes `>=` where graphon's `SupportedComparisonOperator` (and the
list-operator's `FilterOperator`) spell `≥`; pydantic refuses it at
`Graph.init`, so F4's whole Edit batch was rejected and the session parked
at `edit.plan_approval` with nothing written.

`_normalize_condition_list` -- the walker that already visits all five
condition containers and already holds `comparison_operator` in hand --
now canonicalizes the two ordering forms (`>=`→`≥`, `<=`→`≤`) BEFORE the
value rule, which keys on that operator.

Only the ordering forms. Every equality-family ASCII form is ambiguous:
graphon's string equality is `is`/`is not` (`_assert_is` takes `str|bool`)
and its number equality is `=`/`≠` (`_assert_equal` takes only
numbers/bools), so `==`, `!=` and `<>` carry exactly the ambiguity that
keeps `equals`/`gte` rejected. Ordering carries none: graphon has no string
ordering operator at all, so `>=` can only mean `≥`.

`heal_nodes_for_preflight` is the one shared definition of the
deterministic heals a graph gets before it is preflighted, so the write
chokepoint and the dry run that decides applicability cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:30:54 -07:00
Charles Yao
443de73258 fix(workflow-generator): an iteration publishes item and index, not just output
`_declares_variable` accepted `output` alone for a container, so a CORRECT
`{{#node4.item#}}` written by a node INSIDE an iteration was read as
unresolved and silently rewritten to `{{#node4.output#}}` -- this item's
value replaced by the container's entire array. It fired on 9 of 9 live
attempts:

    Workflow generator: rewrote unresolved reference node4.item
    to declared output node4.output

Silently rewriting a reference the engine already resolves is the one thing
the repair path must never do. It is worse than the loud failure it
replaces, and it is the same contract violation this whole plan exists to
enforce: the normalizer may only turn a graph the engine REFUSES into one it
ACCEPTS.

Verified against graphon rather than assumed, and the two container types
turn out NOT to be the same shape:

  - `iteration` publishes THREE names. `output` is the node's own result
    (`outputs={"output": flattened_outputs}`, nodes/iteration/iteration_node
    .py:551/565/631), read by consumers outside. `item` and `index` are added
    to a deep COPY of the pool that only the child engine sees
    (`_create_graph_engine`, :827-828), so they resolve for a node inside the
    sub-pipeline. Scope is not enforced: the walker carries only the TARGET
    of a reference, never the node that made it. The alternative to accepting
    all three is the silent rewrite above, which is strictly worse than an
    out-of-scope `item` failing loudly at run time.

  - `loop` never publishes `item` or `index`. It adds each declared
    `loop_variables[].label` to the MAIN pool (nodes/loop/loop_node.py
    :279-286, visible inside and outside) and reports those labels plus
    `loop_round` as its outputs (:489-492), alongside whatever `data.outputs`
    already declares. Those names are now accepted. `output` stays accepted
    too although the engine does not publish a loop under that name:
    dropping it would turn graphs that generate today into failures, which is
    a tightening, not this fix.

Both container types leave `_sole_declared_variable` as a consequence, not a
separate decision: a node with three published names has no SOLE output, so
falling back to `output` is exactly the "which value did the workflow mean?"
that helper refuses to answer for every other multi-output type. An invented
name on a container is now reported instead of guessed. Every other
single-output type keeps the rewrite it has always had, which is what the
non-container test guards.

This is a LATENT defect that the gated snippet work made reachable, and it
ships together with that work: the container-output branch predates this
plan, but the plan is what made the generator write iteration bodies whose
inner nodes reference the per-item variable. It is a separate commit from
the JSON-Schema walker fix and reverts on its own.

Evidence: docs/superpowers/triage/live-verify-2026-09-22/FINDINGS.md section
B8, and `e0_required_probe.py`, whose `{#node4.item#}` UNRESOLVED_REFERENCE
disappears with this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:02:45 -07:00
Charles Yao
04f0374b59 fix(workflow-generator): a JSON Schema array is not a variable selector
`_collect_refs_in_data` read ANY two-element list of strings as a
`["node_id", "variable"]` value selector. An llm node's structured-output
schema carries `required: ["content", "speaker_notes"]`, so the walker
harvested a reference to a node called `content`, `_collect_unresolved_refs`
found no such node, and the whole generation aborted with

    UNKNOWN_NODE_REFERENCE: Reference {#content.speaker_notes#}
    points at unknown node 'content'

Arity 2 is the entire trigger -- a `required` of one name or three names
harvests nothing -- and `enum: ["formal", "casual"]` and
`type: ["string", "null"]` are exposed exactly the same way. The defect is
the walker's, not the schema's: the node builder was told to expose those
two names and wrote a perfectly valid JSON Schema.

This is a LATENT defect that the gated snippet work made reachable, and it
ships together with that work. The two-element branch is byte-for-byte
unchanged since before this plan; what changed is that a JSON Schema now
reaches the walker at all. `f466fd64f8` taught the llm snippet to enable
structured output -- before it, an llm node emitted no schema, so the hazard
was unreachable -- and `af6b0db106`'s rule 16 pins that schema to exactly the
names the plan declares, which makes the two-name case modal rather than
rare. Neither commit touched the walker, so neither can be reverted to fix
this; the fix belongs here.

`_NON_SELECTOR_LIST_KEYS` already gates a key's whole subtree out of selector
harvesting and is shared by all three walkers (harvest, rewrite, sys.query
normalize), so both halves of the fix are one line each there:

  - `structured_output` excludes an llm node's entire schema wrapper, which
    covers every keyword such a schema will ever grow;
  - `required` / `enum` / `type` exclude the schema keywords that hold string
    arrays wherever they appear, because the walker is generic and the next
    schema-bearing node type will not keep its schema under
    `structured_output` (a tool's `output_schema`, say).

Placeholder harvesting is untouched -- `allow_selector` gates only the
selector reading -- and so is the 3-segment
`["node2", "structured_output", "field"]` selector, which is a list VALUE
under some other key, not a subtree under the `structured_output` KEY.

The regression guard is the point of the test set: a genuine
`[node_id, variable]` selector sitting beside the schema in the same node's
data is still harvested AND still validated. An exclusion that swallowed real
references would quietly undo the walker's whole job.

Reproduced and fixed deterministically, with no LLM: see
docs/superpowers/triage/live-verify-2026-09-22/FINDINGS.md and its
`e0_required_probe.py`. The test asserting the byte-for-byte production error
string is gone fails before this change and passes after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:55:28 -07:00
Charles Yao
af6b0db106 feat(workflow-generator): the plan declares producer outputs, and an LLM node's are the engine's
Two halves of one contract, gated on the cmd+K owner together.

1. Node configs are generated by isolated parallel LLM calls that share the
plan but not the output names each producer chooses, so a consumer
references `node3.deck` against a code node that declared `slides_json`
and the whole generation dies on UNRESOLVED_REFERENCE. The planner now
declares `node_outputs` -- `{"<node id>": ["<name>", …]}` -- for the only
node types whose output names their own config chooses: code (`outputs`
keys), parameter-extractor (`parameters[].name`), human-input
(`inputs[].output_variable_name`) and an llm meant to emit structured
output (its schema fields).

It reaches the builders the way `start_inputs` already does, but through
one type-aware section rather than the shared plan JSON: the producer is
told to expose exactly those names, every other builder is told they are
the only names it may reference on that node, and an llm producer's names
are listed as `structured_output.<field>` -- serialising them raw would
hand every consumer the one reference form the engine cannot resolve.

The declaration is BOUNDED in the prompt -- at most 6 names per node and at
most 10 declared nodes. cmd+K sign-off consideration: this is an unbounded
map added to the stage with a known live truncation failure (ESQ1-300:
planner output-budget exhaustion, masked by `json_repair`), and the
standing finding there is that `_PLANNER_DEFAULT_MAX_TOKENS` must not move
before the stage is instrumented. Bounding the declaration is the
alternative: the fail-open parse below already makes a truncated or absent
value harmless, so the bound costs nothing a plan needs.

Fail-open throughout: the value comes from an LLM, so `_parse_node_outputs`
keeps only lists of referenceable names (the run time's own
`[a-zA-Z_][a-zA-Z0-9_]{0,29}` grammar) keyed by a planned node of a
declaring type -- honouring a declaration on, say, an http-request node
would point consumers at a name the engine never exposes. A plan without
the key, or with a garbage one, produces byte-identical prompts and
behaviour to before.

2. `_declares_variable`'s llm branch was wrong in both directions. graphon's
`LLMNode._build_run_outputs` always publishes `text`, `reasoning_content`,
`usage` and `finish_reason`, adds `files` when the node saved any, and adds
`structured_output` -- one object, addressed as
`{{#<id>.structured_output.<field>#}}` -- only when structured output is
enabled with a schema. The branch accepted `text` plus any flat
structured-output SCHEMA PROPERTY, and rejected every other real output.

So a legitimate `{{#node2.reasoning_content#}}` failed the generation while
`{{#node2.answer#}}` -- which the engine can never resolve -- was waved
through, saved clean by cmd+K and died at run time. Removing that flat
acceptance is deliberate: a silently broken graph becomes a loud generation
failure. `test_runner_missing.py`'s assertion that froze the old behaviour
is rewritten with the reason.

Because a consumer only ever sees its OWN node's schema snippet, the shared
builder head now names an upstream llm's real outputs and both taught
reference forms, and the reference walker harvests the 3-element
`["node", "structured_output", "field"]` selector it teaches -- previously
invisible, so an end node pointing at a drifted llm passed validation
silently while the equivalent placeholder was repaired. Both forms now
travel one path: validated by `_declares_variable`, repaired to the node's
real output when the node turns out to be schema-less. The harvest is
deliberately type-blind -- only an `llm` publishes a `structured_output`
object, so the selector aimed anywhere else cannot resolve and has to be
REPORTED, `["start", "structured_output", "x"]` being exactly the invented
reference this walk exists to catch. The `llm` check sits on the REWRITE
instead, which is where reading a plain 3-string list as a selector did
damage: an end node's `["n2", "structured_output", "x"]` data tag was
collapsed to `["n2", "a"]` against a code node, a silent edit where a
report would have been loud and recoverable.

`_sole_declared_variable` reads `_llm_is_schema_less` (the enable flag AND
the schema) rather than schema presence alone, so the "flag off, leftover
schema" drift still repairs an invented name to `text` instead of failing
the build; a real structured-output node still refuses to guess.

3. One behaviour change beyond those two halves, and it is deliberate: a
DOTTED reference is now validated by its ROOT segment, the way
`VariablePool.get` resolves one. A `file` start variable's attributes
(`{{#node1.doc.url#}}`, the `FileAttribute` set), a `json_object` start
variable's keys and an object output's keys stay valid -- a code node whose
output really is called `structured_output` keeps its 3-element selector.

On the start node, a dotted name whose root addresses something the pool
cannot traverse is REPAIRED to that root -- `{{#start.docs.url#}}` becomes
`{{#start.docs#}}` -- on the same path as every other reference repair, so
it shows up in the change log. That covers `file-list`, whose
`ArrayFileSegment` subclasses `ArraySegment` rather than `FileSegment`, so
`{{#node1.docs.url#}}` renders as its own literal text at run time instead
of resolving, and every scalar input. The dotted form never resolves and
the root form always does, which keeps this inside the normalizer's
contract; reporting it instead would abort the whole generation, and cmd+K
`/create` / `/refine` never retry. A root that names NOTHING is a genuinely
invented reference with no value to fall back to and is still reported. It
is never injected as a start variable either way: the run time's grammar
has no dot inside a segment, so a form input literally named `topic.url`
could never resolve.

That repair reads the start node's declarations, and the same walk writes
them, so the walk is now TWO passes over one deterministically ordered list
(`_ordered_refs`): every missing start input is declared first, and only
then is each dotted root evaluated against the completed set. One pass over
the harvested SET made the outcome depend on which reference it yielded
first -- a graph carrying both `{{#start.docs#}}` and `{{#start.docs.url#}}`
with `docs` undeclared repaired cleanly under `PYTHONHASHSEED` 0-6 and
aborted on UNRESOLVED_REFERENCE under 7-9, i.e. the abort this repair exists
to remove, arriving at random. `_collect_unresolved_refs` walks the same
ordered list so a given graph always yields the same error list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:18:42 -07:00
Charles Yao
0927c22204 test(workflow-generator): every node snippet validates against the engine
Parses the JSON example out of every _NODE_SNIPPETS entry, wraps it as a
node config, and asserts core.workflow.node_factory.validate_node_config
accepts it -- the same call services/dify_builder/preflight.py makes.
FINDINGS.md's Cross-cutting section named this the single highest-value
missing test: before Task 4 (4deede53b5), it fails on parameter-extractor,
whose query example was an array of value_selector arrays where the engine
wants one selector; it only passes with Task 4's fix applied (RED/GREEN
verified by temporarily reverting that one snippet and restoring it).

There is no allow-list of entries excused from validating: every
_NODE_SNIPPETS entry validates standalone, so the parametrize covers all of
them and a snippet that stops validating fails loudly. (The iteration/loop
container snippets, which legitimately cannot validate standalone, live in
node_builder_prompts._CONTAINER_CONFIG_SNIPPETS -- a different dict, out of
this file's scope.)

No production change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:53:05 -07:00
Charles Yao
f466fd64f8 fix(workflow-generator): state an LLM node's real outputs, and the operators a filter takes
The llm paragraph claimed text was a schema-less node's only output; the
engine always exposes reasoning_content, usage and finish_reason too, so
the generator was being told to avoid valid references. The list-operator
snippet now carries the comparison-operator rule itself, because each
node build sees only its own snippet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:51:26 -07:00
Charles Yao
928bf7ade0 fix(workflow-generator): node snippets match the engine's schemas
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:50:18 -07:00
Charles Yao
a2950c7845 fix(dify-builder): the build-failure card names the node each error came from
The retry instruction already named it; the card the user sees did not,
so an error like "Tool acme/render is not installed for this tenant"
left them guessing which node it meant. Both now render through one
formatter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:50:18 -07:00
Charles Yao
4ff4b3679f fix(dify-builder): the build retry feeds back every error with a fix for it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:50:18 -07:00
Charles Yao
3a52bc18f4 refactor(workflow-generator): one reader for an LLM node's structured-output schema
The schema-properties lookup was written out three times; the new
schema-less gate made it four. It now has one reader. The
structured_output_switch_on branch is dropped: the engine serialises
structured_output_enabled as the wire/JSON key, and switch_on is only
that field's internal attribute name -- it is never a dict key a raw
graph carries, so the read (and the test reinforcing it) was dead
against real data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:50:18 -07:00
Charles Yao
2411d12441 fix(workflow-generator): an invented LLM output name resolves to text
Extend the shared _OUTPUT_ALIASES table so a reference to
__structured_output__ / __structured_output / structured_output / output /
result on an llm node with no usable structured output (the enable flag --
structured_output_enabled or its alias structured_output_switch_on -- is
falsy, or the structured_output schema is empty) resolves to the node's
real default output, text, instead of staying UNRESOLVED_REFERENCE. Gated
in _aliased_output so an LLM node that genuinely has structured output
enabled with a schema is left untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:50:18 -07:00
Charles Yao
f2444b3ec1 fix(workflow): a parameter-extractor query is one selector, not a list of them
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:50:18 -07:00
盐粒 Yanli
0f6a6227f5 feat(agent): add output routes to Agent v2 (#42599)
Co-authored-by: zhsama <torvalds@linux.do>
2026-09-22 21:28:07 +00:00
Charles Yao
3cbf4560f8 fix(dify-builder): a malformed http authorization can't crash the preflight
A generated http-request authorization without "type" made graphon's
HttpRequestNodeAuthorization.check_config read values.data["type"] and raise
KeyError; pydantic does not wrap it and validate_node_config only catches
ValueError, so it escaped preflight_errors -> apply_repair and the live
Build session ended in state=failed.

- preflight_errors catches any exception per node and reports it as
  "node '<id>' (<type>): <ExcName>: <message>" (ValueError messages are
  unchanged), then keeps checking the remaining nodes.
- The shared http-request normalizer (called by both the generator
  postprocess and apply_repair) fills a missing authorization type:
  "api-key" when config carries a non-empty api_key, else "no-auth" with
  config None, and reports the node as changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:15:17 -07:00
Charles Yao
77a1213bf7 fix(dify-builder): tighten secret matching, credential keys and host templates
Final-review minors:
- is_user_supplied_secret: a verbatim match needs a remainder of at least
  8 characters, so "Bearer key" no longer counts as supplied because the goal
  says "key".
- is_credential_key: Ocp-Apim-Subscription-Key ("subscription" key prefix),
  and cookie / credential / credentials / bearer as last segments. No
  "header" segment -- accept_header stays non-credential.
- A bare Bearer/Basic/Token word is kept as an auth scheme only on an
  Authorization / Proxy-Authorization line; under any other credential key
  (X-Auth-Token: token, api_key: token) it is replaced whole.
- endpoint_variable_names also collects a start template inside the parsed
  host (https://{{#s.host#}}/v1), via the shared core/dify_builder/urls.py;
  path/query templates stay mockable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:10:57 -07:00
Charles Yao
378887d88b fix(dify-builder): a host the user typed without a scheme is not invented
url_hosts only sees http(s):// URLs, so a goal saying "our renderer is at
api.pptrender.io/v1/render" (or a requirement value typed the same way) did
not count: build grounded the URL anyway and the requirements scrub blanked
the LLM's https://api.pptrender.io/... default. is_user_supplied_host now also
accepts a dotted host that appears verbatim (case-insensitive) in the trusted
text; both is_user_supplied_url and the scrub's URL check use it. A single
label ("localhost", "renderer") never matches a stray word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:08:33 -07:00
Charles Yao
563afc60ca fix(dify-builder): a templated path or query no longer hides an invented host
is_user_supplied_url exempted ANY url containing a Dify template, so
https://api.pptrender.io/v1/render?topic={{#node1.topic#}} (a host the user
never typed) was left in place and every test run hit a host that does not
exist. Only a template in the HOST position (the url starts with one, or its
host contains one) exempts a url now; a literal host is checked as usual.

Grounding such a url replaces only scheme://host[:port] with the start
variable (labelled "<title> base URL") and keeps the path, query and the data
templates in them verbatim; a url whose host is already a template is never
re-grounded, so a second pass is a no-op. The host-position rule lives in the
new stdlib-only core/dify_builder/urls.py (urlsplit reads a template's '#' as
a fragment), shared with endpoint_variable_names' side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:06:32 -07:00
Charles Yao
e54a2cbd2a fix(dify-builder): ground only credentials and host endpoints, never data inputs
The test form and mock data skipped every start variable an http node
read, so a search query or a language header was never mocked. Only a
host-position URL template, credential-keyed header/param lines and an
api-key auth now count, with one shared is_credential_key in core. A
bare "key" param, an empty credential and a bare "Bearer" are grounded
too, and an existing Dify template is never re-grounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:43:00 -07:00
Charles Yao
e741ce902b fix(dify-builder): a credential the user didn't supply becomes an input
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:22:46 -07:00
Charles Yao
ea46a69040 fix(dify-builder): an endpoint the user didn't supply becomes an input
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:07:02 -07:00
Charles Yao
a73c443ecd fix(dify-builder): scrub only real credential keys and exact URL hosts
Keys are credentials by their last segment (api_key, access_token,
Authorization), not by containing "token" or "password", so
password_policy / token_limit defaults survive. URL hosts stop at the
first non-URL character and drop trailing punctuation, so a goal that
names a host at the end of a sentence or next to CJK text is recognised.
Placeholder tokens like <…> only count under a credential key or an
auth scheme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:52:37 -07:00
Charles Yao
be24b00a2a fix(dify-builder): requirements analysis stops inventing endpoints and credentials
S5b (local E2E 2026-09-22, F2): analyze_goal's "a sensible default value per
field" led the LLM to prefill render_api_url=https://api.yourcompany.com/...
and Authorization: Bearer YOUR_API_KEY for a goal that named neither -- caught
only because "yourcompany" happened to match an existing placeholder-URL
regex; a plausible invented host would slip through and recreate ESQ1-302's
launch failure one step upstream, in the requirements form itself.

Adds services/dify_builder/agent/user_supplied.py (pure, no I/O): url_hosts,
is_user_supplied_url, CREDENTIAL_PLACEHOLDER_RE, is_user_supplied_secret,
trusted_text_for -- shared primitives for telling a literal the user actually
wrote from one an LLM invented, used by this task and consumed by later
tasks in the same plan.

build.analyze_goal now tells the model never to invent such a field's
default, and _scrub_invented_defaults deterministically blanks any top-level
field whose value contains a URL host, or a credential-shaped or
credential-keyed literal, not present in the goal text -- decided on hosts
and literal text, never on LLM prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:40:33 -07:00
xuan
a068c47ea9 refactor: pass db.session explicitly to get_message_data (#37403) (#42753) 2026-09-22 15:22:41 +00:00
Miku_Tog
bacf0c761f refactor(api): pass active session to from_db in ApiToolManageService… (#42602) 2026-09-22 15:10:11 +00:00
Stephen Zhou
3a017e8ccc perf(ci): reuse coverage XML totals for API summary (#42745) 2026-09-22 11:31:21 +00:00
Stephen Zhou
1fc040183c chore: replace tsx with native Node TypeScript execution (#42742) 2026-09-22 11:23:20 +00:00
Byron.wang
5b6e850b25 refactor(api): thin console app controllers (#42511) 2026-09-22 11:23:19 +00:00
Byron.wang
ce2eaab9f7 refactor(api): move app-scoped end users behind application services (#41398) 2026-09-22 11:20:45 +00:00
Coding On Star
9badde4d65 fix(marketplace): support single-page carousel navigation (#42743)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-09-22 11:12:18 +00:00
Xiyuan Chen
dfc3bbe513 feat(openapi): publish the op catalog, per-mode run ops and server-built hints on /openapi/v1 (#42487)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-22 10:44:50 +00:00
Stephen Zhou
dced031c95 fix(web): remove extra divider beside home navigation (#42738) 2026-09-22 10:29:03 +00:00
Charles Yao
fc0f5cf8fd test(dify-builder): real resource shapes and placeholder-endpoint coverage
- bind_resources fixtures use the production model label, the full
  provider/model id (langgenius/tokener/tokener/deepseek-v4-flash), and the
  real Code Interpreter id (code/simple_code); the docstring's ESQ1-302 quote
  now shows that shape too.
- The run_test prefill uses without_endpoint_values instead of re-evaluating
  endpoint_variable_names(graph) for every key.
- _ground_placeholder_endpoints: two placeholder http-request nodes get two
  start variables, and a start variable that already exists is not duplicated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 03:02:35 -07:00
Charles Yao
f826cb8227 fix(dify-builder): "use mock data" leaves the endpoint for the form
The Build, Edit and Fix mock call sites passed generate_mock_inputs through
unfiltered, so an endpoint variable got a mocked URL. The engine accepts any
URL at launch; the http-request node then fails on connection, which
is_input_failure reads as a config failure, so the flow fed the config-repair
loop (the ESQ1-302 thrash). without_endpoint_values drops the endpoint keys at
every mock call site: the missing required key now fails the launch as
"<var> is required in input form", which routes back to the test-data gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:59:53 -07:00
Charles Yao
21aff66f45 fix(dify-builder): bind a tool by its id and pin it in the generator
The planner is told to name a tool by its id, but binding matched only the
label and then the "tool" keyword, so two tool steps naming ids both bound
to the first tool step. _covering_step now tries the resource id before the
label. A tool's "(using ...)" suffix also carries its provider/tool id in
ASCII brackets: the shared generator pins a tool only on that identifier
(tool_catalogue._find_explicit_tool_keys), never on the label. The planner
prompt asks for the id as a separate word, since a CJK-glued id never pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:56:46 -07:00
yyh
509f3407a5 chore: enforce erasable syntax in shared tsconfig (#42735) 2026-09-22 09:53:25 +00:00
Joel
963a28b497 fix: improve accessibility in legacy knowledge creation (#42726) 2026-09-22 09:37:03 +00:00
Charles Yao
b1ff7c1596 fix(dify-builder): match resource labels and node types as whole words
"tool" matched "toolkit" and "llm" matched "fulfillment", binding a
resource to an unrelated step instead of falling back. Labels and
keywords now match case-insensitively on ASCII-alphanumeric boundaries,
which still matches CJK step text such as "llm节点".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:35:16 -07:00
Charles Yao
763f93ebc9 fix(dify-builder): re-binding resources replaces the old suffixes
A loop-back feeds the already-bound plan back into bind_resources,
which only appended: a deselected resource kept its "(using …)" and
suffixes piled up in the text handed to the generator. Trailing
"(using …)" groups are stripped before binding, so a re-bind reflects
exactly the current selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:33:46 -07:00
Charles Yao
681f92557d fix(dify-builder): bind each resource to the plan step it covers
bind_resources appended every confirmed resource's label to the LAST
plan step: the ESQ1-302 end-node step read "(using deepseek-v4-flash,
Code Interpreter)" while the step that needed a tool said nothing, and
that is the text the generator was handed. Each resource now lands on
the first step naming its label, else the first step naming its kind's
node type (llm / knowledge-retrieval / tool); the last step is only the
fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:22:32 -07:00
Charles Yao
da338dd5fd fix(dify-builder): only a localhost host marks an endpoint as a placeholder
_PLACEHOLDER_URL_RE matched "localhost" as a substring anywhere in the
URL, so a real tunnel host like abc123.localhost.run was wrongly
flagged; the check now parses the URL and only matches when the host
itself is localhost or ends with .localhost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:16:28 -07:00
Charles Yao
291651b747 fix(dify-builder): the test form asks for an endpoint instead of mocking it
A placeholder endpoint is re-pointed to a required start variable, but
the test-data form pre-filled every text field with a mocked value, so
one click ran against another invented URL. Start variables an
http-request node reads its URL from now stay empty, like upload fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:16:20 -07:00
Coding On Star
db9b9b8993 fix: download preview images instead of opening a new tab (#42419)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: CodingOnStar <hanxujiang@jianghanxudeMacBook-Pro-2.local>
2026-09-22 09:11:19 +00:00
Charles Yao
e4a30b7be1 feat(dify-builder): a placeholder endpoint becomes a required start variable
The ESQ1-302 draft called https://api.example.com/ppt/generate. The
model had no endpoint, so it invented one, and every test run failed
against a host that does not exist. build_nodes now re-points an
http-request whose URL is empty or a placeholder (example.com,
your-api, <your-domain>, {tenant}, localhost) at a new REQUIRED
start-node variable "<node>_url", so the test-data gate asks the user
for the real endpoint -- or, when they have none, says what the step
needs. A Dify template reference is not a placeholder.

Builder-only: the shared http-request snippet's own example is
https://example.com, and cmd+K keeps its placeholders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:00:19 -07:00
twwu
17c6ef4f7a Merge branch 'main' into feat/dify-builder 2026-09-22 16:59:48 +08:00
Charles Yao
6bd32a733e feat(dify-builder): show the planner the tenant's ready tools
propose_plan_v1 was shown REQUIREMENTS only. It cannot plan a tool step
for a tool it has never seen, so the ESQ1-302 plan said "http-request
节点:调用外部PPT生成API" while the tenant had a ready Markdown -> PPTX
tool -- and every later stage (resource recommendation, the shared
generator, the directive) took the plan's word for it.

ready_tool_catalogue lists the tenant's READY tools (an installed-but-
unauthorized tool cannot cover a step), sorted and capped at 40; the
planner prompt gains an "Installed tools you may name" section and tells
the planner to plan an http-request only for an endpoint the user
actually supplied. A listing failure degrades to the old no-catalogue
prompt, never a crashed advance. The positional signature is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:52:01 -07:00
Charles Yao
493d40591a fix(dify-builder): the directive no longer steers away from installed tools
fe3f8154b5 told the generator to use a tool node "only when the plan
names an installed tool; otherwise use 'http-request'". That contradicts
the shared planner prompt (INSTALLED-TOOL-FIRST), and the Builder's own
planner was never shown the tool catalogue, so it could not name one:
every third-party step fell through to an http-request with an invented
endpoint (ESQ1-302 called https://api.example.com/ppt/generate while
the tenant had a ready Markdown -> PPTX tool).

The rule now says tool-first, forbids invented and placeholder
endpoints outright, and keeps the half that earned its place (ESQ1-286):
an http-request's changing inputs belong on the start node.

Builder-only: nothing under core/workflow/generator/ changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:45:05 -07:00
Charles Yao
9d7acf4089 fix(workflow-generator): log every branch edge the repair re-homes
The inline repair the shared graph_normalizers replaced logged each
re-homed edge at info; the delegated version returns (and logs) only
the edges it could NOT re-home, so a successful re-home -- the planner's
"else" moved onto "false", a default-handle edge moved onto a class --
left no trace in the generator logs.

_repair_branch_edge_handles now snapshots each edge's sourceHandle (by
edge object) before the shared repair and logs every edge whose handle
changed (source -> target, old -> new) at info; the unresolved warnings
are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:35:43 -07:00
Charles Yao
7d400fdca8 fix(workflow): keep a failure-arm name off ELSE on fail-branch branch nodes
Two gaps in the fail-closed branch-handle repair:

- The failure-arm aliases lacked fail_branch / failbranch / error_branch
  / on_fail / fallback. A planner's "fail_branch" edge on a fail-branch
  http-request stayed unresolved (only the hyphenated spelling matched
  the declared handle exactly). They are looked up through _canon_name
  like every other name, so any spacing / hyphen / underscore spelling
  hits.
- On an if-else / question-classifier / human-input node with
  error_strategy fail-branch and that arm unwired, the fan-out rule
  eliminated an unrecognized name onto the one free branch arm (ELSE, a
  class, an action) -- even when the edge meant the failure arm. Rule 2
  no longer runs for such a node while its fail-branch arm is unwired;
  the edge is left for a validator instead. Once fail-branch is wired
  the rule applies as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:33:09 -07:00
Charles Yao
920011be82 fix(workflow): call condition value normalizers with one signature
_normalize_conditions called its value normalizer as (value, operator)
and, on any TypeError, retried it as (value) -- the fallback that let
the one-argument list-operator normalizer through. It also swallowed a
genuine TypeError raised inside a normalizer, replacing it with an
unrelated "missing argument" error, and needed two type: ignores.

normalize_filter_condition_value now takes (value, operator="") and
ignores the operator (a FilterCondition scalar is never list-wrapped),
so both normalizers share Callable[[Any, str], Any]; the walker calls
it once, with no try/except and no type: ignore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:31:17 -07:00
Charles Yao
9218d1583e fix(dify-builder): record intents a first build drops on a partial reject
build_nodes dry-runs the generator's intents through filter_applicable
and recorded the rejections only when NOTHING applied. A partial reject
-- e.g. a connect on a branch handle the node does not declare, one the
generator postprocess could not re-home and apply_connect now refuses --
kept the applicable intents and dropped the rest with no log line and no
diagnostic, leaving that arm unwired on the canvas without a trace.

A partial reject now logs a warning naming each dropped op, its args
(minus a create_node's config body) and the reason, and appends a
build_nodes diagnostic with the same list; the applicable intents are
still returned. The total-reject path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:29:43 -07:00
Asuka Minato
f117048f41 test: run message cleanup coverage on SQLite (#40715) 2026-09-22 08:29:00 +00:00
Charles Yao
6b6f50d412 fix(workflow-generator): say what the branch-handle repair does now
The _repair_branch_edge_handles docstring claimed an ambiguous edge is
left "so _validate_structure fails the graph closed" -- true only while
the INVALID_BRANCH_HANDLE check (its own, separately revertable commit)
is in. It now says the edge is left as it was, logged, and returned by
the shared function so a validator can fail closed when that check is
enabled. The comment above the call in _postprocess_graph still
described the old repair (if-else / question-classifier, default
handle only); it now names every branch type the shared repair covers
and the named-but-wrong handles it re-homes, fail-closed.

Comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:28:05 -07:00
Yunlu Wen
1ea81f33ff chore(deps): bump setuptools, pydantic-settings, guzzle for CVE fixes (#42717)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 08:27:58 +00:00
Charles Yao
a154d4b894 fix(workflow): declare "true" on a legacy if-else with no cases
graphon runs an if-else whose `cases` is absent or None (the legacy
top-level `conditions` shape) as one case with id "true"
(IfElseNodeData.iter_cases; if_else_node routes "true" when
uses_legacy_shape), and the canvas migrates it to case_id "true".
declared_branch_handles declared only "false" for it, so the engine's
own IF arm looked undeclared: apply_connect refused a connect on "true",
and with only "true" wired the fan-out rule re-homed the IF arm onto
ELSE in the generator postprocess.

The case ids now mirror iter_cases (legacy -> ["true"]; an explicit
`cases: []` stays case-less), and the one-case IF-alias rule counts the
legacy shape as one case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:26:54 -07:00
yyh
54cc748a36 feat(agent): add publish success guidance (#42645)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jingyi <jingyi.qi@dify.ai>
2026-09-22 08:17:07 +00:00
yyh
0cc53efe06 refactor(files): migrate supported format queries to generated contracts (#42727) 2026-09-22 08:14:04 +00:00
yyh
8253405c0b refactor(plugins): migrate readme queries to generated contracts (#42725) 2026-09-22 08:14:03 +00:00
wangxiaolei
7ed4ab0b32 chore: remove app and dataset detail rbac check (#42729) 2026-09-22 08:08:24 +00:00
Charles Yao
2692243792 feat(workflow-generator): bind branch-node ids to the planner's handles
The planner names a branch's outgoing source_handle values (rule 12) in
one LLM call; the node builder chooses the case / class / action ids in
another, and nothing told it the planner had already chosen. ESQ1-303's
if-else was built with case "true" against planned edges on
"score_equals_60" / "else", so both arms were skipped.

The builder already receives the planned edges with their handles in
"Normalized plan and topology". The if-else, question-classifier and
human-input snippets now state the contract: declared ids MUST be those
planned handles; the ELSE arm is implicit ("false") and is never a case.
human-input also gets a matching clause for its implicit timeout arm
("__timeout"), which must never be declared as a user action. Shared
with cmd+K /create and /refine; the postprocess repair stays as a
fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:01:18 -07:00
Coding On Star
c3d5b5fa4d fix(web): open Marketplace details externally for self-hosted editions (#42576)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-09-22 08:00:15 +00:00
Charles Yao
b2b306b3d0 feat(workflow-generator): reject undeclared branch handles and bad http bodies
_validate_structure fails closed on two defects that reached the canvas
silently: an edge leaving a branch node on a handle postprocess could
not re-home unambiguously (INVALID_BRANCH_HANDLE -- the arm would never
run while the run reports "succeeded", ESQ1-303), and a json / raw-text
/ binary http-request body with a number of items other than one
(INVALID_HTTP_BODY -- the executor rejects it at run time, ESQ1-302, and
no automatic collapse of several key/value items is correct).

cmd+K /create and /refine see these as structured errors with the node
id, like every other structural error; the Builder retries once with the
error fed back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:52:26 -07:00
Charles Yao
197cd9c5ad fix(dify-builder): teach Edit branch handles and retry a partial reject
apply_connect now rejects a connect from a branch node on a handle it
does not declare. Edit's prompt never mentioned source_handle and its
graph view showed no handles, so such connects defaulted to "source"
and a partial reject was dropped without a retry. The op schema and
graph view now carry the handles, and any rejection gets one corrective
re-prompt whose feedback names the declared handles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:43:43 -07:00
Charles Yao
cf99766d93 fix(dify-builder): count nodes the chokepoint normalizers healed as changed
apply_repair's value normalizers can heal a node the repair's intents
never touched; the diff showed it but changed_nodes did not. Both
normalizers' changed ids now join changed_nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:43:35 -07:00
yyh
a78f716216 fix(ui): preserve Base UI callback and ref contracts (#42721) 2026-09-22 07:31:59 +00:00
Charles Yao
d299a81a9f fix(dify-builder): normalize values at the write chokepoint; reject undeclared handles
apply_repair runs the same graph_normalizers the generator's postprocess
runs, for the intents the generator never saw: a Fix/Edit set_node_config
that writes a condition value as a JSON number (ESQ1-285's flip-flop) or
an http body item without type is healed before the preflight would
reject it.

apply_connect rejects an edge from a branch node on a handle the node
does not declare -- including the default "source" it used to fill in
-- with the same ValueError the handlers already surface as "Couldn't
apply the fix". Such an edge hangs off nothing and its arm never runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:27:44 -07:00
Charles Yao
1b0b7e77f2 fix(workflow-generator): apply the shared graph normalizers in postprocess
_postprocess_graph now runs core.workflow.graph_normalizers: condition
values written as JSON numbers become strings, if-else varType is
derived from a declared start-variable type, http-request body items
get the type BodyData cannot default, and _repair_branch_edge_handles
delegates to the shared fail-closed repair (so a named-but-wrong handle
such as "else" is re-homed, not only the default "source").

Shared with cmd+K /create and /refine: every change turns a graph the
engine would have refused into one it accepts, and none alters a graph
the engine already accepted. Nothing is rejected here.

Validated on the real ESQ1-303 dev draft: value "60", varType "number",
handles true/false, edge ids derived from the repaired handles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:18:17 -07:00
yyh
c1316dd30a refactor(web): migrate custom React icons to Iconify CSS (#42716) 2026-09-22 07:10:38 +00:00
Charles Yao
8adc33ea2f fix(workflow): keep branch-handle repair fail-closed for success, error and timeout arms
A missing sourceHandle is "source" to the engine, so a fail-branch node's
unkeyed success edges are valid and must not be re-homed. The error and
timeout arms exist because of the node's configuration, not because the
author wired a branch, so elimination and default assignment never guess
onto them; named edges reach them through explicit aliases instead.
Ambiguous names (duplicate class names, an alias that disagrees with a
stem match) now fail closed, the one-case IF aliases count case ids
rather than handles, and name keys fold _ - and spaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:06:52 -07:00
Charles Yao
36409bd14e feat(workflow): fail-closed branch-handle repair and declared-handle check
The planner names a branch's outgoing source_handle values in one LLM
call; the node builder chooses the case / class / action ids in another.
ESQ1-303's edges left its if-else on "score_equals_60" and "else"
against a case named "true", so the engine skipped both arms and the
run succeeded with nothing.

declared_branch_handles lists what a node routes on; repair_branch_edge_
handles re-homes an edge only when it is forced (exact / alias / name /
stem match, one arm fanning out under a single invented name, default-
source edges onto unused handles in order) and returns everything
ambiguous untouched; undeclared_branch_handles reports what is left so a
validator can reject it. A wrong guess would silently swap IF and ELSE.
Aliases are trusted as names -- the documented limit -- which is why the
node-builder handle contract, not this repair, is the primary fix.

A fail-branch node's success path stays on "source" (what graphon's
NodeRunResult.edge_source_handle actually emits by default), not an
invented "success" handle nothing ever routes on; a fail-branch
branch-type node (if-else / question-classifier / human-input) just
gains the extra "fail-branch" arm. human-input also always declares its
implicit "__timeout" arm, since every human-input node has a timeout
config and the FE always renders that handle; that arm is excluded from
the "unused declared handles" pool the fan-out/default-order repair
rules count against, since it is routinely left unwired by design and
must not block or absorb repair of the arms the author did configure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:47:24 -07:00
非法操作
27ea5e5790 refactor(api): migrate trial audio through shared app audio (#42672) 2026-09-22 06:42:30 +00:00
Charles Yao
967e3b3347 fix(workflow): an empty string http body has no item
graphon turns a falsy body.data into an empty list before the executor
counts items, so a json / raw-text / binary body whose data is "" fails
at run time. http_request_body_errors counted any string as one item;
only a non-empty string is one now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:36:34 -07:00
Charles Yao
115b47c066 feat(workflow): http-request body item type default and item-count check
graphon's BodyData.type (Literal["file","text"]) has no default and the
shared builder prompt only ever shows an empty "none" body, so the LLM
writes {key, value} items and the graph dies at Graph.init (ESQ1-302).
normalize_http_request_bodies fills the type (file when the item carries
a file selector, else text) and empties a "none" body.

The executor sends json / raw-text / binary bodies as ONE rendered
template. Two {key, value} items under a json body have no correct
automatic collapse -- quoting breaks a JSON-valued variable (ESQ1-302's
node3.text is JSON), not quoting breaks plain text -- so
http_request_body_errors reports the node instead of guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:31:16 -07:00
Charles Yao
82c11a5747 feat(workflow): derive if-else varType from a declared start-variable type
varType is a frontend-only hint the canvas uses to pick the operator
list; the runtime ignores it. Builder-made if-else nodes never carried it
(ESQ1-303), which steers the panel to string operators on a number and
reproduces ESQ1-285's runtime error. Derive it only when a start
variable's declared type makes it certain; never overwrite, never guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:23:12 -07:00
yyh
cb044daf60 refactor(web): use standard sizes for icon actions (#42684) 2026-09-22 06:20:45 +00:00
Charles Yao
e1b595ee22 fix(workflow): list-operator filter values follow FilterCondition, not Condition
list-operator's filter conditions are FilterCondition (value: str |
Sequence[str] | bool, default ""), and filtering an array of strings or
numbers requires a plain string. The normalizer treated them like
if-else's Condition: it wrapped an in/not-in scalar into a list and left
None alone, both of which graphon rejects. They now get their own rule:
None becomes "", numbers become text, nothing is wrapped. List items use
the same integral-float rule as scalars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:17:43 -07:00
Charles Yao
b00453949e feat(workflow): pure normalizer for LLM-written condition values
graphon's Condition.value is str | Sequence[str] | bool | None. The node
builder wrote "value": 60 for the ESQ1-303 draft; Graph.init builds every
node up front, so that one number stopped the whole workflow. pydantic
also coerces int 1 / 0 to True / False, so bool must be checked before
int.

core/workflow/graph_normalizers.py is the one place both the shared
generator's postprocess and the Builder's write chokepoint will call, so
the two paths cannot disagree. This commit adds the module with the
condition-value normalizer only (if-else cases, sub-variable conditions,
loop break conditions, list-operator filters); nothing calls it yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:07:55 -07:00
Charles Yao
05f255619d chore(dify-builder): export note_unknown_outcome from handlers_fix
Build and Edit import it from handlers_fix, but __all__ left it out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:01:01 -07:00
Charles Yao
3dea847521 fix(dify-builder): a re-fix from review starts a fresh unknown-outcome count
Build's and Edit's re_fix branches (handle_review's continue_adjusting,
handle_reverted's retry_after_revert) reset repair_attempts and
last_repair_error but not unknown_outcome_count. After the cap (count=2)
-> keep_draft -> review -> re_fix, the new cycle's FIRST unknown outcome
re-capped with "couldn't be determined twice in a row". Reset it at all
four sites, as Fix's re_fix already does.

Also pins that Edit's unknown-outcome cap drops a stale diagnosis and
staged repair (seeded, so the assertion is not vacuous).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:59:20 -07:00
Charles Yao
b7017aa363 fix(dify-builder): tell a draft that would not start apart from a stale fix
apply_repair's preflight raises PreflightError when the graph it is about
to write would fail at Graph.init. Every handler caught it as a plain
ValueError, so the five apply sites could not tell it apart from a
graph_ops rejection:

- plan approval (Build, Edit) called ANY ValueError "The workflow can't
  start", including "node not found: x", sending the user hunting for a
  broken node that does not exist;
- the repair-apply gates (Build/Edit await_repair, Fix apply) called a
  preflight rejection "The proposed fix no longer applies to the current
  draft" -- the fix applied fine; it would leave an unstartable draft.

Add core DraftWouldNotStartError(ValueError) and make PreflightError
subclass it (services imports core; core still imports no services).
Each site now catches it before the ValueError branch, with the same
control flow and the true reason: plan approval keeps "The workflow
can't start" for it and says "Couldn't apply the workflow" otherwise;
the repair gates say "The workflow can't start" for it and keep
"Couldn't apply the fix" otherwise. The shared card/turn construction is
factored (_graph_not_applied, _change_not_applied, and
drop_unapplied_repair in handlers_fix) and the new literals are
catalogued in strings.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:57:33 -07:00
yyh
26d4b9ead8 refactor(datasources): migrate catalog consumers to generated queries (#42713) 2026-09-22 05:55:00 +00:00
yyh
67ab356116 refactor(explore): migrate catalog consumers to generated queries (#42702) 2026-09-22 05:55:00 +00:00
yyh
4192299ad4 refactor(plugins): migrate agent strategy queries to generated contracts (#42696) 2026-09-22 05:54:59 +00:00
yyh
6d358eb680 refactor(plugins): migrate endpoint queries to generated contracts (#42685) 2026-09-22 05:54:58 +00:00
Charles Yao
6d85677006 fix(dify-builder): reset Edit's repair breaker when the user keeps adjusting
Build resets the breaker counter when the user re-enters the repair
loop from review or after a revert; Edit's equivalent branches did not,
so a count left over from an earlier cycle could trip the breaker one
round early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:32:52 -07:00
Charles Yao
b9ee13f0a9 fix(dify-builder): give Edit the repair breaker Build has
Edit's affected-path test diagnosed and staged a repair for the same
engine failure with no counter at all, so the loop 6877532f8e closed for
Build stayed open for Edit. The breaker (engine-keyed signature,
consecutive-repeat counter, MAX_REPEATED_REPAIRS) moves to handlers_fix
under public names, Build keeps its underscore names as aliases, and
Edit stops at its gate on the third identical failure. A new edit goal
resets the counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:25:00 -07:00
Charles Yao
ccb217d72f fix(dify-builder): a re-fix starts a fresh unknown-outcome count
Fix's re_fix action resets the rest of the repair loop's state but kept
unknown_outcome_count, so after the cap tripped the first unknown
outcome of the new repair cycle re-capped immediately instead of being
re-runnable once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:18:28 -07:00
Stephen Zhou
f7dbed5d86 ci: reduce Redis middleware readiness polling delay (#42710) 2026-09-22 05:16:23 +00:00
Stephen Zhou
93c8b0fe18 ci: prepare core E2E browsers alongside web builds (#42706) 2026-09-22 05:16:23 +00:00
Stephen Zhou
ab666bc448 ci: avoid redundant E2E dependency cache saves (#42695) 2026-09-22 05:16:22 +00:00
Charles Yao
dd11872222 fix(dify-builder): stop re-running after two consecutive unknown outcomes
The unknown-outcome branch (a stream that ended with neither a terminal
nor an error frame) returned the user to the re-run state with no
limit in all three flows. With error frames now reported as failures
that branch is rare, but a stream that keeps ending early could still be
re-run forever. DifyBuilderContext.unknown_outcome_count counts
consecutive unknown outcomes (reset by any determinate one); the second
in a row stops at the gate with a card telling the user to look at the
run itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:10:10 -07:00
非法操作
2da0db7df5 refactor(api): isolate trial app and workflow detail previews (#42659)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-22 05:03:49 +00:00
Charles Yao
a8e89d4583 fix(dify-builder): flag only a branch that routed nowhere as no output
The first cut flagged any succeeded run where a branch node ran and no
End did, which also caught a legitimate side-effect-only arm that ran
to completion, and treated any fail-branch node as a router. The rule
is now the ESQ1-303 mechanism itself: a branch node ran and none of its
edge targets ran (the engine skipped every arm), in a graph that has an
End that was never reached. The card points at that branch node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:59:37 -07:00
twwu
62fa7634b7 Merge branch 'main' into feat/dify-builder 2026-09-22 12:53:27 +08:00
Charles Yao
7712442959 fix(dify-builder): a run that took a branch and reached no End is not a pass
ESQ1-303: both edges of a Builder-made if-else used handles the node
does not declare, so the engine skipped both arms, reached no End node,
and reported "succeeded" with outputs={}. Every verify handler took any
"succeeded" as "All checks passed".

A succeeded run that executed a branch node (if-else, question-
classifier, human-input, or a fail-branch node) and no End node is now
"Finished without output": an error-toned test card plus a card pointing
at the branch node, waiting at the gate with no staged repair -- the
engine reported no error, so there is nothing to diagnose. The rule is
deliberately that narrow: requiring an End node for every succeeded run
would false-fail side-effect-only branches, which the structural
validator does not forbid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:46:12 -07:00
Charles Yao
a02c0fe3a8 fix(dify-builder): revert the canvas when plan approval is rejected
apply_repair streams a canvas marker per applied intent before its
preflight runs, so a rejected build left the client with markers for
mutations that were never written. The plan-approval catch now emits
revert_checkpoint: nothing was written and the draft is still the
checkpoint taken at approval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:37:52 -07:00
Yunlu Wen
6f61401a91 feat(agent): export agent trajectory with a separated tracer (#42651) 2026-09-22 04:35:33 +00:00
Charles Yao
181c5e37c3 fix(dify-builder): show a draft that would not start as a card, not a crash
apply_repair now rejects a graph Graph.init would refuse (PreflightError).
Build's and Edit's plan approval called apply_repair with no guard, so
the rejection would have escaped the handler and left the operation
interrupted mid-step. Both now catch it: the step is marked failed, an
error card names the node and the reason, nothing has been written, and
the plan stays at its gate -- re-approving regenerates the graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:29:35 -07:00
Charles Yao
fcb7c719ef feat(dify-builder): preflight the draft in apply_repair before writing it
ESQ1-302 and ESQ1-303 both died at Graph.init -- before workflow_started,
so the Builder saw an error frame with no run id and no rows, and spent
runs and approvals discovering what a validation pass could have said
up front. apply_repair now runs that pass (preflight_errors: the same
node-data validation create_node performs, with no factory or runtime
state) over the graph it is about to write and raises PreflightError,
naming each node Graph.init would reject.

Only NEW problems count: a pre-existing broken node the repair does not
touch must not veto an unrelated fix, and a repair that heals it passes.
PreflightError is a ValueError so the handlers' existing stale-intent
guards already surface it as a card.

The real ESQ1-303 dev draft is the fixture: preflight names node2 and
its numeric value, and passes once the value is a string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:21:17 -07:00
Charles Yao
f970c857c6 refactor(workflow): one validation sequence for create_node and validate_node_config
validate_node_config promises to raise exactly when Graph.init would.
That only holds while it and DifyNodeFactory.create_node run the same
validation steps, so both now call one helper instead of two copies.
No behavior or error-text change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:15:35 -07:00
yyh
404ba6a805 refactor(web): simplify equivalent regular expressions (#42703) 2026-09-22 04:08:37 +00:00
yyh
7a590a5b41 refactor: clear small lint suppression scopes (#42701) 2026-09-22 04:08:36 +00:00
Charles Yao
2fb51857ce fix(workflow): name the node in every node-config error
A graph-init failure surfaced as "2 validation errors for
HttpRequestNodeData ..." -- pydantic names the model class, never the
node. graphon logs the node id and re-raises the raw error, so the error
frame the Builder received (ESQ1-302) could not say which node was
wrong, and its repair agent had to guess.

DifyNodeFactory.create_node now re-raises every ValueError from the
validation stage (shared-shape validation, class resolution, concrete
NodeData re-validation) prefixed "node '<id>' (<type>): ". This runs for
EVERY workflow start, so the error text of every graph-init failure
changes, not only the Builder's. The wrapped error is a plain ValueError,
so app_generator's handling takes its ValueError branch (log only under
DEBUG) instead of the ValidationError branch; the published error is the
same.

validate_node_config runs the same three validation steps with no
factory instance and no runtime state -- the dry Graph.init a draft can
be put through before it is written or run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:07:44 -07:00
Stephen Zhou
01b64254de chore: revert "chore(deps): upgrade vinext and simplify CSS asset aliases" (#42700) 2026-09-22 04:03:42 +00:00
Charles Yao
87f00a8308 fix(dify-builder): keep Run.error in every verify handler
run_draft populates Run.error, and all three consumers threw it away:
Build and Edit assigned run_error = "" and Fix's Run(...) literal had no
error= at all. A launch failure therefore persisted with an empty error
(the long-open "TRUNCATED_STREAM_ERROR not persisted, err_len=0" item),
diagnose saw nothing, and the repair breaker -- which keys a launch
failure on run.error -- could never fire on one.

Fix's handle_verify also gains the try/except Build and Edit already
have, so a run_draft that raises degrades to a failed Run carrying the
launch error instead of killing the advance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:58:27 -07:00
Charles Yao
f862fe327e fix(dify-builder): a stream that ends on an error frame is a failed run
run_draft forwarded the pipeline's ``event: error`` frame to the frontend
and then dropped it, so the ESQ1-302 launch failure (Graph.init rejected
an http-request node before workflow_started) fell into the
truncated-stream path and was reported as "running". The handler's
unknown-outcome branch then sent the user back to re-run, forever.

The loop now keeps the first error frame aside and _finish_run applies
one precedence: terminal frame > error frame > nothing. Chatflow emits
workflow_finished(failed) AND an error frame for one failure, so the
terminal frame must win; a stream that ends with neither still reads as
an unknown outcome, never as a fabricated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:51:24 -07:00
yyh
fe09df3afb refactor(web): use Number.isNaN for numeric guards (#42692) 2026-09-22 03:51:14 +00:00
yyh
1ff8a5ff4f refactor(web): replace constructor parameter properties (#42691) 2026-09-22 03:51:13 +00:00
yyh
a50918dfe8 refactor(web): consolidate duplicate type imports (#42690) 2026-09-22 03:51:13 +00:00
yyh
86cfb29b28 refactor(web): replace suppressed string enums with const objects (#42689) 2026-09-22 03:51:12 +00:00
Charles Yao
d067b95bce fix(dify-builder): map an error frame to a failed Run with its reason
The streaming pipeline reports a run that threw as one ``event: error``
frame with top-level message/code and no data (ESQ1-302: Graph.init
rejected a node before workflow_started, so there is no run id either).
run_mapping had no representation for it: _TERMINAL_STREAM_EVENTS lists
only workflow_finished/paused, so the frame was neither terminal nor a
node event and the run came back "running" with the truncated-stream
notice.

error_from_stream_chunk reads such a frame; map_error_frame_run turns it
into a FAILED Run carrying the message (whitespace collapsed, so it is a
stable repair-breaker signature) and the code. map_run_result now keeps
the engine's run-level error text for a failed run instead of dropping
it -- a launch failure has no per-node rows, so that text is the only
evidence diagnose and the breaker have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:45:26 -07:00
Neo1Noir
a7b1151a2c fix(web): persist reranking_enable for Hybrid Search datasets (#42563) 2026-09-22 03:38:01 +00:00
非法操作
89ffe391be refactor(api): isolate trial preview queries and admission (#42644) 2026-09-22 03:35:02 +00:00
Stephen Zhou
6fa4c9458a chore(deps): upgrade vinext and simplify CSS asset aliases (#42688) 2026-09-22 03:33:25 +00:00
Xx.Zhao
139ce49430 fix(web): refresh provider credentials after editing (#42679) 2026-09-22 03:20:17 +00:00
非法操作
f25c4a3eaf refactor(api): share suggested questions runtime for trial apps (#42635) 2026-09-22 03:10:37 +00:00
yyh
befb18a86f refactor(ui): align Infotip hints and enforce content styling contracts (#42682) 2026-09-22 03:09:19 +00:00
yyh
bfcd0fdb39 refactor(ui): expose compound Infotip primitives and migrate consumers (#42681) 2026-09-22 03:09:18 +00:00
非法操作
206cf8fb94 refactor(api): isolate trial file upload admission and actors (#42625) 2026-09-22 02:43:22 +00:00
非法操作
a6b41ac6d1 refactor(api): isolate trial generation admission and execution (#42130) 2026-09-22 01:55:33 +00:00
Hao Sun
151680eef9 ci: add deterministic pull request size labels (#42615)
Co-authored-by: surbadest <surbadest@users.noreply.github.com>
2026-09-22 01:40:46 +00:00
Charles Yao
c442167828 fix(migrations): merge the two alembic heads on feat/dify-builder
The migration graph had two heads, both descending from d8e4a6b1c902:

- a9cc8d552db4 (add error to dify_builder_runs) on the Builder branch
- e7b2a9c4d601 (add agent sandbox usage) from main, which arrived here
  through merge commit d08c189614

With two heads, `flask upgrade-db` fails at startup with "Multiple head
revisions are present", so the dev dify-api pod built from this branch
was crash-looping and the rollout never completed.

Add an empty merge revision (733e4c46f90e) whose down_revision is
("a9cc8d552db4", "e7b2a9c4d601") instead of re-parenting either one.
Both revisions are already pushed and a9cc8d552db4 is already applied on
the dev database. Re-parenting a9cc8d552db4 onto e7b2a9c4d601 would make
that database believe it is at head while the sandbox usage tables from
e7b2a9c4d601 were never created. The merge revision makes alembic apply
e7b2a9c4d601 on databases that stopped at a9cc8d552db4, then stamp the
merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:37:53 -07:00
chenshiyang
d6491af103 fix(web): resolve tilde-alias SVG URLs at build time (#42654)
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-09-21 13:33:07 +00:00
Stephen Zhou
11be5332bb refactor(i18n): align analysis with Vite environment graphs (#42656) 2026-09-21 13:17:54 +00:00
林玮 (Jade Lin)
a625c62a1d fix(web): skip parameter rules for unavailable models (#42660) 2026-09-21 12:33:26 +00:00
yyh
fb2dba0cf7 chore(web): enforce tooltip styling with shadcn lint (#42667) 2026-09-21 11:45:05 +00:00
yyh
e811c23d37 fix: normalize field label spacing (#42670) 2026-09-21 11:43:01 +00:00
yyh
4ee8095161 fix: correct schedule trigger labels and theme colors (#42665) 2026-09-21 10:47:05 +00:00
Joel
8fb2741963 fix(web): preserve model warnings when reset is unavailable (#42663) 2026-09-21 10:32:56 +00:00
yyh
905afb5be1 fix(ui): align tooltip surfaces with design tokens (#42661) 2026-09-21 10:31:07 +00:00
Joel
f7b594bf79 fix: keep unset agent web app descriptions no fallback (#42662) 2026-09-21 10:22:11 +00:00
zxhlyh
863ce1d70d fix(skills): fix import, tags, duplication, model selection, and TXT icons (#42653) 2026-09-21 09:57:39 +00:00
Ultronen
9089ed40b9 refactor(dataset): replace available_segment_count property with get_available_segment_count(session) (#42523)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-09-21 09:57:27 +00:00
Stream
ad7849a82b chore(api): add union-type and default-param rules to AGENTS.md (#42652) 2026-09-21 09:49:30 +00:00
twwu
9acf99060b fix(dify-builder): hide redundant send goal action 2026-09-21 17:25:50 +08:00
twwu
93a435a4be fix(dify-builder): replace undefined Divider in workflow header 2026-09-21 16:38:01 +08:00
Wu Tianwei
9a0961a4a9 fix(web): align access point permissions (#42648) 2026-09-21 08:33:38 +00:00
twwu
d08c189614 Merge branch 'main' into feat/dify-builder 2026-09-21 16:31:28 +08:00
yyh
1dfa8733a5 fix(i18n): avoid hiding the app during hydration (#42647) 2026-09-21 08:15:40 +00:00
Joel
05df951373 fix: rag pipeline accessibility (#42589) 2026-09-21 08:15:03 +00:00
Stephen Zhou
bbaaaf980f fix(web): align icon picker with updated design (#42636) 2026-09-21 07:59:11 +00:00
twwu
d9991a11e1 Merge branch 'main' into feat/dify-builder 2026-09-21 15:23:36 +08:00
twwu
712bb08f3d feat(app): enable AI prompt improvement during app creation
Connect both creation inputs to the instruction-improvement endpoint without overwriting newer edits. Guide users through unavailable model setup and refresh both default-model query caches after configuration.

Include generated contracts, locale strings, and creation-flow tests.
2026-09-21 15:12:22 +08:00
twwu
3561b0f781 fix(dify-builder): align resource cards and test input submission
Show resource readiness and authorization status, submit the selected resources and prefilled test inputs, and expose collapsible test output.

Cover the requirements-to-plan flow and interrupted plan recovery, with matching locale updates.
2026-09-21 15:12:22 +08:00
twwu
d5ca5ea8ef fix(dify-builder): stream native test runs after canvas refresh
Run workflow streaming inside the Builder worker to avoid queue deadlocks. Forward native events to shared editor callbacks and wait for graph refresh before retesting repairs.

Keep command cleanup stable, preserve batched canvas events, and allow failed or unfinished historical runs to refresh again.
2026-09-21 15:12:22 +08:00
zyssyz123
af4f19840c feat(agent): record E2B sandbox runtime usage (#42554) 2026-09-21 07:11:12 +00:00
Charles Yao
e2633ad6a0 fix(dify-builder): stop reporting a build that applied nothing
Starting a NEW Build session on an app whose draft already holds a previous
build's graph silently changed nothing and still reported "graph built".

_already_present matches a create_node by node id ALONE, and the generator
always emits node1, node2, ... So a new plan whose node2 is a
parameter-extractor was filtered out against an existing node2 that is an
llm: to_apply became empty, apply_repair([]) no-oped, change_set fell back
to "graph built", and the session advanced to build.execution over an
untouched canvas. Observed live -- the snapshot taken before apply was
byte-identical to the draft after it, and the subsequent test run "passed"
because it ran the OLD graph.

The id filter itself is deliberate and stays: it is what makes a loop-back
re-approve (continue_adjusting / revert) idempotent instead of raising on a
colliding id. The guard therefore keys on built_node_ids, which names what
THIS session built -- empty only on a session's first build, which is
exactly the broken case. A loop-back still no-ops silently and advances.

Does NOT clear the canvas on the user's behalf: that would destroy an
existing workflow. It reports honestly and stays in plan_approval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:50:32 -07:00
Charles Yao
6877532f8e fix(dify-builder): key the repeated-repair guard on the engine, not LLM prose
_repair_is_repeating could never fire in production. _note_repair_error
counted repeats by exact string equality on diagnosis.root_cause, which is
LLM prose regenerated on every diagnosis call. Observed live across four
turns of one session: same failure, reworded every time, one turn switching
to Chinese. Equality therefore never held, repair_attempts reset forever,
and the guard was unreachable -- that session burned 9 failed verify runs
and 8 repair approvals with the guard in place.

The call site's own comment reads "ESQ1-285 burned nine approvals this way,
ESQ1-290 six". The reproduction burned nine again.

Key on the ENGINE's output instead: _failure_signature(run) returns the
first failed node's id plus its error text (whitespace-collapsed), falling
back to the launch error, and "" when the run identifies nothing -- so two
unknown failures never compare equal.

Why the existing tests passed while production was broken: every one of them
fed _note_repair_error the SAME literal string twice, and StubAgent.diagnose
always returns one fixed root_cause. They encoded the premise that is false
in production. The new handler-level test drives three turns with an agent
that rewords its root_cause each call while the engine fails identically,
and fails against the old implementation with repair_attempts == 0.

last_repair_error now holds that signature rather than prose. It was never
displayed -- its only use was this comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:45:48 -07:00
林玮 (Jade Lin)
1fa3245e83 fix(app): include image icons in app packages (#42626) 2026-09-21 06:32:21 +00:00
dependabot[bot]
6ba3d29f07 chore(deps): bump google-cloud-aiplatform from 2.1.0 to 2.1.3 in /api in the google group (#42610)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 06:18:59 +00:00
yyh
bf100beb97 refactor(web): let primitives own local interaction state (#42633) 2026-09-21 06:17:07 +00:00
Charles Yao
75c1c6f3a2 fix(dify-builder): restore per-line noqa after ruff reflowed the signature
The brief's one-line _FakeGen.generate_workflow_graph signature carried a
single trailing `# noqa: ARG004`. `ruff format` -- which the plan mandates
after `ruff check --fix` -- reflowed the >120-char signature to one argument
per line, leaving the noqa attached only to `current_graph` and orphaning
tenant_id, mode and model_config. ruff scopes noqa per PHYSICAL line, so
`ruff check` then failed with 3 ARG004 errors while pytest stayed green.

Lint-only; no behaviour change. Verified that `ruff format` now leaves the
file unchanged, so the annotation survives the format pass that broke it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:16:30 -07:00
Charles Yao
fe3f8154b5 feat(dify-builder): carry node usage rules on the Builder-only directive
Meeting item 9 asked for per-node usage detail in the generator prompts.
Those prompts are shared with cmd+K, so the detail rides the channel the
Builder already owns instead: _WORKFLOW_TOPOLOGY_DIRECTIVE, prepended to
every generator instruction at build.py:377 and invisible to cmd+K. That
mechanism is proven -- it is why workflow mode never gets 'answer' nodes.

The rules added are the ones that broke real builds: tool nodes expose
text/files/json and not 'result' (ESQ1-296, ESQ1-289), document-extractor
needs a file input, container children belong inside the container
(ESQ1-288), if-else operators must match the variable's type
(ESQ1-285/290), and http-request inputs belong on the start node
(ESQ1-286).

Deliberately does NOT restate the node whitelist: the generator's shared
system prompt already carries it, and duplicating it would burn planner
output budget that ESQ1-300 already implicates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:08:27 -07:00
Charles Yao
f12d8f4e0f feat(dify-builder): plan in Dify node vocabulary, not business language
propose_plan_v1's system prompt was two sentences with no node vocabulary,
so it planned in business language -- "Send a Feishu notification",
"Connect to the database". Those steps became the generator's
"# User instruction" verbatim, asking it to map work onto nodes that do
not exist, and the failure only surfaced at build-generate-graph, minutes
after the user had approved the plan (ESQ1-279).

The generator already knows the node types: its SHARED system prompt
carries the whitelist. The gap was upstream, in the Builder's own planner.
Naming the vocabulary here makes that existing whitelist sufficient.

Builder-only: nothing under core/workflow/generator/ is touched, so the
cmd+K /create and /refine features are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:05:34 -07:00
yyh
22aff66cc0 fix(dify-ui): preserve state-aware styling callbacks (#42631) 2026-09-21 05:56:21 +00:00
林玮 (Jade Lin)
11038c5936 fix(agent): auto-rename duplicate package imports (#42623) 2026-09-21 05:54:22 +00:00
林玮 (Jade Lin)
99df0ca07f fix(api): mark unavailable legacy DSL agent assets as missing (#42622) 2026-09-21 05:54:14 +00:00
Sourav Rajvi
f9129bc500 fix(api): grant in-run tool files to later workflow nodes (#41300) 2026-09-21 05:39:04 +00:00
盐粒 Yanli
2a66bf6f09 refactor(workflow): prepare inputs by entry node type 🤖🤖🤖 (#42595) 2026-09-21 05:26:23 +00:00
Baki Burak Öğün
ee0efe0bdb fix(i18n): check placeholder and tag parity in i18n:check (#42532)
Signed-off-by: Baki Burak Öğün <63836730+bakiburakogun@users.noreply.github.com>
2026-09-21 05:17:54 +00:00
yyh
679785b67e fix(web): align app creation and pricing separators (#42629) 2026-09-21 05:12:47 +00:00
Charles Yao
d2e204f133 fix(dify-builder): stop the test step deadlocking and losing its outcome
Two defects, both introduced by the streaming switch in 3838db8aa7, which
together livelocked every Builder test run.

1. Self-deadlock. run_draft executes inside the Celery task
   dify_builder_advance_task.advance_session. Streaming mode does not run
   the workflow in place: it enqueues workflow_based_app_execution_task
   via delay() on first subscribe (app_generate_service.py:322-324) and
   returns a Redis subscription. The advance task then blocked on events
   from a task needing the worker slot the advance task was holding.
   Observed live: advance_session "succeeded in 300.59s" (the idle
   timeout) four times over, while each workflow_run finished in under 2s.
   Deadlocks every time at worker concurrency 1, and as soon as N test
   runs are in flight at concurrency N.

2. Lost outcome. Blocking mode returns a Mapping
   {"task_id", "workflow_run_id", "data"}, but the rewrite consumed the
   response with `for chunk in response`, which walks a dict's KEYS as
   strings. Probed against the real service: 3 chunks, 0 run-id matches,
   0 terminal matches. So every run was recorded status="running" with an
   empty dify_run_id, and the "running" branch bounced the user back to
   build.execution -- forever. The pre-3838db8aa7 code read
   response["data"] directly; the rewrite dropped that path.

Blocking mode restored, Mapping responses handled, and the shared tail
factored into _finish_run so the two paths cannot disagree about an
outcome. The stream-consumption path is kept and still tested, for a
future consumer that is NOT a worker -- which is what live per-node
streaming to the frontend will require.

Verified end to end on a local stack: one test run, status succeeded with
a real run id, advance_session 0.04-31.8s, and the session reaching
build.review instead of looping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:10:28 -07:00
Charles Yao
9a910f27e7 fix(dify-builder): close the repair-gate holes in Build, Edit and Fix
Four findings from the whole-branch review, all in the repair loop:

I1 -- repair_attempts/last_repair_error were added to the context and to
serde but not to recovery._reset_working_fields (which clears eleven sibling
fields) nor to the re_fix/restart re-plan branches, so a fresh build could
inherit repair_attempts >= the ceiling and decline a repair on its FIRST
failure. They are cleared with their siblings now. The guard also ANDed a
monotonic counter with a consecutive-error test, so A -> B -> B tripped it
although B had survived one repair. _note_repair_error now makes the counter
mean what the docstring says: consecutive repeats of ONE error, reset when
the error changes.

I2 -- the repeated-failure return dropped run/run_id_sink, so repo.save_run
never ran while fc.verify_run_id already pointed at that run: a dangling
pointer and the third failing run lost from the debug export.

I3 -- ESQ1-291's empty-staged-repair guard existed in Build only. Edit's
model-config branch GUARANTEES an empty staged repair and routes to a state
whose approve applied it blindly; handlers_fix.handle_apply had the same
shape. Both are guarded now, reusing Build's notice.

I5 -- apply_repair re-validates intents against the draft as it is NOW, so a
dotted path that was applicable at propose time can raise ValueError at
approve time. Uncaught at all three approve sites, it reached the runner and
drove the session to FAILED -- the exact ESQ1-271 outcome this branch set out
to remove. Each site now degrades to a "Couldn't apply the fix" error card
naming the reason and clears the staged repair (engaging the I3 guard).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:10:28 -07:00
Stephen Zhou
57c140144a ci(web): check image compression savings and support local fixes (#42618) 2026-09-21 05:10:06 +00:00
Charles Yao
674669f7a6 fix(dify-builder): carry the container start marker's ReactFlow type
The previous ESQ1-288 fix dropped the iteration/loop start marker, stripped
start_node_id from the container config and dropped the generator's container
entry edge. All three are required by the ENGINE, not just the canvas:
IterationNodeData.start_node_id / LoopNodeData.start_node_id have no default,
node data is validated eagerly at graph construction, and the body frame is
rooted at start_node_id. Every container workflow the Builder produced was
therefore non-runnable -- worse than the canvas crash it replaced.

The real root cause was never the marker node. The generator already emits it
with the correct node-level ReactFlow type ("custom-iteration-start" /
"custom-loop-start", runner.py:1350-1362); _build_node hardcoded "type":
"custom" and threw it away, leaving a node the frontend has no component for
(React error #130).

Carry it through instead, the same way the previous commit carried
parentId/position: a defaulted `flow_type` on _build_node / apply_create_node
(absent -> "custom", so every existing caller is byte-identical), set from the
source node's type in to_intents. The marker, start_node_id and the entry edge
are all persisted again.

Tests: the three tests that asserted the ABSENCE of the marker, start_node_id
and the entry edge are replaced by ones asserting their presence, plus the
round-trip test that would have caught this -- to_intents -> filter_applicable
-> APPLY_FNS -> IterationNodeData/LoopNodeData.model_validate on the persisted
container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles Yao
f0c7b7c413 feat(dify-builder): carry nesting and layout through translation
to_intents read only id and data, so every node-level ReactFlow key --
parentId, extent, zIndex, position -- was dropped on each Builder build.
Iteration and loop children were written un-nested, not inside their
container at all, and the generator's computed layout was replaced by
default positions. The data-level markers (isInIteration, iteration_id)
survived via config, which is why the graphs looked almost right.

apply_create_node already took position; parent_id is new and implies
extent="parent" + zIndex=1002, matching what the generator computes.

Unfiled; found while tracing ESQ1-288. Skipping the synthetic start node
stops the canvas crash, but an iteration whose children are not inside it
still does not work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles Yao
8ceee4d3be fix(dify-builder): skip synthetic nodes and clean up dangling references
Builder-generated iterations/loops crashed the canvas permanently with
React error #130 or TypeError. The backend synthesizes container-start
markers correctly typed, but to_intents was recreating them as generic
nodes, and persisting dangling start_node_id pointers.

Fix three related issues:
1. Skip creating synthetic iteration-start/loop-start nodes (frontend
   regenerates them at render time from the container).
2. Strip start_node_id from container configs; the frontend's
   workflow-init.ts rewrites it to match the marker it creates, and
   persisting our pointer causes TypeError on dereference without guard.
3. Skip edges whose source or target is a skipped node; the generator
   emits entry edges to the synthetic marker, which no longer exist.

The generator's own output (cmd+K) is untouched — only the Builder
translation layer is fixed. Zero blast radius.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles Yao
917425a81b fix(workflow-generator): guard TOOL output-schema shape in _declares_variable
Round-1 review defect: the TOOL branch added for ESQ1-296/ESQ1-289 assumed
output_schema was always a dict once truthy. A non-dict output_schema (or a
non-dict `properties` inside it) raised AttributeError instead of failing
open. That crash is new -- the old branch never read output_schema at all --
and it happens outside the _run_stage try/except and outside the
controller's narrow exception allowlist, so it would take down the SSE
generator mid-stream for both cmd+K and the Builder.

Reachable via refine mode: _hydrate_tool_nodes only stamps a schema when it
finds a matching catalogue entry, so a hand-edited or legacy graph can carry
forward an output_schema of any shape.

Fix: shape-check before every .get() -- output_schema must be a dict and its
properties must be a dict, or the branch returns True.

SHARED WITH CMD+K: fails open whenever the schema is absent, empty, or an
unreadable shape, so a tool we cannot describe behaves exactly as before.
Only provably-wrong references, from a schema we can actually read, are
rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles Yao
00b9629800 fix(workflow-generator): validate tool-node output references
_declares_variable returned True unconditionally for TOOL nodes, so any
invented downstream reference survived generation. ESQ1-296 and ESQ1-289
are the same three lines: code/simple_code is a TOOL node, so the real
code-node validation directly above never saw it. They differ only in
consumer -- End tolerates the missing variable and yields a silent null,
if-else fails at run time and feeds the repair loop.

Tool outputs are not opaque: tool_node.py fixes text/files/json and the
generator already copies the provider's output_schema onto the node.
Validate against both, and alias the invented `result` to `text` so the
common mistake self-heals.

SHARED WITH CMD+K: fails open when no output_schema is present, so a tool
we cannot describe behaves exactly as before. Only provably-wrong
references are rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles Yao
9a99804247 fix(dify-builder): restore repair_attempts/last_repair_error on context reload
Critical defect from Task 3 review: context_from_dict rebuilt
DifyBuilderContext field-by-field and never read repair_attempts or
last_repair_error, even though context_to_dict (asdict) wrote them.
BUILD_AWAIT_REPAIR is a waiting state, so every approve_repair is a
fresh turn that reloads the context via context_from_dict on
submit_action -> repair_attempts silently reset to 0 every turn,
incremented to 1, and could never reach _MAX_REPEATED_REPAIRS. The
_repair_is_repeating guard added in Task 3 was therefore structurally
unreachable in production (ESQ1-285/290's dead loop, still alive).

Adds both fields to the context_from_dict constructor call, following
the existing per-field coercion idioms (int() for the counter, matching
checkpoint_seq; bare .get with a "" default for the string, matching
its string-field neighbors).

Also adds:
- A round-trip + defaults-when-absent test pair in test_serde.py,
  following the file's existing convention, reproducing the exact
  reload-drops-fields regression.
- An integration test in test_handlers_build.py that drives the
  "Repeated failure" branch through handle_test_and_repair itself
  (StubAgent + FakeBuildDifyPort, not a direct _repair_is_repeating
  call), so a future regression that re-disconnects serde from the
  handler would be caught even if the pure guard-function unit tests
  still pass in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles Yao
38ef6178a6 fix(dify-builder): refuse to apply a repair that stages no intents
handle_await_repair offered "Apply the fix" unconditionally, so an empty
staged_repair produced approve -> apply nothing -> retest -> same failure
-> offer again, with no LLM in the loop. ESQ1-291's trace shows five such
approvals, every change set {count: 0, changes: []}.

Adds repair_attempts / last_repair_error to the context (both defaulted,
so persisted contexts deserialize unchanged) and _repair_is_repeating for
the follow-on gate. An empty staged repair is now declined with a decision
the user can act on instead of a run that cannot help.

_repair_is_repeating is also wired in at the point a repair is OFFERED
(the config-failure path in handle_test_and_repair, right after
diagnose()): a third identical failure clears staged_repair and surfaces
a "Repeated failure" card instead of proposing a fourth repair
(ESQ1-285/290 burned nine and six approvals this way).

Also catalogs the two new user-facing literals in core/dify_builder/
strings.py so test_catalog_covers_handler_literals keeps passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:25 -07:00
Charles Yao
1b9d301faf fix(dify-builder): reject non-string node ids and paths instead of failing the session
An LLM-supplied int node_id reached diff_graphs, which drops falsy and
non-string ids from its node maps, so result.changes came back empty and
build_change_set's fallback joined a list containing an int. The
TypeError took the whole session to `failed` (ESQ1-271) after the
mutation had already been committed to the draft.

validate_intent_args now rejects a non-string or empty node id, so
filter_applicable turns it into a rejection reason the agent can act on.
build_change_set str()s defensively -- that join must never be what kills
a session.

Also extends the same guard to set_node_config's `path` arg: a non-string
path used to reach _resolve_path's str.split() and raise AttributeError,
surfacing "'int' object has no attribute 'split'" to the repair agent
instead of an actionable rejection reason. Same boundary, same defect
shape, deliberately included here rather than deferred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:25 -07:00
Charles Yao
7e82ab20d9 fix(dify-builder): walk dotted paths in apply_set_node_config
apply_set_node_config wrote node["data"][path] as a flat key, so every
dotted path the repair agent emits (cases.0.conditions.1.value) created a
junk key the workflow engine ignores. The dry run passed, the change set
printed a plausible line, and the graph never changed -- which is why
ESQ1-285 survived nine approvals and ESQ1-290 six.

Paths now resolve through lists and mappings, and an unwalkable path
raises. Raising is half the fix: filter_applicable dry-runs intents
through the real apply_* and feeds rejection reasons back to the repair
agent, so a bad path now produces corrective feedback instead of a
silent no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:25 -07:00
zyssyz123
d2c2e39ba7 fix(agent): preserve sandbox file errors in stdout 🤖🤖🤖 (#42624) 2026-09-21 04:34:28 +00:00
yyh
3e787d11cc feat(ui): replace legacy Divider with semantic Separator (#42627) 2026-09-21 04:28:29 +00:00
林玮 (Jade Lin)
e78ce4db64 feat(app): package inline agents in workflow and chatflow exports (#42579) 2026-09-21 02:54:43 +00:00
yyh
efd1fb0967 fix(web): migrate legacy plain tabs to Dify UI (#42620) 2026-09-21 02:51:58 +00:00
dependabot[bot]
08a9d40096 chore(deps): bump flask-compress from 1.24 to 1.25 in /api in the flask group (#42609)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 02:28:09 +00:00
Sourav Rajvi
126dee3da6 chore(web): optimize in-site notification header background asset (#42479)
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-09-21 02:13:42 +00:00
dependabot[bot]
bfa243aecf chore(deps): bump the github-actions-dependencies group with 4 updates (#42612)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 01:51:00 +00:00
Wu Tianwei
bf8333bc77 fix(builder): avoid layout conflicts and serialize draft updates (#42601) 2026-09-21 09:48:02 +08:00
Charles Yao
3838db8aa7 feat(dify-builder): pre-fill the test-data form instead of hiding the mock
Mocking the inputs removed the work of inventing test data, but it also
removed the user's sight of what was actually tested. A green check
produced by {"topic": "test"} is weak evidence about a workflow, and an
invisible mock is a quieter version of the same false confidence a
passing-but-unverified test gives.

So keep the gate and pre-fill it: the form arrives with the mocked values
already in it, unfrozen, so the common case is one click rather than N
fields to type, and anyone who cares can edit before running. The cost
item 5 removed was PRODUCING the data, not confirming it.

Upload fields stay empty and still ask, because nothing can mock a file --
and the mock generator falls back to a plain string for types it does not
know, so it would otherwise pre-fill a file field with "test". New
``upload_variable_names`` and ``without_upload_values`` strip those values;
``needs_upload_inputs`` is re-expressed on top of the first rather than
duplicating the type check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:40:30 -07:00
Charles Yao
96f98b5804 feat(dify-builder): persist the Dify run id on the test-result card
The run id already reaches a client over SSE, which is enough to open the
run on the canvas while the page is up. It is not enough afterwards: SSE
frames do not survive a reload, and the persisted card's ``run_ids`` holds
BUILDER run ids -- uuid4s the engine mints, which resolve to nothing
outside it. So on reopening a session the card was the only record of the
result, and the canvas view was unreachable.

Put ``dify_run_id`` on TestResultCard, beside ``run_ids``, wired at all
nine construction sites across the Build, Edit and Fix flows. Now the
canvas is reachable from the durable conversation too, which is what makes
the card's ``output`` genuinely the client's choice to render or skip
rather than the only surviving copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:31:34 -07:00
Charles Yao
a3c2e7fca5 feat(dify-builder): tell the canvas which run to show
Streaming the test run gives the user a live progress bar but no results:
NodeEvent carries node_id/title/status/error and nothing else, so a client
watching the stream learns which node is running, never what it produced.
The results already exist -- the run is a normal InvokeFrom.DEBUGGER draft
run, its node executions are in the database, and the editor's canvas
already knows how to render exactly that view. It was simply never told
which run to open.

So put the Dify run id where a client can reach it:

- NodeEvent gains run_id, filled from the streaming frame. Every non-ping
  frame is stamped with workflow_run_id, so this arrives with the FIRST
  node event -- while the run is still going, which is the point.
- The node SSE frame publishes it alongside the existing fields.
- mark_test_success / mark_test_error carry dify_run_id in both the Build
  and Edit flows, so the terminal state names its run too.

This is what makes per-node results land on the graph, where a node's
inputs and outputs already have a home, instead of only as a JSON blob on
a conversation card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:16:41 -07:00
Charles Yao
69ccf2ad15 fix(dify-builder): wire resource readiness into recommender/gap prompts
Three minor whole-branch-review fixups:

- ResourceOption.readiness ("ready"/"missing_config") was computed but never
  shown to the model: discover_resources's recommender prompt listed ids and
  labels only, so it could recommend an unauthorized tool; _GAP_SYSTEM showed
  readiness in the listing (assess_capability_gap) but never defined what
  missing_config means. Both prompts now show readiness and state that
  missing_config is installed-but-not-authorized, i.e. not usable -- pinned
  with tests asserting the literal "missing_config" in the constructed
  prompts.
- strings.py: dropped three orphaned catalogue entries ("Here's the initial
  plan.", "Ask each time", "Prefer audited") -- the conflict-policy concept
  they backed was removed end to end; nothing in the handlers emits them.
- core/workflow/generator/tool_catalogue.py: provider.need_credentials was
  read OUTSIDE the per-provider try/except that already guards
  get_tools()/tool parsing. This module is shared with the cmd+K /create and
  /refine flows, so a provider whose property raised would break those too,
  not just the Builder. Moved the read inside the try/except so a bad
  provider is skipped like any other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:41:52 -07:00
Charles Yao
7ae152c5c2 fix(dify-builder): cap the test-result card's rendered output
_terminal_output json.dumps'd a node's outputs with no length limit. Node
outputs are only truncated upstream at ~100,000 chars per string, so a
report-generating workflow -- the Builder's showcase case -- could push
hundreds of KB of JSON into a dify_builder_conversation_items row, the SSE
frame, and the localizer walk on every successful build.

Cap the rendered output at 2000 chars with a visible truncation marker
before it reaches TestResultCard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:37:45 -07:00
Charles Yao
383d6d3624 fix(dify-builder): don't diagnose/repair a truncated verify run
run_draft's streaming path introduced Run.status="running" (outcome
genuinely unknown -- the progress stream ended before the run did) as a
third status alongside "succeeded"/"failed", but neither
handle_test_and_repair (Build) nor handle_verify (Fix) knew about it: both
treated anything short of "succeeded" as a failure, so a truncated run got
labeled "Test run -- Failed" and, in Build, had a graph repair diagnosed
and staged against it while the run might still be executing.

Both handlers now branch on status=="running" before the failure path: a
neutral notice explains the outcome couldn't be determined, and the flow
returns to a re-runnable waiting state (build.execution / fix.await_testdata)
without ever calling diagnose or propose_repair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:36:16 -07:00
Charles Yao
f1bf2fb318 feat(dify-builder): say when the workspace cannot cover the plan
Add assess_capability_gap: one plain sentence naming a plan step nothing
installed can perform, or "" when the plan is covered. Grounded only in
installed resources -- there is no marketplace lookup, so it names a missing
CAPABILITY, never a product to install. A missing_config tool (installed but
unauthorized, from the prior task's real readiness) doesn't count as
covering a step either, since the model is shown each option's readiness
alongside its label and kind.

Wired through the full agent seam: the DifyBuilderAgent protocol, the real
LlmBuilderAgent (delegates to build.assess_capability_gap), and the
PlaceholderAgent test double (returns ""). handlers_build.py calls it
after discover_resources and, when non-empty, appends a NoticeItem beside
the existing empty-resources notice -- the two are independent and can
both fire.

Like its sibling cognition functions, it degrades to "" (never raises) on
no model, an LLM error, or an unparseable reply: a gap that can't be
assessed isn't a gap worth inventing. The sentence is LLM-generated prose
already in the user's language (json_language_instruction), not a
catalogue entry, so no static string was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:13:02 -07:00
Charles Yao
0a228b7c23 feat(dify-builder): report whether a recommended tool is actually authorized
The Builder recommended every installed tool as ready to use without
ever checking whether its provider actually has credentials configured.
A credentialed tool with no tool_builtin_providers row got bound into a
plan and then failed at test time with an authorization error the user
can't act on.

tool_catalogue.py now carries provider_controller.need_credentials onto
each ToolCatalogueEntry as needs_credentials. resources.py resolves
which providers are actually authorized for the tenant (mirroring
ToolTransformService.builtin_provider_to_user_provider's need_credentials
/ db_provider rule exactly, so the Builder and the console's tools page
can never disagree) and sets ResourceRef.readiness to "missing_config"
for a credentialed tool with no authorized provider row, "ready"
otherwise. Datasets and models are unaffected. If the authorization
query itself fails, readiness fails open to "ready" rather than
mass-labeling every credentialed tool unauthorized on a transient error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:04:48 -07:00
Charles Yao
574c9d1208 fix(dify-builder): never report a truncated test run as a failed one
Streaming introduced a correctness regression: if the event stream ended
without a terminal frame (idle timeout, dead publishing worker), run_draft
synthesised status="failed" with an empty dify_run_id. A run that had in fact
SUCCEEDED would then be reported as a failed build and sent into the repair
loop. The blocking path would have waited and returned the truth.

The stream is the fast path, not the authority. Every non-ping frame is
stamped with workflow_run_id by convert_stream_full_response, so the run id
is known from the first frame onwards -- long before the terminal frame that
may never arrive. run_draft now captures it from any frame and, when no
terminal frame arrives, reads the run's real status from the workflow-run row
instead of inventing one.

Note that data["id"] is the node-execution id on node frames, not the run id,
so it is only read as a run id on workflow-level frames.

When even the row cannot settle it -- no run id, no row, or a row still
marked running because the worker died mid-run -- the result is reported as
status="running" with an explicit Run.error, which is what Run.status already
documents for "not over / not known". A truncated stream is now either the
truth or a visible unknown, never a fabricated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:49:50 -07:00
Charles Yao
e9599355a2 feat(dify-builder): stream the test run instead of replaying it
run_draft blocked on AppGenerateService.generate(streaming=False) and then
replayed every finished node-execution row through on_event, so the user
watched a spinner for the whole run and then saw every node light up at once.
It now consumes the streaming generator and fires on_event per frame.

Two things the plan assumed that turned out not to hold:

- The stream does NOT yield dicts. convert_stream_full_response yields dicts,
  but that runs in the Celery worker; on our side
  BaseAppGenerator.convert_to_event_stream (not overridden) re-wraps every
  event as "data: {json}\n\n" and every keep-alive as "event: ping\n\n". An
  isinstance(chunk, dict) guard would have dropped every frame. New
  stream_chunk_as_mapping accepts both shapes and rejects keep-alives.

- The streaming workflow_paused frame carries workflow_run_id where the
  blocking response carried id, so map_run_result would KeyError on it.
  Adapted at the call site via run_result_data_from_terminal_chunk;
  map_run_result's contract is unchanged for the fix/edit flows.

Session lifetime: _dispatch_generate is a plain function, so all of its
Session work is eager, and the generator it returns only reads a Redis
subscription while the run executes in a Celery worker with its own session.
The consumption loop therefore sits outside the `with` block rather than
pinning a DB connection for the length of a workflow run; a test pins this.

The node-execution read stays at the end -- map_run_result still needs those
rows for per_node[].outputs. Only the progress streams.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:42:25 -07:00
Charles Yao
69d59be764 test(dify-builder): cover the file-schema testdata gate end-to-end via the FSM
The task-5 change to handle_execution's run_test branch (gate only on
needs_upload_inputs) had no test driving the file-declaring branch through
the real Runner: the two nearest tests called handle_execution /
handle_await_testdata directly and never connected into one flow, and no
existing full-flow test's generated graph declares a file variable.

Add test_full_build_flow_file_schema_routes_through_testdata_gate_via_fsm:
builds a real session through runner.advance(), mutates the built graph's
Start node to declare a file variable, then asserts run_test lands at
BUILD_AWAIT_TESTDATA with a testdata form card actually emitted, and that
submitting provide_testdata through the Runner (not a direct handler call)
advances it to build.review. Together with the existing (task-5-updated)
fileless full-flow tests, both branches of the conditional are now
exercised through the state machine end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:30:41 -07:00
Charles Yao
2a0fae4bcb feat(dify-builder): show what the test run actually produced
A passing build test reported "All checks passed" and threw away the
run's actual node outputs. TestResultCard gains an `output: str` field
(pretty-printed JSON of the last node that produced anything, "" when
none); handle_test_and_repair's success card now populates it via a new
_terminal_output(per_node) helper.

_terminal_output is deliberately status-agnostic (no node.status filter):
per-node status spellings differ by source ("success" vs "succeeded"), and
a status filter that silently misses would defeat the point of the card.

FakeDifyPort (tests/unit_tests/core/dify_builder/fakes.py) gains a
settable run_outputs dict, seeded onto the success-path NodeOutput's
outputs, so tests can assert on what the card shows without hand-rolling
a Run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:22:13 -07:00
Charles Yao
f53425243f feat(dify-builder): mock test inputs unless the workflow needs an upload
handle_execution's run_test branch used to stop and ask a human for test
data any time none was prepared yet, even when every declared start
variable was something the agent could invent. Add
needs_upload_inputs(schema) -> bool (true only for a declared file /
file-list variable) and gate BUILD_AWAIT_TESTDATA on it: everything else
is now mocked inline via env.agent.generate_mock_inputs and the flow goes
straight to build.test_and_repair. A schema mixing text and file
variables still stops at the gate, since the file can't be invented.

Updates three full-build-flow tests whose generated graph has no file
start variable, so run_test now lands directly at build.review instead of
pausing at the testdata gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:21:33 -07:00
Charles Yao
a82e4ca97d Builder: remove the duplicate pre-discovery plan card from the loop-back
Code review finding: handle_review's re_fix branch and handle_reverted both
emitted PlanCard("Build plan", "v1") before falling through to
build.initial_plan's unconditional resource discovery, which falls through
again to confirm_resources -- which emits PlanCard("Build plan", "v1") a
SECOND time, now with resources bound. Before this task those two cards were
tagged v1 then v2, so they were visibly different plans; now both say v1,
so the loop-back shows the same plan card twice in one pass. Task 2 already
removed this exact duplicate from the straight-through path
(handle_goal_analysis); it should not survive on the loop-back and not on
the straight-through path.

Fix: handle_review's re_fix branch and handle_reverted still draft
fc.plan_items/fc.plan_version_tag (needed by discovery and the eventual
confirm_resources card) but no longer emit a PlanCard or claim cards=["plan"]
on their AssistantTurnItem (now cards=[], matching the existing
handlers_fix.py precedent for a turn with no card). There is now exactly ONE
plan card per pass, tagged v1, on every path: straight-through,
continue_adjusting, and retry_after_revert.

Updated the two direct-handler unit tests that asserted the now-removed
card, and extended test_review_continue_adjusting_then_reapprove_is_
idempotent (an end-to-end Runner-driven test) to assert re_fix adds zero new
plan cards and the subsequent confirm_resources adds exactly one, tagged v1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:09:06 -07:00
Charles Yao
f665ddcec2 Builder: one ProgressReporter per operation on the straight-through path
Code review finding: handle_goal_analysis built its own ProgressReporter
(build-review-requirements/build-draft-plan) and finish()ed it, then
tail-called _discover_and_offer_resources, which built a SECOND reporter
under the SAME env.operation_id. ProgressReporter's revision counter starts
at 0 per instance, so the second reporter's activate/finish calls restarted
revision at 1 -- two progress events with revision 1 (and 2, and 3) under
one operation_id. contract.py's ProgressEventData docstring states revision
is monotonic within an operation_id; every other handler across the three
handler files uses exactly one reporter per step -- this was the sole
exception, on the most-travelled build path.

Fix: _discover_and_offer_resources now takes an optional `progress`
parameter. handle_goal_analysis passes its live (still-open) reporter
through instead of finishing it early; the helper adds the discovery steps
onto that SAME reporter via add_steps() and is the one that finish()es it.
handle_initial_plan (the continue_adjusting/retry_after_revert loop-back,
which has no reporter of its own -- a separate CAS commit/operation) still
gets progress=None and the helper builds a fresh one, unchanged.

Added test_submitting_requirements_progress_is_one_operation_with_monotonic_
revisions: drives the real Runner with submit_requirements, captures every
emitted ProgressEventData, and asserts a single operation_id with strictly
increasing, non-repeating revisions across all four progress steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:08:20 -07:00
Charles Yao
e1be992c24 Builder: renumber the last stray "Plan v2" prose string to v1
Code review finding: the plan card's version_tag was renumbered v2 -> v1
in an earlier commit, but handle_resource_recommendation's own reply_text
still read "Plan v2 ready for approval." -- a card that says v1 next to
prose that says v2 is worse than leaving both at v2, since it's exactly
the user-visible inconsistency the renumber exists to eliminate.

Updated the reply_text to "Plan v1 ready for approval." and its matching
entry in the strings.py literal catalog, so test_catalog_covers_handler_
literals keeps passing for the right reason (a registered v1 string, not
a stale v2 one). Grepped the whole of core/dify_builder/ for any other
quoted v2/v2.1 string this renumber might have missed -- this was the
only one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:51:28 -07:00
Charles Yao
a96ed2d387 Builder: fix the continue_adjusting dead end at build.initial_plan
Code review finding: build.initial_plan was left in _WAITING with zero
_ACTIONS_FOR entries but a handler still gated on find_resources. Since a
waiting state settles the runner's advance loop (is_waiting(next_state) ->
settled), continue_adjusting from build.review landed a session there with
no button the FE could render and no action the handler would accept --
the loop-back could never advance.

Reclassify build.initial_plan as working (state.py _WORKING, not _WAITING):
it is now a genuine pass-through step, matching build.test_and_repair /
build.publish / build.governance_feedback. handle_initial_plan drops its
find_resources gate and runs resource discovery unconditionally on entry;
the runner drives it with the action already consumed and falls straight
through to build.resource_recommendation within the same advance() call.
Removed the now-stale BUILD_INITIAL_PLAN entry from service.py's
_BACKEND_ACTIONS_FOR (no action to gate on any more) and the compensating
exemption it required in test_every_backend_waiting_action_has_a_ui_path
(the invariant now holds with no special case).

Added a state-classification test (test_build_initial_plan_is_working_not_
waiting) and an end-to-end regression driving the Runner from build.review
through continue_adjusting with no find_resources action ever sent
(test_continue_adjusting_reaches_resources_without_find_resources_action),
which is the scenario this fix exists to unwedge. Fixed the two existing
idempotency flow tests that asserted the old (broken) two-step loop-back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:50:45 -07:00
Charles Yao
6058af3fe5 Builder: capability_check gates on the goal, not the action
handle_capability_check now advances a session that already carries a goal
(fc.goal_text set at creation) on ANY turn, not only send_goal -- the
composer already carried the goal in, so requiring a further send_goal
click was a second decision-free gate. A goal-less (create-from-blank)
session still waits for send_goal to supply one; that action id stays in
_ACTIONS_FOR for exactly that case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:39:33 -07:00
Charles Yao
a2cb087510 Builder: renumber the surviving plan card from v2 to v1
With build.initial_plan's plan card gone (previous commit), the plan card
emitted by handle_resource_recommendation on confirm_resources is now the
FIRST and only plan card a build shows -- so it becomes v1 instead of v2.
The build-complete plan card in handle_plan_approval follows suit,
v2.1 -> v1.1. A build no longer displays "the same plan twice under two
version numbers".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:38:31 -07:00
Charles Yao
806cc4e517 Builder: collapse the decision-free find_resources gate at build.initial_plan
submit_requirements now tail-calls a shared _discover_and_offer_resources
helper straight into build.resource_recommendation, instead of stopping at
build.initial_plan to show a plan card the user must click through with no
real decision to make. The resource-discovery body (progress reporting,
discover_resources, ResourceSelectCard, empty-options notice, assistant
turn) is extracted verbatim into that helper so the straight-through path
and the continue_adjusting/retry_after_revert loop-back (which still
re-enters at build.initial_plan and still requires find_resources) share
one implementation instead of drifting apart.

build.initial_plan survives as the loop-back target but now offers no UI
action (_ACTIONS_FOR entry removed); its handler still accepts
find_resources at the backend for that loop-back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:37:01 -07:00
Charles Yao
05d1a0c7b2 docs(dify-builder): regenerate console OpenAPI doc after conflict-policy removal
api/openapi/markdown/console-openapi.md still documented the removed
ConflictPolicyOption schema and ResourceSelectCard.conflict_policy_options
field as live API contract after e8b14b50ff. Regenerated via
api/dev/generate_swagger_markdown_docs.py and kept only the two hunks
that remove the conflict-policy schema; discarded unrelated churn the
regeneration also picked up (the already-merged /workflow-generate/improve
endpoint's docs, never regenerated by that earlier commit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:17:19 -07:00
Charles Yao
e8b14b50ff refactor(dify-builder): drop the resource conflict policy
The resource-recommendation step made the user pick a "conflict
policy" that never decided anything. Remove it end to end: the
ConflictPolicyOption card option, ResourceSelectCard's
conflict_policy_options field, the third bind_resources() parameter
across the port/placeholder/LLM-agent implementations, and the dead
conflict_policy field on the (unused) ResourcesPayload contract type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:10:34 -07:00
Charles Yao
583f7fa55f feat(workflow-generator): add an instruction-improvement endpoint
The App Builder composer's "Add missing details" link needs a backend. It
fires in the pre-creation composer, so there is no app, no draft workflow and
no Builder session yet -- it is tenant-scoped only, which puts it beside
/workflow-generate/suggestions rather than under console/dify_builder.

POST /console/api/workflow-generate/improve expands a short build instruction
into one that states what a builder would otherwise have to ask about: the
trigger and inputs, the steps, the rules that decide between outcomes, the
output and its destination, and the failure path.

Soft-fail like suggestions: no default model, an LLM error, an echo of the
input, or a rewrite shorter than the input all return "" from the generator,
and the route answers 200 with the caller's own text and changed=false. The
action can never strand what the user typed, so there is deliberately no
402/429 mapping.

Deliberately not grounded in the tenant's tools or knowledge bases. Naming
tools here would prejudice the workflow generator's own tool router, which
selects from the full installed catalogue against the instruction it is
actually given -- and it keeps the endpoint free of DB and plugin-daemon
calls.

The boundary guard is split out rather than shared: _workflow_instruction_guard
names ideal_output in its wording, which this payload does not carry. The
existing endpoints' error strings stay byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:04:21 -07:00
twwu
3686aa891b Merge branch 'main' into feat/dify-builder 2026-09-20 16:12:20 +08:00
twwu
cce9470161 Merge branch 'main' into feat/dify-builder 2026-09-20 09:45:05 +08:00
twwu
0d9f0e0b59 fix(web): remove unused Builder translations 2026-09-18 17:02:20 +08:00
twwu
b330bcdb60 fix(web): update app creation toast import 2026-09-18 16:31:26 +08:00
twwu
bb7f7e4640 Merge branch 'main' into feat/dify-builder 2026-09-18 16:06:20 +08:00
twwu
e8fa8d57f1 feat(apps): refresh creation menu and Builder starter 2026-09-18 15:58:21 +08:00
Charles Yao
0436d0a19e fix(dify-builder): add the az-AZ app-name fallback stem
The main merge added Azerbaijani (#42291) as a console locale. The naming
fallback keys its "New app" stem by console locale, so a new locale without a
stem silently falls back to English; the catalog-completeness test caught it on
rebase, which is what that test exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:11:19 -07:00
Charles Yao
d3087cbe34 fix(dify-builder): stop recommending the whole workspace when nothing matches
discover_resources initialised its selection to the entire tenant inventory and
only replaced it when the model returned a non-empty pick, so three paths -- no
model, a model that picked nothing, a failed call -- recommended everything. On
a workspace with 57 resources that is not merely noisy: the client pre-checks
every recommendation and submits them all by default, and bind_resources then
appends all 57 labels to the last plan item, which is the text fed to the graph
generator.

Recommending nothing is the honest answer when there is no signal. The card is
still emitted so the confirm gate keeps its active interaction and a later pass
cannot inherit a stale one, and a notice says why rather than leaving a blank
card. The conflict-policy control no longer renders over an empty list.

The test that should have caught this used a one-resource catalog, where "all"
and "none" are indistinguishable; it now uses 57.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:09:18 -07:00
Charles Yao
d20286482f fix(dify-builder): let a testdata submit through a draft that moved while typing
The testdata gate is the one place a human types for tens of seconds, and it
opens right after Builder has written the canvas -- long enough for the editor's
own autosave to move the app revision underneath them. Both revision gates then
refused the submit, and because a conflict leaves the session with only
check_recovery as an action, active_interaction drops to None and the client
freezes every input: the form stayed on screen but could no longer be typed into.

A testdata submit carries run inputs, not graph edits, so neither gate applies
to it. Exempting only the draft-changed check would not have been enough --
the stale-app-revision check above it fires first, on the base_app_revision the
client has held since the form was issued.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:09:18 -07:00
twwu
f0c2c644d2 Merge branch 'main' into feat/dify-builder 2026-09-18 10:24:19 +08:00
Charles Yao
65a47018b4 feat(dify-builder): say the app's name once when the build lands
Spec N4 has the build-complete card headline read "<App name> is ready", with
the annotation 名字只在回复句里念一次;记录卡上不出现 -- said once, in that
sentence, and never repeated on the completion receipt. We said it nowhere: the
card was titled "Build plan" and the engine had no app name to say.

DifyBuilderContext now carries app_name, seeded at session creation through a
get_app_name_fn seam alongside the existing revision/authorize ones. Env.rename_app
returns the name the database actually took rather than None, so the refine
records what landed instead of what it proposed -- the service normalizes after
the engine hands off, and the two can differ.

The headline is interpolated, so it gets a strings.py template or it would
survive localization untranslated. That pattern matches almost anything ending
in "is ready", so it sits last and every more specific template gets first
refusal.

The receipt already omitted the name; there is now a test pinning that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:09:23 -07:00
Charles Yao
aa2b4fe4ab feat(dify-builder): name apps from their prompt, and turn gate actions into card options
Two App Builder spec changes that share the Builder session contract.

Naming (spec N1-N3): an app created from the homepage prompt is no longer named
by the user. core/dify_builder/naming.py cuts the prompt's opening clause at
create time -- instant and infallible, since it runs inside POST /apps -- and
CreateAppPayload.name becomes optional alongside a new prompt field. The Builder
then refines that name once during goal analysis, where a model and the detected
reply language are already in hand, so the judgement calls that are
English-shaped in pure Python (dropping an imperative lead-in, spotting a
contentless prompt) work in any language. Plan approval freezes the name.

Card options: SessionView gains a Decision carrying the gate's choice as options
inside the card plus a fixed confirm/cancel pair, replacing a button row whose
shape changed at every state. Option ids are the old action ids untouched, so
resolve_action_kind and the per-state legality check are unchanged, and
SessionView.actions keeps being populated -- the deployed web client goes on
working until it reads decision instead.

The regenerated console OpenAPI doc also picks up ErrorCard.diagnostics, missed
when cc9ca8b1dd landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:55:38 -07:00
Charles Yao
cc9ca8b1dd feat(dify-builder): carry generation diagnostics into the exported debug log
api pods lose their logs on restart, so a failed build's real cause
("Workflow generator: structural validation failed: ...") could only be read
from a pod log that may already be gone. Session 01a08fd0 hit exactly that.

Carry the generator's own diagnostics back with the result instead:
BuildNodesResult and ErrorCard gain a ``diagnostics`` list, and build_nodes
records one entry per failed generation attempt -- initial AND the corrective
retry, so an error that changes between attempts stays visible -- plus the
all-rejected and exception paths.

Each entry holds a server-side UTC ``at`` (so the surviving pod-log window can
be searched around it), ``source``, ``attempt``, the generator's ``message``
(the same text the server logs), its ``codes``, and the full structured
``errors`` including ``node_id``. That is strictly more than the log line
carries: for an UNRESOLVED_REFERENCE the export now names the offending node.

The list rides in the ErrorCard's item payload, which is what the streamed
conversation item -- and therefore the exported debug log -- preserves. It is
not rendered in the UI. Retries that eventually succeed keep their breadcrumbs.

Deliberately not mirroring the "structural validation failed" prefix: build.py
cannot tell structural failures from the rest without duplicating the
generator's code taxonomy, which would go stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:16:10 -07:00
twwu
77924d3470 fix(web): preserve request bodies across auth retries
Refs ESQ1-287
2026-09-17 17:37:58 +08:00
twwu
782688563d Merge branch 'main' into feat/dify-builder 2026-09-17 17:01:34 +08:00
twwu
744596e5dc fix(web): recover workflow drafts after sync conflicts
Pause draft saves after version conflicts until the latest graph and hash are reloaded. Allow App Builder canvas refreshes to resolve the conflict without adapting or validating node data.

Refs: ESQ1-282
2026-09-17 16:59:49 +08:00
twwu
43f5a2fedb fix(web): restore App Builder sessions after refresh
Mount canvas and panel subscriptions before restoring persisted sessions. Release canceled loading state and ignore stale restore responses so graph generation can resume after a page refresh.

Cover delayed conversation history, stream reconnection, and StrictMode restoration. Fixes ESQ1-283.
2026-09-17 14:07:21 +08:00
twwu
a931c81cdc Merge branch 'main' into feat/dify-builder 2026-09-17 10:53:03 +08:00
twwu
0653d907ae fix(web): align checklist App Builder fix entry with test run 2026-09-17 10:46:59 +08:00
twwu
7d18af6483 Merge branch 'main' into feat/dify-builder 2026-09-17 09:57:23 +08:00
twwu
cecf2a1b9a fix(dify-builder): handle unavailable models during session creation 2026-09-11 18:28:52 +08:00
twwu
c675193591 fix(dify-builder): remove conversation message actions 2026-09-11 16:46:37 +08:00
twwu
90bfa8f270 fix(dify-builder): refine empty state and panel header 2026-09-11 16:29:28 +08:00
twwu
b9f4c7251c fix(dify-builder): refine entry toggle and stabilize workflow header 2026-09-11 15:49:14 +08:00
twwu
99ac6a1f9e fix(dify-builder): limit run Fix to live test results
Remove Fix entries from test run history. Keep the action below the failed status card in the live Test Run Detail panel and match the App Builder design.

Refs ESQ1-262.
2026-09-11 15:08:08 +08:00
twwu
345a60c94d fix(dify-builder): enable Fix for waiting sessions
Restore the editing draft before starting Fix from run history. Serialize Fix startup and clear the previous session UI when switching sessions.

Refs ESQ1-262.
2026-09-11 14:08:33 +08:00
Charles Yao
4e3ee71c52 fix(migrations): re-parent Builder migration onto main head to remove split
After a main-merge pulled in d8e4a6b1c902 (expand_normalized_email_providers),
the Alembic graph had two heads: the Builder line (…→a9cc8d552db4) and the
email-providers migration both forked off c3f1a9b2e6d4. `flask db upgrade head`
then fails with "Multiple head revisions are present", crash-looping dify-api
on startup (the only pod that runs migrations).

Re-parent b28a1b2fbf4d (add_workflow_copilot_tables) from c3f1a9b2e6d4 to
d8e4a6b1c902 so the Builder migrations chain after main's, yielding a single
head a9cc8d552db4. Column-add is independent of the email-providers change, so
the reordering is schema-safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cAkpbcvNmWY827x6SJsrM
2026-09-10 22:01:27 -07:00
twwu
eaed69929b fix(dify-builder): prevent stale workflow draft overwrites 2026-09-11 12:23:48 +08:00
Charles Yao
e59389776d fix(dify-builder): surface the specific reason in build-fail error card
When build-generate-graph failed at plan-approval, the error card always
showed one hardcoded generic message regardless of the actual cause
(UNRESOLVED_REFERENCE, non-object JSON, credit_balance_exhausted, ...).

Root cause: build_nodes() returned only list[MutationIntent]; on failure it
returned [], so the reason was logged internally but never reached the
handler, which had nothing to show but a fallback string.

Thread the reason out: build_nodes now returns BuildNodesResult{intents,
error}. _generation_error_text() extracts the best reason (top-level error
-> joined errors[].detail/message/code -> generic fallback), and every
failure path populates .error (generator error, all-intents-rejected, and
the except path via str(exc) so provider errors surface verbatim).
handle_plan_approval builds the ErrorCard body from build_result.error when
present, keeping the generic sentence only as a genuine fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 21:00:47 -07:00
Charles Yao
1e6d776697 fix(dify-builder): surface model-config test failures instead of repair-thrashing
A verify/affected-path run that fails because the workflow references a model
that isn't configured/available (ModelNotExistError, "Model X does not exist",
provider/credentials errors) was routed into the config-repair path -> diagnose
+ propose_repair kept proposing graph mutations (if-else / check nodes) that
can't fix a missing model, thrashing across rounds.

Add model_config_error_text() (classifier parallel to is_input_failure; pairs an
existence/credential signal with a model/provider/credential token so unrelated
"<node> does not exist" faults aren't swallowed). In both build and edit failure
branches, when it matches, surface a clear "model not configured" diagnosis with
NO staged repair and route to the await-repair gate: the user configures the
model (global update_model) or changes it, then re-runs. Catalog the two new
user-facing strings so the reply-language layer localizes them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 20:37:56 -07:00
Charles Yao
537a38157c fix(workflow-generator): repair options-less select start variables
Dify has no native boolean start-variable type, so a bool requirement the
Builder proposes gets emitted as a `select` — and the LLM often leaves its
`options` empty. An options-less select renders as an empty, unfillable dropdown
in the Builder's test-input form, so the user can never submit test data
(affects every workflow whose requirements include a bool field).

Add _normalize_start_select_variables to the generator postprocess (beside
_normalize_start_file_variables): a `select` start variable with no usable
(non-empty string) options is converted to a `text-input` the user can type
into; a select that declares real options is normalized to those strings and
kept. Idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 20:21:18 -07:00
twwu
8c41cbbd4c Merge branch 'main' into feat/dify-builder 2026-09-11 11:18:28 +08:00
Charles Yao
2a574c7f74 fix(dify-builder): ground built workflow nodes to the user-selected model
Build wrote the Builder's session/cognition model (e.g. deepseek) into every
generated node's model block, ignoring the model the user picked at resource
confirmation (e.g. gpt). _ground always used the generation model, and the
selected model resource only reached the plan as label text (bind_resources) —
so a workflow got a model with no runtime credentials -> ModelNotExistError ->
instant test failure.

Thread the selected resource_ids from resource_selection through
agent.build_nodes into build.build_nodes, resolve the selected MODEL resource
(id is `{provider}/{name}`) via _selected_workflow_model, and ground node model
blocks to it — falling back to the session/generator model when the user
selected no model. The generator still runs on the session model (cognition);
only the built workflow's runtime model follows the user's choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 19:18:36 -07:00
Charles Yao
968e442c10 fix(workflow-generator): repair HTTP-request response refs to body
Builds failed with an empty graph when the generator LLM wired downstream
references to an HTTP-request node's output as `{#node.response#}`. HTTP-request
nodes expose only {body, status_code, headers, files} — never `response` — and
the reference-repair pass only rewrites to a node's SOLE output, so with four
outputs the ref stayed unresolved: structural validation failed
(runner.py:646 UNRESOLVED_REFERENCE), generate_workflow_graph returned no graph,
and build_nodes produced an empty build ("Couldn't build the workflow").

Add a targeted, unambiguous output-name alias (http-request `response` -> `body`,
the response body) applied in _reconcile_variable_references before the
sole-output fallback, guarded so it only rewrites when `body` is actually
declared. Extensible via _OUTPUT_ALIASES for future known-wrong names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 17:49:15 -07:00
Charles Yao
70c92b97d9 fix(dify-builder): make M2 translation parsing model-agnostic
Harden the Localizer's batch translation against models that format JSON
differently (the Builder model is user-changeable):
- correlate by numeric INDEX instead of echoing source strings as keys, so a
  model that normalizes/truncates long keys no longer misses every lookup
- accept the reply as an index-keyed object OR a positional array, optionally
  wrapped one level (_translations_by_index)
- cache only real translations; a failed/partial reply is left UNCACHED so the
  next turn retries (self-heals) instead of sticking on the English original

TDD: added parser shape-variation tests + a no-cache-failure/self-heal test;
the fake now speaks the index protocol.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 17:31:38 -07:00
Charles Yao
28fc7d0fac fix(dify-builder): unwrap the model's translation envelope in the Localizer
Live e2e revealed M2 never translated the static catalog strings: the model
returns the batch-translate mapping wrapped under a "strings" key (echoing the
request envelope) — {"strings": {src: translation}} — so _fill_cache's
top-level table.get(src) missed every source and cached the English original.
Unwrap one level when the sources aren't top-level keys, and tighten the prompt
to ask for a flat object. The unit-test fake now mirrors the real wrapped shape
so this can't regress (a flat fake hid the bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 17:03:29 -07:00
Charles Yao
0597a8b512 feat(dify-builder): localize the update_model "Model changed to X" notice
Follow-up to the reply-language feature. The update_model action commits its
"Model changed to <name>" notice directly via compare_and_advance, bypassing
Runner._commit and thus the Env.localize_items hook, so that one notice stayed
English. Localize it in the service (when the session has a detected
reply_language) with a "Model changed to {name}" catalog template that
translates the frame and re-inserts the model name verbatim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 16:38:48 -07:00
Charles Yao
9b4e095e71 refactor(dify-builder): promote model_or_none to the agent protocol
Follow-up to the reply-language feature. `_model_or_none` was private to
LlmBuilderAgent, so the advance-task wiring reached it via a fragile
`getattr(agent, "_model_or_none", lambda: None)`. Make it part of the
DifyBuilderAgent protocol (typed `object | None` to keep the pure port free of
any model-runtime import) and implement it on every conforming agent
(LlmBuilderAgent renames the private method public; PlaceholderAgent and the
test doubles return None). The Localizer wiring now uses `agent.model_or_none`
directly. Also drops the useless-return ruff nit in the wiring test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 16:34:29 -07:00
Charles Yao
ad06a30961 fix(dify-builder): restore reply_language persistence, share translation cache, harden localize contract
Final-review fix wave for the "reply in the user's input language" feature:

- serde.py: context_from_dict omitted reply_language from the explicit field
  allowlist, so it round-tripped to "" on every read despite context_to_dict
  (asdict) serializing it -- the localization feature silently no-op'd after
  the first persisted write. Added it to the constructor call; TDD'd with a
  failing-then-passing round-trip test in test_serde.py.
- localize.py: Localizer._cache was per-instance, but a fresh Localizer is
  built per advance-task invocation, so the static string catalog was
  re-translated every turn instead of once per language. Moved the cache to a
  module-level _TRANSLATION_CACHE dict (safe to share: keys are
  tenant/session-independent catalog-string+language pairs); added an autouse
  fixture in test_localize.py to clear it between tests for isolation.
- localize.py: detect_language and _fill_cache called self._model_provider()
  outside their try/except, violating the Env callback contract that these
  must never raise. Moved both calls inside their try/except so a raising
  provider degrades to the existing safe fallback instead of propagating into
  advance/_commit.
- strings.py: documented that Template.translate_fields is advisory only --
  the Localizer re-inserts all captured groups verbatim and does not consult
  it. No behavior change.

uv run --project api pytest tests/unit_tests/core/dify_builder tests/unit_tests/services/dify_builder -q
-> 722 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 16:17:54 -07:00
Charles Yao
bd8951fbd6 feat(dify-builder): wire Localizer into the advance task (M2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:57:55 -07:00
Charles Yao
a9775b523f fix(dify-builder): strengthen localize test to prove persisted mutation; document callback contract (fix round 1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:49:15 -07:00
Charles Yao
e7c7c7684a feat(dify-builder): engine hooks for language detection + item localization (M2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:39:04 -07:00
Charles Yao
7e70c7e189 fix(dify-builder): validate detect_language reply shape before trusting it (M2 fix round 1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:33:11 -07:00
Charles Yao
32870a731a feat(dify-builder): Localizer — detect language + cached catalog translation (M2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:24:23 -07:00
Charles Yao
4e55747aa6 fix(dify-builder): add 3 missing status literals + restore typed annotations in strings.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:18:55 -07:00
Charles Yao
c54274e92f feat(dify-builder): static-string catalog for reply localization (M2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:10:40 -07:00
Charles Yao
9a87125231 feat(dify-builder): instruct agent to reply in the user's language (M1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:02:42 -07:00
Charles Yao
97b8354173 fix(dify-builder): capture verify-launch errors instead of swallowing them
The build/edit test handlers wrapped run_draft in a bare, non-logging
`except Exception` that turned any launch-time failure into a blank failed
run (dify_run_id="", per_node=[], no error). Diagnose then had nothing to
act on and the repair loop ran blind — the prod symptom where a stale test
input (ValueError "<var> is required in input form") produced an
undiagnosable await_repair loop.

Now the launch error is logged, captured, and persisted so diagnose/routing
can act on it:
- Run.error field + dify_builder_runs.error column (migration a9cc8d552db4)
- handlers_build/edit: logger.exception + launch_error_text(exc) -> Run.error
- is_input_failure() also inspects Run.error (+ "in input form" signal), so a
  stale-input launch failure routes to the testdata gate, not blind repair
- diagnose/_degraded_diagnosis surface the launch error when no node executed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 14:24:51 -07:00
twwu
485dede372 Merge branch 'main' into feat/dify-builder 2026-09-10 17:35:09 +08:00
twwu
e88d6d468a fix(web): scroll Dify Builder actions with conversation 2026-09-10 10:55:02 +08:00
twwu
c1e90107f5 fix(web): require model for Dify Builder prompts 2026-09-10 10:53:20 +08:00
twwu
0a8b35c2bf Merge branch 'main' into feat/dify-builder 2026-09-10 09:44:17 +08:00
twwu
a4260d7d91 chore: enable Dify Builder by default for testing 2026-09-09 16:10:32 +08:00
twwu
e808808168 Merge branch 'main' into feat/dify-builder 2026-09-09 15:55:44 +08:00
twwu
cac945b431 feat(web): support app creation with App Builder 2026-09-08 18:29:43 +08:00
twwu
9fea1cc3a8 Merge branch 'main' into feat/dify-builder 2026-09-08 15:22:53 +08:00
twwu
c51e277389 feat(dify-builder): show affected nodes in change cards
Render change sets directly in plan-style cards with affected node names,
IDs, and change details. Capture node metadata for proposed and applied
changes and persist it with conversation history.

Remove the view_changes action and expand/collapse state. Update generated
contracts, translations, and regression coverage.

Validation: 112 frontend tests and 742 backend unit tests passed.
2026-09-07 13:44:39 +08:00
twwu
e661825c03 Merge branch 'main' into feat/dify-builder 2026-09-07 11:25:24 +08:00
twwu
4f107ea524 fix(dify-builder): preserve form drafts and submit cleared fields 2026-09-07 10:58:27 +08:00
Charles Yao
adca9adff5 Merge feat/dify-builder-debug-log-export: Dify Builder debug-log export (v1)
Panel-header kebab that downloads a session's engine/transport trace (inbound
SSE frames + outbound commands) as JSON, for debugging. Non-reactive per-session
ring buffer, capture taps in the session controller, a pure export assembler +
circular-safe serializer, and the exportDebugLog i18n key across all 24 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 07:00:37 -07:00
Charles Yao
d9b775b193 style(dify-builder): format trace files and satisfy noUncheckedIndexedAccess in specs
The Task 1-3 implementers ran vp test but not vp check, so the trace
modules/specs shipped with formatter drift and TS2532 (possibly-undefined
index access) errors that vp test does not catch. Apply the formatter and
use optional chaining on array-index access in the specs, matching the
existing spec convention (calls[0]?.[0]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 06:59:45 -07:00
Wu Tianwei
8b52c102a3 feat(dify-builder): add live progress and conversation streaming (#41778) 2026-09-04 18:30:44 +08:00
Charles Yao
829bc277e0 fix(dify-builder): clear trace buffer when a new session starts
A new session created via startBuild/startEdit/startFix/startChecklistFix
without an intervening reset() left the previous session's trace frames
in the buffer, so a debug-log export's meta (describing the new session)
could be attached to a trace still containing the prior session's frames.
Clear traceRef in runCommand's startsSession branch, before the outbound
session_start append, so every new session starts with a fresh buffer.

Also adds two one-line clarifying comments: the inbound trace capture
site notes the payload is captured by reference and serialized only at
export time, and the serializeTraceExport replacer notes its single-
WeakSet cycle guard can also flag shared-but-acyclic sibling references
as [Circular] (acceptable for a debug export).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 03:28:53 -07:00
Charles Yao
c7b2b03bbe feat(dify-builder): add debug-log export control to the builder panel header
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 03:15:04 -07:00
Charles Yao
c25f991cde feat(dify-builder): add trace export assembler and safe serializer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 03:05:04 -07:00
Charles Yao
eca20fcf69 feat(dify-builder): capture inbound frames and outbound commands into the trace buffer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 02:58:53 -07:00
Charles Yao
aa4e49df23 feat(dify-builder): add non-reactive trace ring buffer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 02:50:17 -07:00
Charles Yao
31a972e961 Merge PR #41778 (feat/add-progress-event) into feat/dify-builder
Integrates the FE team's live-progress + conversation-streaming rework
(durable progress events, repository layer, snapshot-consistent reads,
idempotent message retries, EDIT_PUBLISH->EDIT_COMPLETE) and reconciles its
interrupted-step recovery model with the reconciler already on
feat/dify-builder.

Combined recovery model:
- interrupted (crash mid working-step, lock released, nothing durably
  recorded) -> Retry (recovery_continue, re-runs the handler) + Start over
- FAILED (handler raised, recorded by runner.fail) -> Restart only
- a Retry that itself raises lands in FAILED -> Restart (no wedge)

Kept from the PR: EDIT_PUBLISH working / EDIT_COMPLETE terminal, snapshot
refresh in apply_recovery_action, paused reset. Kept from the reconciler:
Retry on interrupted, state-aware access tier (EDIT_PUBLISH added to the
publish tier), apply_repair idempotency filter.

680 dify_builder unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 02:14:40 -07:00
twwu
0db923c6cc feat(dify-builder): align conversation cards with design 2026-09-04 16:10:32 +08:00
twwu
ac7db51302 fix(dify-builder): align form schema types with values 2026-09-04 16:10:22 +08:00
Charles Yao
3fec0e9458 fix(dify_builder): gate interrupted-step Retry at the working op's access tier
A recovery_continue (Retry) re-runs the interrupted working handler, which
re-invokes the state's privileged op -- publish (RELEASE) at build.publish/
fix.publish, a draft test run (TEST_AND_RUN) at the test/verify states. Before
this, recovery_continue was classified as base EDIT, so a Retry re-ran the
privileged op below its own authorization tier without re-checking. Make
_app_access_for_action state-aware for recovery_continue so it enforces the
same tier the state's normal action requires. recovery_restart only resets +
re-enters the entry state, so it stays EDIT.

Also: an interrupted working step now always surfaces the Retry/Start-over
offer, overriding a stale drift recovery_class a user may have left set at an
earlier waiting gate (drift recovery only applies at a waiting gate).

Addresses the two findings from the final reconciliation review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 00:18:17 -07:00
Charles Yao
f623dc3fe1 feat(dify_builder): recover an interrupted working step via Retry/Start over
When a session crashes mid working-step (e.g. build.publish; the async advance
raised and released the lock), it was a locked dead-end: working states surface
no UI actions and the composer locked with no way forward.

- Surface a RecoveryRef offer (can_continue=Retry, can_restart=Start over,
  recovery_class="") on an interrupted working-state view -- built directly so a
  real drift class still wins.
- Allow recovery_continue / recovery_restart at an interrupted working state in
  _internal_action_allowed. recovery_continue returns the working state
  (non-settled) so the runner re-runs the stalled handler; recovery_restart
  resets working fields and re-enters the entry state -- both reuse existing
  recovery machinery, no new action kinds / no runner change.

Adds unit tests (offer surfaced, allowance) + regression tests (recovery_continue
re-runs handle_publish and advances; recovery_restart resets to entry state;
repeat-failure re-surfaces the offer with no wedge).

This reconciler was reviewed as a standalone plan on the pre-merge base; here it
is re-applied onto feat/dify-builder after PR #41677 merged in the reworked
service.py + _internal_action_allowed gate (which makes the allowance necessary).
The apply_repair idempotency + delete-guard commits (safe fix.apply Retry) land
alongside it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 00:07:14 -07:00
Charles Yao
3dcb06db01 fix(dify-builder): exclude in-batch delete_node ids from apply_repair idempotency filter
apply_repair's already-present filter (added for interrupted-step Retry
idempotency) compared create_node/connect intents against before_graph
alone, with no awareness of delete_node intents in the same batch. A
from-scratch build sends delete_node(placeholder_start) + create_node(same
id) in one apply_repair call to replace the canvas's default placeholder
start with the generator's own start node -- the filter dropped the
create_node as "already present" while the delete_node still ran, deleting
the start node with no re-create, so the following connect raised
ValueError: node not found. Mirror handlers_build.py's existing M2 guard:
subtract in-batch delete_node targets from the present-node/edge sets
before filtering, so a delete+recreate of the same id in one batch survives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 00:01:42 -07:00
Charles Yao
61b35a36b7 fix(dify_builder): make apply_repair idempotent for already-present create/connect
Drops create_node intents whose id already exists and connect intents whose
edge already exists (referenced against the original draft graph), so a
fix.apply Retry re-applying the same intents is a clean no-op instead of a
duplicate/ValueError. graph_ops validation (raise on a genuinely fresh
duplicate id) is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 00:01:42 -07:00
twwu
efa7528275 fix(web): simplify builder conversation events 2026-09-04 14:40:50 +08:00
twwu
4893c5e621 refactor(dify-builder): separate reasoning from execution progress 2026-09-04 13:43:16 +08:00
twwu
eb25c2d8ee feat(dify-builder): add live progress and conversation streaming 2026-09-04 10:16:50 +08:00
twwu
537d2a3178 Merge branch 'main' into feat/dify-builder 2026-09-04 09:44:01 +08:00
twwu
c00852cf97 fix(api): linearize Dify Builder migration after main 2026-09-03 11:28:53 +08:00
twwu
a257ac4dee Merge branch 'main' into feat/dify-builder 2026-09-03 11:12:22 +08:00
twwu
c5655ffe9d fix(web): optimize Dify Builder composer input 2026-09-03 08:48:23 +08:00
Charles Yao
2b4df02305 fix(dify_builder): close SSE subscription even when the response is never iterated
progress_bus.subscribe() eagerly enters the pub/sub subscription (opening a
Redis connection + starting a listener thread) BEFORE the SSE generator
exists, so the subscribe completes before the Celery advance can publish.
But the only close() lived in the generator's finally -- which Python does
not run if the generator is never started. A streaming Response dropped
before its first frame (client aborted, or an after_request/teardown error)
therefore leaked the connection + thread until process restart.

stream_advance_frames now returns a small _ClosingFrameStream wrapper whose
close() closes the subscription directly (idempotent); the WSGI server calls
close() on the response iterable even on early disconnect, so cleanup fires
regardless of iteration. The inner generator keeps its own finally for the
iterated/disconnect paths, so this is strictly additive -- no happy-path
change. All seven call sites get it unchanged.

Adds a regression test (never-iterated stream -> close() -> subscription closed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:48:23 +08:00
Charles Yao
de8a5745ea fix(dify_builder): always emit a terminal SSE frame so held version can't go stale
Two paths ended a streaming advance without a terminal `state`/`error`
frame, leaving the SSE client stalled to the deadline with a stale held
version -> its next action 409s:

1. advance_session published the terminal `state` frame outside the
   try/finally and unguarded. If the post-advance view projection
   (get_session_view -> read_graph) or the publish itself raised after a
   SUCCESSFUL advance, the exception escaped and no terminal frame was
   sent. Guard it and fall back to a terminal `error` frame.

2. submit_action_stream emitted a terminal `state` frame on settle only
   for `message`. `update_model` also settles synchronously while bumping
   the version, so it now emits one too -- otherwise the FE keeps the old
   version and 409s on its next action.

Adds regression tests for both (projection-failure -> error frame;
update_model -> state frame).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:48:23 +08:00
Charles Yao
87b22412b6 ci: drop deleted gen-dify-builder-contract from autofix workflow
This branch removed the Dify Builder contract generator (contract_gen.py
and the gen-dify-builder-contract npm script), but autofix.yml still
invoked that script. On every api/** change the "Generate frontend
contracts" step failed (command not found under bash -e), turning the
job red and skipping the autofix-ci commit step -- so even the good
gen-api-contract output never landed. Remove the stale invocation and
the now-redundant contract_gen.py path-filter entries (already covered
by api/**).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:48:23 +08:00
twwu
e543eeac49 refactor(web): optimize Dify Builder streaming updates 2026-09-03 08:48:23 +08:00
twwu
3eacff14a6 feat: add typed SSE interactions to Dify Builder 2026-09-03 08:48:23 +08:00
Charles Yao
1eb5bb1dd1 fix(dify_builder): publish adapter unpacked publish_workflow's return as a tuple
WorkflowService.publish_workflow returns a single Workflow (-> Workflow), but
dify_port.publish still unpacked it as (workflow, retirement_candidates) -- a
stale signature. Every other caller was updated when the return type changed
(via the main merge); only the Dify Builder port was missed, so every publish
crashed with "TypeError: cannot unpack non-iterable Workflow object", surfaced
to the user as a generic "step failed" SSE frame.

Assign the single return value (retirement_candidates was unused anyway). The
existing test_dify_port publish test mocked publish_workflow as a 2-tuple, which
masked the bug -- corrected it to the real -> Workflow signature so it now guards
the regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:11:49 -07:00
Charles Yao
e7e14ab888 fix(dify_builder): stop empty/mode-mismatched builds silently emptying the canvas
A build for a workflow app whose goal reads like a chatbot produced an empty
canvas while the assistant reported "Workflow built on the canvas.": the plan
was chatbot-shaped, build_nodes fed it to the generator (mode="workflow"), the
LLM emitted a chatflow graph (answer node / no end node), the generator
rejected it as MISSING_TERMINAL -- a class it does NOT retry -- and build_nodes
swallowed the error into []. handle_plan_approval then treated [] as a
successful build: it deleted the placeholder start and advanced to
build.execution, leaving nothing on the canvas.

Two parts:

1. build_nodes now prepends an explicit workflow-topology directive (one
   start + >=1 end node, no answer nodes) to the generator instruction, and
   retries ONCE on an error/empty result with the specific error fed back as
   a corrective nudge -- covering the terminal-node failure the shared
   generator skips.

2. handle_plan_approval refuses to fake success: when build_nodes yields zero
   create_node intents it surfaces an honest error and stays in
   build.plan_approval (retryable) without deleting the placeholder, instead
   of emptying the canvas and claiming a build. A loop-back re-approve still
   returns the fixed create intents (only filtered as already-present), so
   zero create intents unambiguously means a genuine generation failure.

Adds regression tests for both. Note: part 1 improves LLM reliability
(verifiable live); part 2 is the deterministic guarantee against the
false-success empty canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 14:39:56 -07:00
twwu
3152ba3faa Merge branch 'main' into feat/dify-builder 2026-09-01 17:09:54 +08:00
Charles Yao
1ef298e5fd Fix dify-builder contract gen script cwd and stale docstring paths
uv run --project selects the venv but doesn't chdir, so invoking
gen-dify-builder-contract from packages/contracts (as autofix.yml does)
raised ModuleNotFoundError: No module named 'core'. Switch to
--directory so uv chdirs into api/ before running the module. Also
update two contract_gen.py docstrings that still pointed at the old
web/app/components/dify-builder/contract/types.ts output path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 00:06:31 -07:00
Charles Yao
b4f251cd25 ci(dify-builder): regenerate contract in autofix alongside @dify/contracts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 23:50:54 -07:00
Charles Yao
c6f7b9b832 refactor(dify-builder): consume contract from @dify/contracts/dify-builder
Also repoints 4 additional consumers under web/app/components/dify-builder/
(state.ts, types.ts, use-dify-builder-session.ts, and its test) that
imported the contract via a relative path ('./contract/types' /
'../contract/types') rather than the '@/app/...' alias the brief's grep
pattern searched for. Deleting the old generated file without updating
these would have broken the build; a full `pnpm run type-check` confirms
they were the only additional consumers and the fix introduces no new
errors (baseline vs. post-change error sets are identical, both containing
only 27 pre-existing, unrelated .next/ typegen errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 23:46:04 -07:00
Charles Yao
cad5919220 refactor(dify-builder): generate contract TS into @dify/contracts/dify-builder
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 23:34:45 -07:00
Charles Yao
9fe2e2a3fa feat(dify-builder): response_mode streaming returns SSE on create/action/message POSTs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 22:18:42 -07:00
Charles Yao
52d241d754 feat(dify-builder): streaming create_fix/build/edit_session with subscribe-before-dispatch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 22:18:42 -07:00
Charles Yao
6888282e95 feat(dify-builder): subscribe-before-dispatch streaming submit_action/message
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 22:18:42 -07:00
Charles Yao
fa4921eff0 feat(dify-builder): stream_advance_frames terminal-aware SSE generator
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 22:18:42 -07:00
Charles Yao
390eea61fb feat(dify-builder): terminal state frame carries the full SessionView
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 22:18:42 -07:00
twwu
e3cbd7b90b fix(api): linearize Dify Builder migrations 2026-09-01 11:25:21 +08:00
twwu
22be55af5f feat: redesign app builder fix flow 2026-08-31 18:18:30 +08:00
twwu
c200902173 refactor(web): move app builder button to canvas header 2026-08-31 18:18:30 +08:00
twwu
cb53f02905 Merge branch 'main' into feat/dify-builder 2026-08-31 13:31:50 +08:00
Charles Yao
ba7692e2e9 feat(dify-builder): log build_nodes empty/failed generation and starved resource sources
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:48 -07:00
Charles Yao
6f27a709ba feat(dify-builder): log llm-agent model-resolution failure once per advance
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:48 -07:00
Charles Yao
133e8fdc0c refactor(dify-builder): remove unused propose_build_repair from agent Protocol
It was stubbed to [] in LlmBuilderAgent, canned in PlaceholderAgent, and never
called by any handler — the Build failure path reuses Fix cognition (diagnose +
propose_repair). Removed from the DifyBuilderAgent Protocol, both implementations,
and all test doubles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:48 -07:00
Charles Yao
d2cfaf7f72 fix(dify_builder): narrow input-failure signals, fix build delete/create id collision
Final-review fix wave for the testdata-gate + start-selector slice:

- I1 (Important): is_input_failure's signal list included the bare
  "required" (and generic "variable not found"), which also fire on
  config/runtime faults unrelated to test inputs -- misrouting those
  failures to the *_AWAIT_TESTDATA gate, where the diagnosed auto-repair
  is never offered and re-running just loops at the gate. Narrowed
  _INPUT_FAILURE_SIGNALS to ("file variable", "not provided", "missing
  input"), favoring precision per the spec's ambiguous-failure ->
  config-repair safe default. The real file-input E2E error ("File
  variable not found for selector: [...]") still matches via "file
  variable".

- M2 (Minor, latent): handle_plan_approval's from-scratch delete-then-
  create-filter path could drop a generator create_node/connect that
  reuses a just-deleted placeholder start's id, since _already_present
  compared against the PRE-delete existing_node_ids/existing_edges --
  delete with no re-create, silently vanishing the node. Deleted ids are
  now excluded from that comparison set; the loop-back path (no deletes)
  is unaffected.

- Cleanups: state.py's Build/Edit state-count comments (14->15, 9->10,
  verified against the actual enum members), FormCard's docstring now
  lists the "testdata" variant, and handle_plan_approval binds
  current_graph's nodes to one local instead of calling .get() 3x and
  documents the delete-placeholder branch.

Tests: extended test_is_input_failure_matches_input_signals's sibling
with a config "required" case (red before the signal-list fix, green
after), and added a plan_approval test where the generator's create_node
reuses the deleted placeholder start's id (red before the id-exclusion
fix -- a dangling connect ValueError -- green after).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:48 -07:00
Charles Yao
db150d1a43 feat(dify-builder): Edit live test reuses prepared input, routes input-failures to testdata gate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:47 -07:00
Charles Yao
ef88f6532c feat(dify-builder): EDIT_AWAIT_TESTDATA gate + run_affected_tests routing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:47 -07:00
Charles Yao
cea65957bb feat(dify-builder): Build live test reuses prepared input, routes input-failures to testdata gate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:47 -07:00
Charles Yao
4f6a5f07b7 feat(dify-builder): BUILD_AWAIT_TESTDATA gate + run_test routing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:47 -07:00
Charles Yao
1dda1d3326 feat(dify-builder): is_input_failure + testdata_form_fields core helpers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:47 -07:00
Charles Yao
20d932177c fix(dify-builder): drop draft placeholder start on from-scratch build
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:47 -07:00
Charles Yao
86341638df fix(dify-builder): keep generator node ids in graph_translate (no start rename)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:47 -07:00
twwu
433046e0de Merge branch 'main' into feat/dify-builder 2026-08-30 14:25:22 +08:00
Charles Yao
8b640e44de Fix Dify Builder live-test escape paths to reset stale test inputs
Clear fc.test_input_ref and fc.verify_run_id on the four re-plan/revert
escape branches in Build and Edit (handle_review's continue_adjusting
and handle_reverted's retry_after_revert) so a rebuild+retest
regenerates fresh schema-shaped mock inputs instead of reusing the
first build's stale ones -- the approve_repair retest path is left
untouched since it must fairly retest against the same failing case.
Also corrects three doc/comment mismatches (llm_agent's 12->13 method
count, an await_repair docstring describing a branch that doesn't
exist, and state.py's Build state-count banner), and adds tests for
the run_draft-raises degrade path and Build/Edit pass-path canvas
event symmetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 19:26:48 -07:00
Charles Yao
53f805dbf7 test(dify-builder): restore canvas + summary-variant assertions on Edit test pass path
Task B2 review found that removing the old canned test_test_affected_paths_
emits_result_and_reaches_review test dropped two pass-path assertions that
none of the 4 replacement tests reproduced: that the success path fires both
the mark_test_success and mark_review_ready canvas events, and that the
emitted SummaryCard's variant is "review". Restore both onto
test_edit_test_pass_goes_to_review_with_real_run so a regression on either
point is caught again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 19:11:20 -07:00
Charles Yao
b3e9f1df3d feat(dify-builder): live Edit test via run_draft, fail routes to repair gate
Rewrite handle_test_affected_paths from a canned always-success stub to a
live handler mirroring Build's handle_test_and_repair (A5): derive mock
inputs once via start_schema/generate_mock_inputs and persist a TestInput
(reused on retest), run run_draft (degrading to a synthesized failed Run on
exception), and mint a real immutable Run. Pass -> edit.review with a green
TestResultCard + summary. Fail -> real diagnose/propose_repair, stage
fc.diagnosis/staged_repair/risk, emit a red TestResultCard + ErrorCard (+
ChangeSetCard when intents are proposed) -> edit.await_repair (the B1 gate).
No auto-apply; apply_repair still runs only inside handle_await_repair's
approve_repair branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 19:04:29 -07:00
Charles Yao
ac5f48a765 feat(dify-builder): EDIT_AWAIT_REPAIR gate + handler
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 18:53:09 -07:00
Charles Yao
f6d822a2db test(dify-builder): cover fail-path card assembly and no-repair branch
Extends the fail-routes-to-await-repair test to assert card content
(error tone, diagnosis body/node_id, change_set presence, mark_test_error
canvas event) and adds a dedicated test for the no-proposed-intents fail
branch, plus a light card-shape check on the pass path. Closes the
test-coverage gaps left by removing the old canned-arc tests in the A5
live-test rewrite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 18:43:12 -07:00
Charles Yao
badb67c720 feat(dify-builder): live Build test via run_draft, fail routes to repair gate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 18:33:32 -07:00
Charles Yao
26fdde78c2 feat(dify-builder): BUILD_AWAIT_REPAIR gate + handler
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 18:20:29 -07:00
Charles Yao
d3584d3e12 test(dify-builder): live-run FakeBuildDifyPort.run_draft (pass/fail toggle)
Upgrade FakeBuildDifyPort.run_draft from canned stub to real fail-then-pass fake. Add mutable verify_pass flag (default True) and run_draft_inputs tracking, mirroring FakeDifyPort behavior. Emits node events and returns Run with per_node (succeeded when verify_pass, else failed node with error). FakeEditDifyPort inherits the live behavior automatically since it only overrides __init__.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 18:08:32 -07:00
Charles Yao
bbbf2ff457 feat(dify-builder): real schema-shaped generate_mock_inputs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 18:01:16 -07:00
Charles Yao
e2b9e3aba3 feat(dify-builder): start_schema core helper (start node variables)
Implement start_schema(graph: Graph) -> StartSchema that reads the start
node's declared input variables from graph.nodes[].data.variables into
{"variables": [...]}. Pure dict access — stays in core without importing
services. Returns {"variables": []} when there is no start node or it
declares no variables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 17:52:36 -07:00
Charles Yao
cd5fb6bbf0 fix(dify-builder): ground model on all model-bearing nodes, carry branch handles, clean test lint
Final consolidated fix wave from whole-branch review of the Real Build +
Edit Cognition slice:

- _ground now overwrites the model dict on ANY create_node whose config
  carries a model dict (llm, question-classifier, parameter-extractor),
  not just "llm", so a drifted/hallucinated provider/name on those other
  node types no longer flows through ungrounded.
- to_intents now carries a generator edge's sourceHandle/targetHandle
  into the connect intent's args (source_handle/target_handle) so
  branching nodes (if-else, question-classifier) wire onto the correct
  port instead of collapsing onto the default handle; edges without
  handles still produce bare {from_node, to_node} args.
- Cleared the 17 ruff errors (PT018/E302/E306/E501) across the slice's
  test files by splitting composite asserts, adding missing blank
  lines, and wrapping long lines -- no assertions weakened, no noqa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:22:19 -07:00
Charles Yao
a07dc0e766 fix(dify-builder): correct method count in llm_agent docstring
Docstring said "9 now use real LLM cognition" but enumerated 3 Fix +
6 Build + 3 Edit = 12 methods (Task B3 added the 3 Edit methods
without updating the leading count). Fix the count to 12 of 14 to
match the enumerated list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:06:25 -07:00
Charles Yao
27be3de30a feat(dify-builder): wire real Edit cognition into LlmBuilderAgent
analyze_impact/propose_edit_plan/build_edit_intents now call
services.dify_builder.agent.edit.* via _model_or_none() instead of
delegating to the canned PlaceholderAgent. 9 of the Protocol's 14
Build/Edit/Fix methods now use real LLM cognition; only
generate_mock_inputs and propose_build_repair (returns []) remain
deferred pending the live-test step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:00:58 -07:00
Charles Yao
f801481fdc fix(dify-builder): guard analyze_impact target_node_ids; drop ARG noqa in edit tests
analyze_impact could raise on a non-list target_node_ids from the LLM (no
isinstance guard, unlike fields/values) -- violated the never-crash
contract. Guard it the same way. Also collapse the fake models' invoke_llm
signatures to **_kwargs (underscore-prefixed, no noqa) per the ARG lint
constraint, and pin two more malformed-shape degrade paths
(build_edit_intents non-list intents, propose_edit_plan non-list plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:54:08 -07:00
Charles Yao
f5458e16b3 feat(dify-builder): real Edit cognition (impact/plan/intents via Fix pattern)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:41:59 -07:00
Charles Yao
ac2523beb6 feat(dify-builder): goal-shaped Edit rules form from cognition output
analyze_impact now returns {fields, values, target_node_ids} instead of a
flat edit_rules dict, mirroring Build's goal-shaped form (A2). Rewire
handle_capability_check to read fc.form_fields/edit_rules/edit_target_node_ids
from that shape and render the FormCard via build_form_fields;
handle_impact_analysis merges the submitted payload on the dynamic
fc.form_fields keys instead of a fixed key tuple; handle_review's re_fix
branch re-renders the form the same way. Delete the now-dead
_EDIT_RULE_FIELDS/_EDIT_RULE_KEYS constants. PlaceholderAgent.analyze_impact
returns the new shape (the old fields become canned fields/values), so both
it and the LLM agent (which still delegates to canned) are shape-uniform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:28:30 -07:00
Charles Yao
cbb1487d6a fix(dify-builder): sync cards manifest to attached cards, cover remaining Build wirings
handle_test_and_repair's AssistantTurnItem.cards was unconditional even
though error/change_set cards are now only appended when
propose_build_repair returns intents -- the manifest claimed cards that
were never attached in the neutral (empty-repair) path. Make cards
conditional to match what's actually appended, and assert it in both
branches.

Remove three noqa comments added in the prior commit that suppressed
ARG001/ARG005 on new test code (the global constraint disallows noqa
for non-enabled rules); switch the genuinely-unused monkeypatch
args/lambda params to underscore-prefixed names instead, and drop a
build_nodes monkeypatch that exercised nothing. Add
test_remaining_build_methods_thread_model_and_tenant_args to cover
propose_plan_v1/discover_resources/bind_resources/build_nodes/
learn_from_build's model/tenant_id/model_config threading, matching
the existing analyze_goal coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:14:08 -07:00
Charles Yao
8683e6517c feat(dify-builder): wire real Build cognition + neutral empty test-repair
LlmBuilderAgent's 6 Build methods (analyze_goal, propose_plan_v1,
discover_resources, bind_resources, build_nodes, learn_from_build) now
delegate to services.dify_builder.agent.build instead of the canned
PlaceholderAgent. propose_build_repair returns [] -- there's no real
failure signal to diagnose until the live test step is de-canned.

handle_test_and_repair now conditions the error card + apply_repair on
a non-empty propose_build_repair result: placeholder mode keeps today's
"found and fixed" arc, while real cognition (which never reports a
failure yet) emits a neutral "no issues found" review summary instead
of a fake error/fix on a hardcoded node id that real build_nodes
wouldn't have generated anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:25:14 -07:00
Charles Yao
02edd73204 fix(dify-builder): stop fabricating llm completion_params, dedupe dataset_ids grounding
Grounding only overwrites provider+name on an llm node's model dict now,
preserving whatever mode/completion_params the generator produced instead
of synthesizing a temperature when none was present. knowledge-retrieval
grounding now assigns each node its own dataset_ids list copy instead of
aliasing one shared list across every KB node in the graph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:15:08 -07:00
Charles Yao
dc85ea51a2 feat(dify-builder): build_nodes via WorkflowGenerator with model+dataset grounding
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:04:58 -07:00
Charles Yao
0ba1f932a9 fix(dify-builder): address review findings for build.py cognition
- Fix S110: replace bare except-pass with explicit degrade behavior
- Fix E501: wrap long function signatures to <=120 chars
- Fix I001: sort imports in test file (resources to top)
- Fix PT018: split composite assertions into separate statements
- Add _BoomInstance for provider-error testing
- Add 5 degrade tests (analyze_goal, propose_plan_v1 x2, discover_resources, learn_from_build)
- Remove unused _KIND_BY_PREFIX dict
- Add noqa comments for test mock arguments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:50:40 -07:00
Charles Yao
2e77396d39 feat(dify-builder): real Build light cognition (analyze/plan/discover/bind/learn)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:40:05 -07:00
Charles Yao
bcd0f59100 fix(dify-builder): prevent duplicate start node ids on multiple starts
Only map the first start-type node to canonical 'start'; additional starts
retain their original ids to prevent collisions and ambiguous intents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:36:02 -07:00
Charles Yao
4ec3e5bfe7 feat(dify-builder): translate generator graph into build intents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:30:08 -07:00
Charles Yao
a5f8d71eb7 fix(dify-builder): dataset visibility + normalization guard + test coverage
FINDING 1 (dataset visibility): DatasetService.get_datasets requires user
parameter and silently omits ONLY_ME/PARTIAL_TEAM datasets on RBAC tenants.
Query Dataset model directly by tenant_id for complete grounding inventory.

FINDING 2 (normalization guard): Move fetch+normalize inside _safe to prevent
malformed records from raising after _safe returns. Use lambda builders to wrap
complete resource transformation including record normalization.

FINDING 3 (test coverage): Add test_mixed_failure_partial_success (one source
fails, others succeed) and test_malformed_tool_record_degrades (missing
tool_name key gracefully degrades).

Minor: compute model id once and reuse for both id and label fields; ensure
E302 two blank lines between test functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:24:56 -07:00
Charles Yao
d976f9bf66 feat(dify-builder): tenant resource inventory (models/datasets/tools)
Implement resources.py with list_tenant_resources() to normalize tenant's
configured models, datasets, and tools to ResourceRef(id, label, meta,
readiness). Each source degrades to empty list on failure for graceful
degradation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:15:57 -07:00
Charles Yao
14bd9425d6 feat(dify-builder): goal-shaped Build requirements form from cognition output
analyze_goal now returns {fields, values} instead of a flat requirements
dict, so the Build capability-check form renders whatever fields the
agent (placeholder or LLM) proposes rather than a hardcoded 6-field
constant. handlers_fix.build_form_fields whitelists/coerces the agent's
raw field specs into FormField (unknown type -> text). handle_goal_analysis
now merges submitted values against the dynamic fc.form_fields keys
instead of the deleted _REQUIREMENT_KEYS constant.

Also updates test_placeholder_agent.py's analyze_goal shape assertion,
which broke as a direct consequence of the new {fields, values} contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:06:47 -07:00
Charles Yao
28a4dad58f feat(dify-builder): persist goal-shaped form_fields on context
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 10:54:36 -07:00
Charles Yao
e44ab9bb57 fix(dify-builder): broaden filter_applicable's except, fix stale docstring + typo
filter_applicable's per-intent try/except only caught (ValueError, TypeError),
so a hallucinated insert_between with a non-dict edge arg raised an uncaught
AttributeError and crashed the whole advance instead of being rejected by the
dry-run. Broaden to except Exception (ruff does not flag it, so no noqa is
added). Add a regression test for the string-edge case.

Also update llm_agent.py's stale module docstring (Fix cognition is now real,
not delegated; Protocol has 14 methods, not 13) and fix a stray closing paren
in a test_handlers_fix.py comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 02:39:08 -07:00
Charles Yao
d5a8f5021f feat(dify-builder): explain the no-automatic-fix outcome in handle_propose
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 02:23:08 -07:00
Charles Yao
8a1f0382d3 feat(dify-builder): wire LlmBuilderAgent Fix methods to real cognition
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 02:12:46 -07:00
Charles Yao
8078f5c169 fix(dify-builder): resolve connect-intent endpoint types in _touched_node_types
_touched_node_types looked up connect intents via a nonexistent node_id key,
always resolving to "" and missing the external-side-effect type of either
endpoint. Branch connect on from_node/to_node instead, and add a test proving
the external risk floor fires on a non-structural set_node_config against an
http-request node.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 02:03:59 -07:00
Charles Yao
024c76f032 feat(dify-builder): real Fix propose_repair with dry-run validation + risk floor
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 01:56:13 -07:00
Charles Yao
a84491071f feat(dify-builder): real Fix diagnose + diagnose_checklist cognition
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 01:46:55 -07:00
Charles Yao
6714e5a1d0 fix(dify-builder): consolidate APPLY_FNS, simplify filter_applicable, add dup-id test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 01:40:29 -07:00
Charles Yao
f371732edc feat(dify-builder): graph_ops.filter_applicable dry-run validation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 01:32:06 -07:00
Charles Yao
c73dcc7720 fix(dify_builder): final review fixes — wiring test, stop char-split, ping log, lint sweep
- test_wiring.py: expected dict was missing SessionView's new `model` field
- model_resolver.normalize_completion_params: a string `stop` was being
  char-split into a list of letters instead of wrapped in a single-element
  list; added a regression test
- controllers/console/dify_builder._ping: log the exception server-side
  before returning the ok:false envelope
- ruff-clean the slice's new/extended test files (ARG001/ARG002/ARG005 via
  precise noqa, PT018 compound-assert splits, E306, E501) with no change
  to test behavior or assertion meaning

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
affe84777a feat(dify-builder): agent ping endpoint for live LLM connectivity
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
4c0efd947d feat(dify-builder): echo chosen model in SessionView + regen contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
f5815c8723 feat(dify-builder): accept + validate + stamp model_config on create
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
02a5f01f84 feat(dify-builder): DIFY_BUILDER_AGENT_MODE gate + factory/task model wiring
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
6d1ed0a4b4 feat(dify-builder): LlmBuilderAgent shell delegating to PlaceholderAgent
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
90fda2b665 fix(dify-builder): enforce ruff linting on LLM helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
68b82e8dd8 feat(dify-builder): shared LLM invoke/parse/retry helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
6bf013da3a feat(dify-builder): model resolver + BadRequestError -> 400
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:27 -07:00
Charles Yao
71a1e51917 feat(dify-builder): add model_config to session context + serde
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 22:51:26 -07:00
twwu
616a7b79ad fix: align Dify Builder requests with console CORS 2026-08-26 18:19:33 +08:00
twwu
7e580db1cc Merge branch 'main' into feat/dify-builder 2026-08-26 17:15:19 +08:00
Charles Yao
24de2c8417 Dedup Dify Builder handlers/services: extract shared helpers
Behavior-preserving code-quality cleanup across the Dify Builder engine:
extract emit_canvas, build_change_set, mint_checkpoint, action_kind, and
merge_known_keys into handlers_fix.py (shared by handlers_build.py and
handlers_edit.py); extract _add in repository.py for the id-preservation
idiom; extract _make_edge (and unify the node/edge id-collision loops
into _next_unique_id) in graph_ops.py; extract _sync_graph_only in
dify_port.py. No behavior, generated ids, or wire output changed — same
387-test suite passes, ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 19:32:57 -07:00
Charles Yao
b0586315f3 Fix cosmetic minor docstring and test assertions in handlers_build
FINDING 1: Updated module docstring in handlers_build.py to accurately
reflect that build.complete's completion summary is emitted by the
governance-tail handlers (handle_governance_feedback for automatic/disabled
policies, or handle_await_learning for the ask policy).

FINDING 2: Strengthened two per-policy governance tests to include notice
kind assertions:
- test_governance_automatic_learns_and_reaches_complete: add assert "notice" in kinds
- test_await_learning_accept_learns_and_completes: add assert "notice" in kinds

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 13:09:13 -07:00
Charles Yao
45ab0d38e6 feat(dify-builder): stamp skill_learning_policy + surface await_learning actions (C-2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:55:55 -07:00
Charles Yao
bad0046ccf feat(dify-builder): real governance tail — policy branch + build.await_learning (C-2)
Rework handle_governance_feedback into the 3-policy branch (automatic/
disabled/ask) reading fc.skill_learning_policy, and add handle_await_learning
to resolve the ask-policy accept_learning/skip_learning turn. Both funnel
into a shared _emit_completion helper for the build-complete summary.
Registered BUILD_AWAIT_LEARNING in build_registry().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:46:39 -07:00
Charles Yao
893ffedbf0 feat(dify-builder): add DIFY_BUILDER_SKILL_LEARNING_POLICY config + FeatureService surface (C-2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:38:04 -07:00
Charles Yao
6f02649374 feat(dify-builder): add DifyBuilderAgent.learn_from_build seam (canned) (C-2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:33:44 -07:00
Charles Yao
e4b0195528 feat(dify-builder): add build.await_learning state + skill_learning_policy context field (C-2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:24:19 -07:00
Charles Yao
859e78606f fix(dify-builder): correct mis-cased FE import paths (difyBuilder- → dify-builder-)
The rename camelCased 'copilot' inside kebab-case filenames in import
specifiers (use-difyBuilder-session, ./difyBuilder-session-view) while the
files themselves were kebab-cased on disk — next build module-not-found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:35:38 -07:00
Charles Yao
7e83264330 refactor: rename Workflow Copilot → Dify Builder
Project rename across api/ + web/: modules, symbols, routes
(/console/api/dify-builder/*), feature flag (DIFY_BUILDER_ENABLED),
Celery queue/tasks, the 7 DB tables (via migration df1b0114de40),
and regenerated contract artifacts. Behavior unchanged; 430 tests green,
ruff clean. Branch renamed feat/copilot-py-backend → dify-builder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:18:35 -07:00
Charles Yao
478e8bf1fe fix(copilot): drive recovery_restart through working FIX/FIX_CHECKLIST entry states
The runner's check_recovery/recovery_continue/recovery_restart short-circuit
committed the transition and returned immediately, without entering the
advance loop. recovery_restart in FIX/FIX_CHECKLIST mode (the forced path for
structural_invalidating drift) lands on the *working* fix.diagnose /
checklist.diagnose entry state, which the runner must drive by running its
handler -- the early return stranded the session there with no UI actions.
Fix: rest only when the landed state is waiting/terminal (covers stop/resume,
check_recovery, recovery_continue, and BUILD/EDIT restarts unchanged);
otherwise consume the action and fall through to the existing advance loop.

Also: parity-test recovery.entry_state_for against the states
service.create_*_session actually assigns; add classify()-through-runner
coverage for the three remaining recovery classes and target_node_ids guard
branches; pin recovery_ref_for's message for config_only/structural_compatible;
and qualify dify_port.py's graph_ops delegations (module import) so
structural_fingerprint/node_ids no longer read as unqualified self-recursion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 10:42:53 -07:00
Charles Yao
b810869941 feat(copilot): surface SessionView.recovery via RecoveryRef + regen artifacts (C-1)
Adds RecoveryRef to the contract, recovery_ref_for() to recovery.py, and
wires it into get_session_view() beside the existing CheckpointRef pattern.
Regenerates contract_schema.json and the FE types.ts from contract.py
(additive only: RecoveryRef + SessionView.recovery). Also updates
test_wiring.py's exact asdict() round-trip assertion to include the new
recovery: None default, since dataclasses.asdict(SessionView) is exhaustive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 10:14:45 -07:00
Charles Yao
ec6c6181a9 feat(copilot): recovery classification + runner check_recovery/continue/restart short-circuit (C-1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 10:02:14 -07:00
Charles Yao
f2b8ead9a4 feat(copilot): add RecoveryClass contract enum + regen artifacts (C-1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 09:56:07 -07:00
Charles Yao
6dbd2d3587 feat(copilot): record last_structure_fingerprint at all 10 snapshot-hash sites (C-1)
Every fc.last_snapshot_hash assignment across handlers_fix.py, handlers_build.py,
and handlers_edit.py now sets fc.last_structure_fingerprint alongside it, using
result.structure_fingerprint at apply-repair sites and
env.dify.structural_fingerprint(graph) at read-graph/restore sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 09:48:43 -07:00
Charles Yao
a789dd4858 feat(copilot): DifyPort.structural_fingerprint + graph_node_ids, apply_repair stamps fingerprint (C-1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 09:37:43 -07:00
Charles Yao
39665a8bc2 feat(copilot): add recovery fields to ApplyResult + CopilotContext + serde (C-1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 09:28:54 -07:00
Charles Yao
529db1f879 feat(copilot): add graph_ops.structural_fingerprint + node_ids (C-1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 09:22:15 -07:00
Charles Yao
f8ee3ce274 copilot: Slice 4 final-review polish (revert docstrings + test parity)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 05:58:52 -07:00
Charles Yao
47937f739f copilot: pause/resume (runner stop/resume short-circuit + RunStatus.PAUSED)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 05:42:18 -07:00
Charles Yao
7b41d09549 copilot: surface SessionView.checkpoint from fc.checkpoint_id
Populate the frozen SessionView.checkpoint field from the session context's
active checkpoint_id. When set, build a CheckpointRef with the checkpoint_id
and a generic "Restore point" label; otherwise None.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 05:33:17 -07:00
Charles Yao
1f79eb4748 copilot: real Build + Edit revert-restore via perform_revert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 05:23:39 -07:00
Charles Yao
655e060c10 copilot: perform_revert helper + real Fix revert-restore
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 05:11:01 -07:00
Charles Yao
8481cd6017 copilot: Repository.invalidate_conversation_items (approval-card invalidation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 04:57:54 -07:00
Charles Yao
2f1e342ed0 copilot: DifyPort.restore_graph (whole-graph draft restore)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 04:50:09 -07:00
Charles Yao
e464c2879f copilot: CopilotContext lifecycle fields (paused, checkpoint_seq) + serde
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 04:41:27 -07:00
Charles Yao
5d6e1c717b copilot: full Edit-flow acceptance + loop-idempotency tests
Add 4 end-to-end tests for the complete Edit flow: test_full_edit_flow_goal_to_publish
(capability_check → send_edit_goal → submit_edit_rules → approve_repair → run_affected_tests
→ review → publish_workflow → EDIT_PUBLISH terminal), test_full_edit_flow_keep_draft_completes_without_publish
(same but keep_draft without publishing), test_continue_adjusting_then_reapprove_is_idempotent
(loop back via re_fix), and test_revert_then_retry_after_revert_reapprove_is_idempotent
(loop via undo then re_fix). All tests drive the real Runner + edit_registry + PlaceholderAgent
+ FakeEditDifyPort with version-CAS for each advance().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 02:39:06 -07:00
Charles Yao
ef255c4dde copilot: Edit service wiring + create_edit_session + merged registry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 02:27:55 -07:00
Charles Yao
606cff1964 copilot: Edit test_affected_paths + review + reverted handlers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 02:17:26 -07:00
Charles Yao
5287d579d9 copilot: Edit plan_approval (the edit) + apply_changes handlers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 02:07:26 -07:00
Charles Yao
17dd777a29 copilot: Edit capability_check + impact_analysis handlers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 01:53:47 -07:00
Charles Yao
529e1626df test(copilot): FakeEditDifyPort pre-seeded graph fake
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 01:44:10 -07:00
Charles Yao
dddf6b16fd copilot: canned Edit cognition on PlaceholderAgent + CopilotAgent Edit methods
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 01:37:06 -07:00
Charles Yao
f668959ece copilot: CopilotContext Edit fields + serde defaults
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 01:29:17 -07:00
Charles Yao
8f39028fda copilot: make handle_plan_approval idempotent on loop-back rebuilds
continue_adjusting/revert/retry_after_revert all return to
build.initial_plan without resetting the already-built graph. Re-walking
find_resources -> confirm_resources -> approve_plan then re-emits the same
fixed create_node/connect intents, and the second apply_repair raised
ValueError on the colliding node id, dead-ending the session at
build.plan_approval.

Fix: before applying, read the current draft graph and drop any
create_node whose node_id already exists or connect whose (from, to) edge
already exists, so a re-approve after a loop-back is a no-op apply_repair
rather than a crash. built_node_ids is still set from the full 4-node set
regardless of what was actually applied this pass.

Adds an end-to-end loop test via the real Runner covering both the
continue_adjusting and retry_after_revert paths, asserting the session
reaches build.execution with the graph still at 4 nodes / 3 edges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 23:45:45 -07:00
Charles Yao
a2a308fe58 copilot: full Build-flow acceptance + Build service-table tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 23:03:52 -07:00
Charles Yao
747441ee55 copilot: Build publish + governance_feedback + reverted handlers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 22:45:07 -07:00
Charles Yao
b6e857b931 copilot: Build test_and_repair (canned) + review handlers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 22:34:52 -07:00
Charles Yao
ed4db951e6 copilot: Build resource_recommendation + plan_approval (the build) + execution
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 22:17:56 -07:00
Charles Yao
2ed1a5b42a copilot: strengthen Build goal_analysis merge test discrimination
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 22:07:25 -07:00
Charles Yao
3204c31b06 copilot: Build goal_analysis + initial_plan handlers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 22:00:12 -07:00
Charles Yao
25b897353d copilot: Build session bootstrap + capability_check handler + merged registry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 21:49:48 -07:00
Charles Yao
d0f6b09319 copilot: canned Build cognition on PlaceholderAgent + CopilotAgent Build methods
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 21:35:59 -07:00
Charles Yao
4be0e77ade copilot: rename FixContext -> CopilotContext + additive Build fields
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 21:24:48 -07:00
Charles Yao
32077736ba test(workflow-copilot): widen _StubDifyPort.apply_repair with on_canvas param
Completes Slice 1 Task 9's 'widen all DifyPort implementers' invariant — the
whole-branch review caught this third conformance stub that the per-task
reviews (aware of only WorkflowServiceDifyPort + FakeDifyPort) missed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 11:13:05 -07:00
Charles Yao
b3b4776e95 feat(workflow-copilot): opt-in per-intent canvas-event emission through apply_repair 2026-08-22 10:57:37 -07:00
Charles Yao
c8c1a7052d feat(workflow-copilot): apply_repair dispatches all 5 verbs + real graph diff feeds change_set
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 10:46:12 -07:00
Charles Yao
18e45a1964 feat(workflow-copilot): diff_graphs real before/after graph diff
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 10:34:43 -07:00
Charles Yao
df584c7221 feat(workflow-copilot): canned node-default-config registry for Build's 4 node types 2026-08-22 10:28:44 -07:00
Charles Yao
59b781ac50 feat(workflow-copilot): apply_insert_between graph mutation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 10:22:44 -07:00
Charles Yao
5c950b1a9a feat(workflow-copilot): apply_connect graph mutation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 10:15:45 -07:00
Charles Yao
5006a34226 feat(workflow-copilot): apply_delete_node graph mutation 2026-08-22 10:08:58 -07:00
Charles Yao
0097f7d2b1 fix(workflow-copilot): ruff-clean create_node tests + explicit-position sentinel + deepcopy config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 10:04:19 -07:00
Charles Yao
7a8e27c06d feat(workflow-copilot): apply_create_node graph mutation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 09:54:58 -07:00
Charles Yao
1e3578d341 feat(workflow-copilot): document + enforce MutationIntent's per-op arg contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 09:48:05 -07:00
Charles Yao
f9b38f9be0 fix(workflow-copilot): reachable reject at approval gate, client-only-action guard, contract doc fixes 2026-08-22 01:55:17 -07:00
Charles Yao
5b7d1e9a4f feat(workflow-copilot): FE adopts the interaction contract (data-driven actions + generated types) 2026-08-22 01:31:13 -07:00
Charles Yao
7b2e975c5f feat(workflow-copilot): typed contract schemas (TS + JSON) with lockstep check
Add contract_gen.py: a pure, deterministic generator that discovers every
StrEnum/dataclass/card defined in contract.py (plus EntryMode,
ConversationItem, and SessionView) via introspection and emits the checked-in
contract_schema.json (JSON Schema draft 2020-12) and
web/.../contract/types.ts. The discriminated ConversationItem union is built
from _Card.__subclasses__(), one oneOf/union branch per card kind.

test_contract.py gains a lockstep test that byte-compares a fresh
generate() call against the checked-in files (guards drift/hand-edits) and a
jsonschema.validate test that proves the generated schema is itself valid
and accepts a real SessionView instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 01:10:47 -07:00
Charles Yao
d089a84da7 refactor(workflow-copilot): re-express Fix conversation items onto the card vocabulary 2026-08-22 00:48:03 -07:00
Charles Yao
fd01a6cfe8 feat(workflow-copilot): data-driven SessionView.actions + action_id endpoint acceptance 2026-08-22 00:32:11 -07:00
Charles Yao
9a83b67cf6 feat(workflow-copilot): item/trace/canvas SSE frames + extended state frame 2026-08-22 00:15:21 -07:00
Charles Yao
55752cd435 feat(workflow-copilot): 17-kind card vocabulary + submit payload types 2026-08-22 00:00:46 -07:00
Charles Yao
5a098f89d4 fix(workflow-copilot): _run_status reports COMPLETE for Build/Edit terminal states 2026-08-21 23:49:45 -07:00
Charles Yao
eb1ee3981a feat(workflow-copilot): Action/CheckpointRef + SessionView entry_mode/phase/actions/checkpoint 2026-08-21 23:36:53 -07:00
Charles Yao
6bceb1be62 feat(workflow-copilot): contract enums + Build/Edit PcState sets 2026-08-21 23:24:13 -07:00
Charles Yao
a101a57308 chore(workflow-copilot): add checklist entry to the debug harness
Wire the existing startChecklistFix into the throwaway debug page: a Checklist Errors (JSON) input plus a Start Checklist Fix button, and send {passed:true} for the recheck action so await_recheck advances to the shared decision gate instead of looping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 01:48:31 -07:00
Charles Yao
4104863212 fix(workflow-copilot): track live session version from the SSE stream
runAction sends base_version equal to view.version, but view was only updated from HTTP responses (create/action/refresh), never the SSE stream. While the engine auto-advances (diagnose to await_verify) the held version stayed frozen at create time, so the next action base_version CAS returned 409 conflict. Reconcile view from snapshot/state frames so its version tracks the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 01:48:31 -07:00
Charles Yao
2e8ab05acb fix(workflow-copilot): re-parent copilot migration onto main head
The add_workflow_copilot_tables migration (b28a1b2fbf4d) kept down_revision=e4708db55c1d after the branch was rebased onto main, forking the Alembic graph into two heads and letting a fresh DB skip the main lineage (e.g. workflows.version_number), which 500s the editor /workflows/draft. Re-point down_revision to the main head (56124e050600) so the chain is linear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 01:48:31 -07:00
Charles Yao
72c171b63c feat(copilot): record the failed Dify run as a CopilotRun on session create
create_fix_session now treats failed_run_id as the Dify workflow-run id and
records it as an immutable original-failed CopilotRun, pointing
fc.failed_run_id at that row so the async diagnose can resolve it. Closes the
FE->diagnose gap for a live Fix flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:46:53 -07:00
Charles Yao
303442d7b3 feat(copilot): repoint FE to OSS snake_case workflow-copilot API 2026-08-18 21:46:53 -07:00
Charles Yao
086186a579 fix(copilot): close SSE subscription even if snapshot frame is interrupted
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:46:53 -07:00
Charles Yao
b8b381afc0 feat(copilot): register console workflow-copilot routes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:46:52 -07:00
Charles Yao
925491fac4 feat(copilot): SSE stream route (snapshot-first + heartbeat) 2026-08-18 21:45:07 -07:00
Charles Yao
e82bef9ec3 test(copilot): compose real console decorator stack in routes test 2026-08-18 21:45:07 -07:00
Charles Yao
388edeef80 feat(copilot): console JSON routes (create/view/actions/messages) 2026-08-18 21:45:07 -07:00
Charles Yao
6431d146dc feat(copilot): service wiring + view-serialize + error-map helpers 2026-08-18 21:45:07 -07:00
Charles Yao
9d982be3d0 feat(copilot): pluggable CopilotAgent factory (P3c seam) 2026-08-18 21:45:07 -07:00
Charles Yao
0cfea5cea4 feat(copilot): workflow_copilot_enabled feature flag 2026-08-18 21:45:07 -07:00
Charles Yao
38cf016ee3 fix(copilot): construct advance deps inside try so the lock always releases 2026-08-18 21:45:07 -07:00
Charles Yao
837ee6e593 test(copilot): async usecase→task→SQL acceptance 2026-08-18 21:45:06 -07:00
Charles Yao
3099be4c69 feat(copilot): interrupted-session reconciler (beat) 2026-08-18 21:45:06 -07:00
Charles Yao
1b975eb72e feat(copilot): async advance_session Celery task
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:06 -07:00
Charles Yao
f4ff26b2a7 test(copilot): cover dispatch lock-release-on-enqueue-failure + submit_message payload 2026-08-18 21:45:06 -07:00
Charles Yao
48419e3f69 feat(copilot): copilot session usecase + dispatch 2026-08-18 21:45:06 -07:00
Charles Yao
a7db7d36f1 feat(copilot): session progress bus over the broadcast channel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:06 -07:00
Charles Yao
043df4e6dc fix(copilot): prefix-serialize lock-release eval key + prefix regression test 2026-08-18 21:45:06 -07:00
Charles Yao
639235de98 feat(copilot): cross-process session advance lock 2026-08-18 21:45:06 -07:00
Charles Yao
eddb498df2 test(copilot): P1 engine runs on the SQL repository (SQLite) 2026-08-18 21:45:06 -07:00
Charles Yao
f889a97032 feat(copilot): SQL repository — checkpoints, runs, test-inputs, conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:06 -07:00
Charles Yao
2ca01cc802 feat(copilot): SQL repository — sessions + version-CAS
Add SqlCopilotRepository implementing the Repository Protocol's session
lifecycle (create_session/get_session) and compare_and_advance as a real
DB-level version-CAS: a single UPDATE ... WHERE version=base_version, with
rowcount==0 disambiguated into NotFoundError (missing session) vs
ConflictError (stale version). A UNIQUE(session_id, seq) collision on
conversation items during compare_and_advance is caught and mapped to
ConflictError, making the DB the seq authority. Remaining Repository
methods (checkpoints/runs/test-inputs/list_conversation) are stubbed with
NotImplementedError, deferred to Task 5.

Tested against SQLite in-memory: create/get round-trip, CAS version bump,
stale-version conflict leaving the row unchanged, missing-session
NotFoundError, and duplicate-seq ConflictError.
2026-08-18 21:45:06 -07:00
Charles Yao
0c4eefba1b feat(copilot): FixContext JSON (de)serialization 2026-08-18 21:45:06 -07:00
Charles Yao
c400f82527 feat(copilot): alembic migration for copilot session tables
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:05 -07:00
Charles Yao
f8f11ff38d feat(copilot): SQLAlchemy models for copilot session persistence 2026-08-18 21:45:05 -07:00
Charles Yao
3ec6c8990d fix(copilot): adapter review fixes — node_outputs test, tighter apply assert, publish audit fields, no-op apply short-circuit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:05 -07:00
Charles Yao
ef2ed808fd feat(copilot): WorkflowService-backed DifyPort adapter
Implements WorkflowServiceDifyPort, the real DifyPort adapter that wires
the pure P1 domain and Task 1-3 helpers to WorkflowService,
AppGenerateService, and the node-execution repository. Each method opens
its own session, resolves account/app via the identity helpers, and
mutates the draft graph client-side (no server-side patch primitive)
before writing it back with graph_only=True/sync_agent_bindings=False.
Pins the publish workflow_id+commit gotcha and the hash-mismatch
re-mapping. Confirmed via mocked-service unit tests and a runtime_checkable
isinstance(..., DifyPort) check.
2026-08-18 21:45:05 -07:00
Charles Yao
5a93bec706 feat(copilot): actor→account resolution + app loading with tenant guard 2026-08-18 21:45:05 -07:00
Charles Yao
388d3a949b feat(copilot): pure run-result mapping
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:05 -07:00
Charles Yao
535788f1c1 feat(copilot): pure set_node_config graph mutation 2026-08-18 21:45:05 -07:00
Charles Yao
4e73fb566b test(copilot): full fix-flow engine acceptance on fakes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:05 -07:00
Charles Yao
6d4e9dd59b feat(copilot): port canned placeholder agent 2026-08-18 21:45:05 -07:00
Charles Yao
0bd9b1f94a feat(copilot): port checklist handlers + merged fix registry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:05 -07:00
Charles Yao
b0177e9044 docs(copilot): document save_run id-preservation contract (T6 review) 2026-08-18 21:45:05 -07:00
Charles Yao
37008011e2 feat(copilot): port run-fix handlers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:04 -07:00
Charles Yao
04f350d2e5 feat(copilot): port runner with version-CAS advance
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:04 -07:00
Charles Yao
aa6eb92ac6 test(copilot): in-memory repository/dify/agent fakes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:04 -07:00
Charles Yao
95f2a11023 feat(copilot): port ports, value types, errors
Port dify-enterprise/server/pkg/enterprise/biz/copilot/{ports,errors}.go to
api/core/workflow_copilot as pure Python.

- errors.py: ConflictError, NotFoundError, BusyError (Exception subclasses,
  replacing Go's sentinel error values).
- models.py: add Diagnosis, Risk, MutationIntent, ChangeSet, NodeEvent,
  ApplyResult value dataclasses (Go: ports.go); tighten FixContext.diagnosis/
  staged_repair/risk/change_set from Any to these real types now that they
  live in the same module (avoids a models<->ports import cycle).
- ports.py: CopilotAgent / DifyPort / Repository as @runtime_checkable
  Protocols, snake_case methods per the port plan; DifyPort methods take
  actor: Actor instead of Go's ForwardAuth.
- test_ports.py: constructs each value type, asserts trivial stubs satisfy
  each Protocol via isinstance, and checks the error types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:45:04 -07:00
Charles Yao
1b98b0fb29 feat(copilot): port domain models 2026-08-18 21:45:04 -07:00
Charles Yao
d779b1629a feat(copilot): port PcState machine to api/core/workflow_copilot 2026-08-18 21:45:04 -07:00
Charles Yao
d4dd55226a fix(web): refresh draft into canvas before copilot checklist re-check 2026-08-18 21:45:04 -07:00
Charles Yao
fdfa631d02 feat(web): copilot panel — fix pre-publish checklist issues
Extend the Workflow Copilot editor panel to fix pre-publish checklist
errors alongside failed runs.

- Detect blocking checklist items client-side via useChecklist(nodes,
  edges, {flowType}) and offer a "Fix N checklist issues" entry next to
  the failed-run entry, splitting config errors (auto-fixable) from
  unconnected/plugin-missing items (surfaced but not auto-fixed in v1).
- Extend use-copilot-session's create path to POST checklist_errors
  (new startChecklistFix) alongside the existing failed_run_id path
  (startFix unchanged).
- Drive checklist.await_recheck: re-run the checklist against the
  current draft and submit the recheck action with
  passed = (fixable remaining === 0) and the full remaining list.
- Add copilot.checklist* i18n keys to en-US, matching the existing
  copilot.* keys' en-US-only precedent for this feature.
2026-08-18 21:45:04 -07:00
Charles Yao
22dc64d6e6 feat(web): surface Workflow Copilot inside the workflow editor
Add a copilot toggle button next to Test Run in the workflow header and
a left-mounted panel that runs the copilot Fix flow (start fix, live
SSE progress log, approve_repair/run_verify/provide_testdata/publish/
undo/re_fix actions, and a message box), auto-resolving the app id
(workflow store `appId`), workspace id (`currentWorkspaceAtom`), and
the most recent failed run (`useWorkflowRunHistory`) instead of
requiring manual entry.

Extracts the session/SSE request logic from the standalone
`/workflow-copilot-debug` page into a shared
`app/components/workflow-copilot` module (`use-copilot-session`) so
both the debug page and the new panel drive the same implementation.

Adds a `showWorkflowCopilotPanel` store flag (defaults false) mirroring
the existing `showEnvPanel` pattern, so the feature is fully additive
and does not affect rag-pipeline/snippet editors that reuse the same
header/panel shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 21:45:04 -07:00
Charles Yao
f49c6a1b30 feat(workflow-copilot): minimal debug page to drive the Fix backend 2026-08-18 21:45:04 -07:00
5982 changed files with 265162 additions and 149740 deletions

View File

@@ -26,15 +26,17 @@ Prefer:
## Styling
For Dify UI/Base UI state styling and callback composition, apply the package's [Styling] contract,
including its documented reasons for direct state access.
Flag:
- New CSS modules or ad hoc CSS when Tailwind utilities and Dify tokens cover the need.
- Component-level plain `.css` files or component CSS imported through `globals.css`; use scoped `*.module.css` only when Tailwind and component variants cannot express the style.
- Generic color utilities where Dify semantic tokens exist.
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or documented radius mappings exist.
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or standard Tailwind radius classes match the required design value. Apply [Styling] when choosing radius classes.
- `!` important modifiers or important CSS overrides without a narrow, documented reason.
- Manual class-list assembly through string concatenation, template strings, array `.join(' ')`, or a custom conditional combiner instead of `cn(...)`. Conditional values passed to `cn(...)` remain valid.
- JS conditional class branches for primitive visual states already exposed by Dify UI/Base UI `data-*` selectors.
- Incoming `className` placed before default classes in `cn(...)`, preventing call-site overrides.
- Arbitrary z-index or one-off layering fixes on overlays.
@@ -43,7 +45,6 @@ Use:
- `cn(...)` from the local package or utility already used by the file.
- Dify semantic tokens and Tailwind v4 utilities.
- Existing component variants before one-off class forks.
- Primitive selectors such as `data-disabled:*`, `data-checked:*`, `data-highlighted:*`, `group-data-*`, `peer-data-*`, and `has-[:focus-visible]` before adding React state or boolean props solely for styling.
- Component-level variants, semantic tokens, and normal cascade/order before `!` overrides. Use `!` only for a contained compatibility override that cannot be expressed through the component API or local selector structure.
## Imports
@@ -66,3 +67,5 @@ Flag:
- Error messages that state only the failure and not the next step.
Use feature-local translation keys by default. Alias only when crossing namespaces. `pnpm i18n:check --file <name>` should pass for any touched translation namespace.
[Styling]: ../../../../packages/dify-ui/docs/styling.md

View File

@@ -16,7 +16,7 @@ owner:
| Forms and field semantics | `packages/dify-ui/docs/forms.md` |
| Selection and typed values | `packages/dify-ui/docs/selection.md` |
| Portals, layers, and floating surfaces | `packages/dify-ui/docs/overlays.md` |
| Tailwind and radius tokens | `packages/dify-ui/docs/styling.md` |
| State styling, callbacks, and Tailwind | `packages/dify-ui/docs/styling.md` |
| Package tests and Storybook | `packages/dify-ui/docs/testing.md` |
For consumer code under `web/`, also read `web/AGENTS.md` for application-owned reuse policy and

View File

@@ -10,6 +10,7 @@ Use this skill to route component architecture decisions to its bundled referenc
## Topic Routing
- Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership].
- Dify UI wrappers or primitive state styling: read the package's [Public API authoring] and [Styling] contracts.
- Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state].
- Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data].
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state.
@@ -24,6 +25,8 @@ Identify the behavior owner, state lifetime, and public contract from the nearby
- Write canonical Tailwind v4 classes; prefer canonical utilities over equivalent arbitrary values.
- Common forms include `w-105` instead of `w-[420px]`, `px-2.25` instead of `px-[9px]`, `bg-linear-to-b` instead of `bg-gradient-to-b`, `wrap-break-word` instead of `break-words`, and `field-sizing-content` instead of `[field-sizing:content]`.
[Public API authoring]: ../../../packages/dify-ui/docs/authoring.md
[Styling]: ../../../packages/dify-ui/docs/styling.md
[data]: references/data.md
[interactions]: references/interactions.md
[ownership]: references/ownership.md

View File

@@ -10,13 +10,8 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
## Keyboard Commands
- Distinguish application commands from widget-local keyboard semantics. Use `@tanstack/react-hotkeys` for application commands; keep menu navigation, dialog Escape handling, editor behavior, and ARIA widget keys in their local primitive or owner.
- Use `useHotkey` or `useHotkeys` for registered commands. When an existing `onKeyDown` intentionally owns the command, use `matchesKeyboardEvent` rather than duplicating modifier parsing or adding another global listener.
- Keep registration and keycap or menu display derived from one canonical command. Distinguish registered commands, held keys, and display-only accelerators.
- Keep a single-owner command beside its component. Create a feature-local hotkey module only when several production files share it; tests alone do not justify extraction.
- Make availability and scope explicit with `enabled`, `ignoreInputs`, and `target`. Put a target ref on the actual behavior owner rather than creating wrapper DOM solely for hotkey scope.
- Preserve existing `preventDefault` and propagation behavior when migrating command APIs.
- Test observable command behavior, disabled and input scope, target scope, and the registration/display contract at the owning feature boundary.
- Keep widget navigation, dialog dismissal, and editor commands in their local primitive or owner.
- For Web application commands, follow [Keyboard commands] for TanStack registration, typed bindings, targets, event consumption, display, and verification. Do not recreate that policy in a wrapper or a second command framework.
## Secondary Surfaces
@@ -28,4 +23,5 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root.
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
[Keyboard commands]: ../../../../web/docs/hotkeys.md
[state ownership]: state.md

View File

@@ -15,7 +15,7 @@ runs:
run-install: 'false'
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -5,20 +5,25 @@ inputs:
run-install:
description: Install web dependencies after setting up the toolchain
default: 'true'
cache-save:
description: Save the dependency cache after the job completes
default: 'true'
runs:
using: composite
steps:
- name: Setup pnpm and Node.js
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
with:
install: false
- name: Setup Vite+
uses: voidzero-dev/setup-vp@9fd26ff49f9b5e6276c2b493da4454ad4527f7f4 # v1.20.0
uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1
with:
node-manager: false
package-manager: |
pnpm: false
npm: false
cache: true
# Nested composite post actions can evaluate inputs in the outer scope.
cache-save: ${{ inputs.cache-save || 'true' }}
run-install: ${{ inputs.run-install }}

View File

@@ -0,0 +1,21 @@
name: Wait for Core E2E Web Build
description: Wait for the current workflow run's Web build artifact while the producer finishes independently
outputs:
artifact-id:
description: ID of the ready Web build artifact
value: ${{ steps.wait.outputs.result }}
runs:
using: composite
steps:
- name: Wait for build artifact
id: wait
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
E2E_WAIT_ACTION_PATH: ${{ github.action_path }}
with:
result-encoding: string
script: |
const waitForBuild = require(`${process.env.E2E_WAIT_ACTION_PATH}/wait.cjs`)
return await waitForBuild({ github, context, core, runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT) })

View File

@@ -0,0 +1,104 @@
const { execFile } = require('node:child_process')
const { appendFile, mkdtemp, rm } = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { promisify } = require('node:util')
const waitForBuild = require('./wait.cjs')
const execFileAsync = promisify(execFile)
async function downloadWebBuild({
env = process.env,
execute = execFileAsync,
webDir = path.resolve(__dirname, '../../../web'),
} = {}) {
const repository = env.GITHUB_REPOSITORY
const runId = env.GITHUB_RUN_ID
const runAttempt = Number(env.GITHUB_RUN_ATTEMPT)
if (
!repository ||
!/^\d+$/.test(runId || '') ||
!Number.isInteger(runAttempt) ||
runAttempt < 1 ||
!env.GH_TOKEN
) {
throw new Error(
'Downloading the Web build requires GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, and GH_TOKEN.',
)
}
const run = (command, args, timeout = 60_000) =>
execute(command, args, {
env,
timeout,
maxBuffer: 32 * 1024 * 1024,
})
const api = async (endpoint, paginate = false) => {
const { stdout } = await run('gh', [
'api',
endpoint,
...(paginate ? ['--paginate', '--slurp'] : []),
])
return JSON.parse(stdout)
}
const prefix = `repos/${repository}/actions`
let summary = ''
const artifactId = await waitForBuild({
github: {
rest: {
actions: { listJobsForWorkflowRunAttempt: 'jobs', listWorkflowRunArtifacts: 'artifacts' },
},
paginate: async (kind) => {
const endpoint =
kind === 'jobs'
? `${prefix}/runs/${runId}/attempts/${runAttempt}/jobs?per_page=100`
: `${prefix}/runs/${runId}/artifacts?per_page=100`
const pages = await api(endpoint, true)
return pages.flatMap((page) => page[kind])
},
},
context: { repo: {}, runId },
core: {
info: console.log,
summary: {
addHeading(text) {
summary += `### ${text}\n\n`
return this
},
addRaw(text) {
summary += `${text}\n`
return this
},
async write() {
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, summary)
},
},
},
runAttempt,
})
const artifact = await api(`${prefix}/artifacts/${artifactId}`)
const directory = await mkdtemp(path.join(os.tmpdir(), 'dify-e2e-web-build-'))
try {
// Artifact names are immutable within a run. Use the name selected by wait.cjs,
// including its matching upload timestamp when only consumer jobs are rerun.
await run(
'gh',
['run', 'download', runId, '--repo', repository, '--name', artifact.name, '--dir', directory],
300_000,
)
await run('tar', ['-xf', path.join(directory, 'e2e-web-build.tar'), '-C', webDir])
console.log(`Web build artifact ${artifactId} extracted and ready.`)
} finally {
await rm(directory, { force: true, recursive: true })
}
}
module.exports = downloadWebBuild
if (require.main === module) {
downloadWebBuild().catch((error) => {
console.error(error)
process.exitCode = 1
})
}

View File

@@ -0,0 +1,112 @@
const assert = require('node:assert/strict')
const { access } = require('node:fs/promises')
const { test } = require('node:test')
const downloadWebBuild = require('./download.cjs')
function fixture({ downloadFails = false, producerFails = false } = {}) {
const calls = []
const env = {
GITHUB_REPOSITORY: 'owner/repo',
GITHUB_RUN_ID: '123',
GITHUB_RUN_ATTEMPT: '4',
GH_TOKEN: 'test-token',
}
const execute = async (command, args) => {
calls.push({ command, args })
if (command === 'tar') return { stdout: '' }
if (args[0] === 'run') {
if (downloadFails) throw new Error('artifact download failed')
return { stdout: '' }
}
if (args[1].includes('/jobs?')) {
return {
stdout: JSON.stringify([
{
jobs: [
{
name: 'Run Web Full-Stack E2E / Prepare Core E2E Web Build',
status: 'completed',
conclusion: producerFails ? 'failure' : 'success',
steps: [
{
name: 'Upload Web build',
conclusion: 'success',
started_at: '2026-09-22T04:08:08Z',
completed_at: '2026-09-22T04:08:12Z',
},
],
},
],
},
]),
}
}
if (args[1].includes('/artifacts?')) {
return {
stdout: JSON.stringify([
{ artifacts: [] },
{
artifacts: [
{
id: 42,
name: 'core-e2e-web-build-2',
created_at: '2026-09-22T04:08:11Z',
expired: false,
},
],
},
]),
}
}
assert.equal(args[1], 'repos/owner/repo/actions/artifacts/42')
return { stdout: JSON.stringify({ name: 'core-e2e-web-build-2' }) }
}
return { calls, options: { env, execute, webDir: '/test/web' } }
}
test('downloads the producer-selected artifact on consumer-only reruns, including paginated results', async () => {
const { calls, options } = fixture()
await downloadWebBuild(options)
assert.match(calls[0].args[1], /runs\/123\/attempts\/4\/jobs/)
const download = calls.find(({ args }) => args[0] === 'run')
assert.deepEqual(download.args.slice(0, 7), [
'run',
'download',
'123',
'--repo',
'owner/repo',
'--name',
'core-e2e-web-build-2',
])
const extract = calls.at(-1)
assert.equal(extract.command, 'tar')
assert.deepEqual(extract.args.slice(-2), ['-C', '/test/web'])
await assert.rejects(access(download.args.at(-1)), { code: 'ENOENT' })
})
test('does not extract a failed download and removes its temporary directory', async () => {
const { calls, options } = fixture({ downloadFails: true })
await assert.rejects(downloadWebBuild(options), /artifact download failed/)
assert.equal(
calls.some(({ command }) => command === 'tar'),
false,
)
await assert.rejects(access(calls.at(-1).args.at(-1)), { code: 'ENOENT' })
})
test('does not download or extract when the producer fails', async () => {
const { calls, options } = fixture({ producerFails: true })
await assert.rejects(downloadWebBuild(options), /Web build finished with failure/)
assert.equal(calls.length, 1)
})
test('requires CI metadata and credentials before starting preparation', async () => {
const { calls, options } = fixture()
for (const key of Object.keys(options.env)) {
await assert.rejects(
downloadWebBuild({ ...options, env: { ...options.env, [key]: '' } }),
/requires GITHUB/,
)
}
assert.equal(calls.length, 0)
})

View File

@@ -0,0 +1,65 @@
const { setTimeout: delay } = require('node:timers/promises')
module.exports = async function waitForBuild({
github,
context,
core,
runAttempt,
now = Date.now,
sleep = delay,
timeoutMs = 30 * 60 * 1000,
}) {
const started = now()
const run = { ...context.repo, run_id: context.runId, per_page: 100 }
core.info('Waiting for core-e2e-web-build in this workflow run')
while (now() - started < timeoutMs) {
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, {
...run,
attempt_number: runAttempt,
})
const producer = jobs.find(
(job) =>
job.name === 'Prepare Core E2E Web Build' ||
job.name.endsWith(' / Prepare Core E2E Web Build'),
)
if (producer?.status === 'completed' && producer.conclusion !== 'success') {
throw new Error(
`Web build finished with ${producer.conclusion} before publishing an artifact`,
)
}
if (producer && ['in_progress', 'completed'].includes(producer.status)) {
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, run)
const upload = producer.steps?.find((step) => step.name === 'Upload Web build')
const artifact = artifacts.find((item) => {
if (item.expired || !/^core-e2e-web-build-[1-9]\d*$/.test(item.name)) return false
if (producer.status === 'in_progress') {
// A rebuilding producer must publish this attempt's immutable artifact.
return item.name === `core-e2e-web-build-${runAttempt}`
}
// GitHub relabels reused successful jobs with the current run_attempt.
// Their upload step keeps its original timestamps, identifying the artifact
// to reuse when only consumers are rerun (including across several attempts).
return (
upload?.conclusion === 'success' &&
item.created_at >= upload.started_at &&
item.created_at <= upload.completed_at
)
})
if (artifact) {
const seconds = Math.round((now() - started) / 1000)
core.info(`Web build artifact ${artifact.name} ready after ${seconds}s`)
await core.summary
.addHeading('E2E Web build wait')
.addRaw(`Artifact wait: ${seconds}s`)
.write()
return String(artifact.id)
}
}
await sleep(Math.min(10000, timeoutMs - (now() - started)))
}
throw new Error('Timed out waiting for core-e2e-web-build in this workflow run')
}

View File

@@ -0,0 +1,164 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const waitForBuild = require('./wait.cjs')
function fixture({ artifacts = [], jobs = [producer], error, runAttempt = 2 } = {}) {
let elapsed = 0
const calls = []
const summary = {
addHeading() {
return this
},
addRaw() {
return this
},
async write() {},
}
return {
calls,
options: {
context: { repo: { owner: 'owner', repo: 'repo' }, runId: 123 },
core: { info() {}, summary },
runAttempt,
github: {
rest: {
actions: { listWorkflowRunArtifacts: 'artifacts', listJobsForWorkflowRunAttempt: 'jobs' },
},
async paginate(endpoint, params) {
calls.push({ endpoint, params })
if (error) throw error
const values = endpoint === 'artifacts' ? artifacts : jobs
return typeof values === 'function' ? values(elapsed) : values
},
},
now: () => elapsed,
sleep: async (ms) => {
elapsed += ms
},
timeoutMs: 20000,
},
}
}
const producer = {
name: 'Run Web Full-Stack E2E / Prepare Core E2E Web Build',
status: 'in_progress',
run_attempt: 2,
}
const artifact = {
id: 42,
name: 'core-e2e-web-build-2',
expired: false,
created_at: '2026-09-22T04:08:11Z',
}
const oldArtifact = {
...artifact,
id: 41,
name: 'core-e2e-web-build-1',
created_at: '2026-09-22T03:08:11Z',
}
const successfulProducer = {
...producer,
status: 'completed',
conclusion: 'success',
steps: [
{
name: 'Upload Web build',
conclusion: 'success',
started_at: '2026-09-22T04:08:08Z',
completed_at: '2026-09-22T04:08:12Z',
},
],
}
test('starts consuming the current artifact without waiting for producer cache saves', async () => {
const { options, calls } = fixture({ artifacts: (elapsed) => (elapsed ? [artifact] : []) })
assert.equal(await waitForBuild(options), '42')
assert.equal(options.now(), 10000)
assert.deepEqual(
calls.map((call) => call.endpoint),
['jobs', 'artifacts', 'jobs', 'artifacts'],
)
for (const { endpoint, params } of calls) {
assert.equal(params.run_id, 123)
assert.equal(params.owner, 'owner')
assert.equal(params.repo, 'repo')
if (endpoint === 'jobs') assert.equal(params.attempt_number, 2)
}
})
test('full reruns ignore the old artifact until the rebuilding producer publishes this attempt', async () => {
const { options } = fixture({
artifacts: (elapsed) => (elapsed ? [oldArtifact, artifact] : [oldArtifact]),
})
assert.equal(await waitForBuild(options), '42')
assert.equal(options.now(), 10000)
})
test('does not use an old artifact before the current producer appears or while it is queued', async () => {
const { options } = fixture({
artifacts: [oldArtifact],
jobs: (elapsed) => (elapsed ? [{ ...producer, status: 'queued' }] : []),
})
await assert.rejects(waitForBuild(options), /Timed out/)
})
test('consumer-only reruns reuse the actual successful upload despite a relabeled run_attempt', async () => {
const { options } = fixture({
runAttempt: 4,
artifacts: [oldArtifact, artifact],
jobs: [{ ...successfulProducer, run_attempt: 4 }],
})
assert.equal(await waitForBuild(options), '42')
assert.equal(options.now(), 0)
})
for (const conclusion of ['failure', 'cancelled', 'skipped']) {
test(`does not fall back to an old artifact when the producer finishes with ${conclusion}`, async () => {
const { options } = fixture({
artifacts: [oldArtifact, artifact],
jobs: [{ ...producer, status: 'completed', conclusion }],
})
await assert.rejects(waitForBuild(options), new RegExp(`Web build finished with ${conclusion}`))
assert.equal(options.now(), 0)
})
}
test('ignores unrelated and expired artifacts and stops at the deadline', async () => {
const { options } = fixture({
artifacts: [
{ ...artifact, expired: true },
{ ...artifact, name: 'other' },
],
})
await assert.rejects(waitForBuild(options), /Timed out/)
assert.equal(options.now(), 20000)
})
test('completed rebuilt producers select their upload instead of the old artifact', async () => {
const { options } = fixture({ artifacts: [oldArtifact, artifact], jobs: [successfulProducer] })
assert.equal(await waitForBuild(options), '42')
})
test('allows artifact visibility to catch up after a successful producer finishes', async () => {
const { options } = fixture({
artifacts: (elapsed) => (elapsed ? [oldArtifact, artifact] : [oldArtifact]),
jobs: [successfulProducer],
})
assert.equal(await waitForBuild(options), '42')
assert.equal(options.now(), 10000)
})
test('does not reuse artifacts without a successful matching upload step', async () => {
const { options } = fixture({
artifacts: [artifact],
jobs: [{ ...successfulProducer, steps: [] }],
})
await assert.rejects(waitForBuild(options), /Timed out/)
})
test('reports API errors rather than silently waiting until timeout', async () => {
const { options } = fixture({ error: new Error('Resource not accessible by integration') })
await assert.rejects(waitForBuild(options), /Resource not accessible/)
assert.equal(options.now(), 0)
})

94
.github/scripts/size-labeler.test.cjs vendored Normal file
View File

@@ -0,0 +1,94 @@
const fs = require('node:fs')
const assert = require('node:assert/strict')
const test = require('node:test')
const source = fs.readFileSync(
require('node:path').join(__dirname, '../workflows/size-labeler.yml'),
'utf8',
)
const script = source
.replace(/\r\n/g, '\n')
.split(' script: |\n')[1]
.split('\n')
.map((l) => l.replace(/^ /, ''))
.join('\n')
const run = new (Object.getPrototypeOf(async function () {}).constructor)(
'github',
'context',
'core',
script,
)
async function scenario(files, existing = [], changed = files.length) {
const calls = []
const warnings = []
const api = {
pulls: {
get: async () => ({ data: { changed_files: changed } }),
listFiles: 'files',
},
issues: {
listLabelsOnIssue: 'labels',
addLabels: async (p) => calls.push(['add', p.labels[0]]),
removeLabel: async (p) => calls.push(['remove', p.name]),
},
}
await run(
{
rest: api,
paginate: async (endpoint) =>
endpoint === 'files' ? files : existing.map((name) => ({ name })),
},
{
repo: { owner: 'test', repo: 'repo' },
payload: { pull_request: { number: 1 } },
},
{ warning: (s) => warnings.push(s) },
)
return { calls, warnings }
}
for (const [n, label] of [
[0, 'XS'],
[9, 'XS'],
[10, 'S'],
[29, 'S'],
[30, 'M'],
[99, 'M'],
[100, 'L'],
[499, 'L'],
[500, 'XL'],
[999, 'XL'],
[1000, 'XXL'],
]) {
test(`threshold ${n}`, async () =>
assert.deepEqual((await scenario([{ filename: 'a.py', additions: n, deletions: 0 }])).calls, [
['add', `size:${label}`],
]))
}
test('count deletions', async () =>
assert.deepEqual((await scenario([{ filename: 'a.py', additions: 0, deletions: 30 }])).calls, [
['add', 'size:M'],
]))
test('ignore nested lockfiles', async () =>
assert.deepEqual(
(
await scenario([
{ filename: 'api/uv.lock', additions: 9000, deletions: 5 },
{ filename: 'web/pnpm-lock.yaml', additions: 5000, deletions: 0 },
])
).calls,
[['add', 'size:XS']],
))
test('replace stale size only', async () =>
assert.deepEqual(
(await scenario([{ filename: 'a.py', additions: 10, deletions: 0 }], ['size:L', 'bug', 'web']))
.calls,
[
['add', 'size:S'],
['remove', 'size:L'],
],
))
test('idempotent', async () => assert.deepEqual((await scenario([], ['size:XS', 'bug'])).calls, []))
test('incomplete file list', async () => {
const r = await scenario([], ['size:L'], 3001)
assert.deepEqual(r.calls, [])
assert.equal(r.warnings.length, 1)
})

View File

@@ -52,7 +52,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'
@@ -80,7 +80,7 @@ jobs:
if [[ "$WCAG_PAGE" == "all" ]]; then
vp run e2e:accessibility:${{ inputs.level }}
else
pnpm exec tsx ./scripts/run-cucumber.ts --full -- --tags "@axe and @wcag-${{ inputs.level }} and @wcag-page-$WCAG_PAGE"
pnpm exec node ./scripts/run-cucumber.ts --full -- --tags "@axe and @wcag-${{ inputs.level }} and @wcag-page-$WCAG_PAGE"
fi
- name: Upload accessibility Cucumber report

View File

@@ -23,7 +23,7 @@ jobs:
with:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: '3.12'
enable-cache: false
@@ -86,7 +86,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -164,7 +164,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'
@@ -227,7 +227,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -282,7 +282,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: '3.12'
@@ -298,13 +298,6 @@ jobs:
set -euo pipefail
coverage_version=$(python3 -c 'import tomllib; print(next(p["version"] for p in tomllib.load(open("api/uv.lock", "rb"))["package"] if p["name"] == "coverage"))')
{
echo "### API Coverage"
echo ""
echo "Merged backend coverage report generated for Codecov project status."
echo ""
} >> "$GITHUB_STEP_SUMMARY"
mapfile -t unit_coverage < <(find coverage-data -type f -name 'coverage-unit-*' -print | sort)
mapfile -t controller_coverage < <(find coverage-data -type f -name 'coverage-controllers-*' -print | sort)
mapfile -t integration_coverage < <(find coverage-data -type f -name 'coverage-integration-*' -print | sort)
@@ -321,21 +314,28 @@ jobs:
exit 1
fi
report_file="$(mktemp)"
uvx --from "coverage==$coverage_version" coverage combine "${unit_coverage[@]}" "${controller_coverage[@]}" "${integration_coverage[@]}"
uvx --from "coverage==$coverage_version" coverage report --show-missing | tee "$report_file"
echo "Summary: \`$(tail -n 1 "$report_file")\`" >> "$GITHUB_STEP_SUMMARY"
{
echo ""
echo "<details><summary>Coverage report</summary>"
echo ""
echo '```'
cat "$report_file"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
uvx --from "coverage==$coverage_version" coverage xml -o coverage.xml
# Reuse the XML totals instead of analyzing every source file again for a text report.
python3 - <<'PY'
import os
import xml.etree.ElementTree as ET
coverage = ET.parse("coverage.xml").getroot()
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
summary.write("### API Coverage\n\n")
summary.write("Merged backend coverage report generated for Codecov project status.\n\n")
summary.write("| Metric | Covered | Total | Coverage |\n")
summary.write("| --- | ---: | ---: | ---: |\n")
for label, metric in (("Lines", "lines"), ("Branches", "branches")):
covered = int(coverage.attrib[f"{metric}-covered"])
total = int(coverage.attrib[f"{metric}-valid"])
percentage = f"{covered / total:.2%}" if total else "N/A"
summary.write(f"| {label} | {covered} | {total} | {percentage} |\n")
summary.write("\nSee Codecov for per-file coverage details.\n")
PY
- name: Report coverage
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:

View File

@@ -64,7 +64,7 @@ jobs:
python-version: '3.11'
- if: github.event_name != 'merge_group' && (steps.api-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true' || steps.dify-agent-changes.outputs.any_changed == 'true')
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
@@ -136,7 +136,8 @@ jobs:
- name: Generate frontend contracts
if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract
run: |
pnpm --dir packages/contracts gen-api-contract
- if: github.event_name != 'merge_group'
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4

View File

@@ -78,7 +78,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -87,7 +87,7 @@ jobs:
with:
bun-version: latest
- uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
- uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
with:
install: false
@@ -121,7 +121,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -166,7 +166,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -227,7 +227,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -266,7 +266,7 @@ jobs:
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
path: cli/test-results/
@@ -287,7 +287,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -341,7 +341,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -396,7 +396,7 @@ jobs:
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-last-${{ github.run_id }}
path: cli/test-results/

View File

@@ -51,7 +51,7 @@ jobs:
- name: Run smoke against live dify
working-directory: ./cli
run: pnpm exec tsx scripts/run-smoke.ts --base-url http://localhost:5001
run: pnpm exec node scripts/run-smoke.ts --base-url http://localhost:5001
- name: Dump dify logs on failure
if: failure()

View File

@@ -19,7 +19,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'
@@ -40,7 +40,7 @@ jobs:
cp envs/middleware.env.example middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
uses: hoverkraft-tech/compose-action@b659c744ee9bed25aea2307c017ad849bd104433 # v3.2.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
@@ -69,7 +69,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'
@@ -94,7 +94,7 @@ jobs:
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
uses: hoverkraft-tech/compose-action@b659c744ee9bed25aea2307c017ad849bd104433 # v3.2.0
with:
compose-file: |
docker/docker-compose.middleware.yaml

View File

@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

39
.github/workflows/image-resources.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Image optimization
on:
pull_request:
permissions:
contents: read
jobs:
image-resources:
name: Image optimization
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
fetch-depth: 0
- name: Set up workspace toolchain
uses: ./.github/actions/setup-web
with:
run-install: 'false'
- name: Install image checker dependencies
run: pnpm --filter @dify/image-resources... install --frozen-lockfile --ignore-scripts
- name: Type-check image checker
run: pnpm --filter @dify/image-resources type-check
- name: Test image checker
run: pnpm --filter @dify/image-resources test
- name: Check image optimization
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
pnpm --filter @dify/image-resources check:images --base "$BASE_SHA"

View File

@@ -21,9 +21,10 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
jobs:
# Keep orchestration independent of the Depot pool used for test execution.
pre_job:
name: Skip Duplicate Checks
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
steps:
@@ -37,7 +38,7 @@ jobs:
# Check which paths were changed to determine which tests to run
check-changes:
name: Check Changed Files
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
outputs:
api-changed: ${{ steps.changes.outputs.api }}
cli-changed: ${{ steps.changes.outputs.cli }}
@@ -66,6 +67,7 @@ jobs:
- 'scripts/lint_controller_sqlalchemy.py'
- 'scripts/ast_grep_rules/no_new_getattr.yml'
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
- '.github/workflows/style.yml'
- '.github/workflows/main-ci.yml'
- '.github/workflows/api-tests.yml'
@@ -112,6 +114,7 @@ jobs:
- '.github/workflows/main-ci.yml'
- '.github/actions/setup-web/**'
- '.github/actions/setup-core-e2e/**'
- '.github/actions/wait-e2e-web-build/**'
vdb:
- 'api/core/rag/datasource/**'
- 'api/tests/integration_tests/vdb/**'
@@ -159,9 +162,11 @@ jobs:
- 'scripts/ast_grep_guard.py'
- 'scripts/check_no_new_getattr.py'
- 'scripts/check_no_new_controller_sqlalchemy.py'
- 'scripts/check_no_new_session_mock.py'
- 'scripts/lint_controller_sqlalchemy.py'
- 'scripts/ast_grep_rules/no_new_getattr.yml'
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
- '.github/workflows/style.yml'
- '.github/workflows/main-ci.yml'
dify-agent-style:
@@ -178,6 +183,9 @@ jobs:
- 'pnpm-workspace.yaml'
- 'knip.config.ts'
- 'scripts/check-web-production-unused-after-knip-fix.mjs'
- '.npmrc'
- '.github/workflows/main-ci.yml'
- '.github/workflows/web-bundle-size.yml'
- '.github/workflows/style.yml'
- '.github/actions/setup-web/**'
ts-common-style:
@@ -189,6 +197,7 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.json'
- 'vite.config.ts'
- 'lint.config.ts'
- 'eslint.config.mjs'
@@ -207,7 +216,8 @@ jobs:
- '.editorconfig'
- '.vite-hooks/**'
# Run tests in parallel while always emitting stable required checks.
# Finalizers report unchanged paths directly, avoiding separate skip-only jobs.
# Keep each required check name stable and fail closed if classification fails.
api-tests-run:
name: Run API Tests
needs:
@@ -217,17 +227,6 @@ jobs:
uses: ./.github/workflows/api-tests.yml
secrets: inherit
api-tests-skip:
name: Skip API Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped API tests
run: echo "No API-related changes detected; skipping API tests."
api-tests:
name: API Tests
if: ${{ always() }}
@@ -235,21 +234,26 @@ jobs:
- pre_job
- check-changes
- api-tests-run
- api-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize API Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.api-changed }}
RUN_RESULT: ${{ needs.api-tests-run.result }}
SKIP_RESULT: ${{ needs.api-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "API tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "API tests ran successfully."
@@ -260,12 +264,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "API tests were skipped because no API-related files changed."
exit 0
fi
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
cli-tests-run:
@@ -277,17 +281,6 @@ jobs:
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() }}
@@ -295,21 +288,26 @@ jobs:
- pre_job
- check-changes
- cli-tests-run
- cli-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: 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 }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.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 [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "CLI tests ran successfully."
@@ -320,12 +318,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; 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
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
web-tests-run:
@@ -337,17 +335,6 @@ jobs:
uses: ./.github/workflows/web-tests.yml
secrets: inherit
web-tests-skip:
name: Skip Web Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web tests
run: echo "No web-related changes detected; skipping web tests."
web-tests:
name: Web Tests
if: ${{ always() }}
@@ -355,21 +342,26 @@ jobs:
- pre_job
- check-changes
- web-tests-run
- web-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Web Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.web-changed }}
RUN_RESULT: ${{ needs.web-tests-run.result }}
SKIP_RESULT: ${{ needs.web-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Web tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Web tests ran successfully."
@@ -380,12 +372,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Web tests were skipped because no web-related files changed."
exit 0
fi
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
web-e2e-run:
@@ -399,17 +391,6 @@ jobs:
run-external-runtime: false
secrets: inherit
web-e2e-skip:
name: Skip Web Full-Stack E2E
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web full-stack e2e
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
web-e2e:
name: Web Full-Stack E2E
if: ${{ always() }}
@@ -417,21 +398,26 @@ jobs:
- pre_job
- check-changes
- web-e2e-run
- web-e2e-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Web Full-Stack E2E status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
RUN_RESULT: ${{ needs.web-e2e-run.result }}
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Web full-stack E2E ran successfully."
@@ -442,12 +428,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Web full-stack E2E was skipped because no E2E-related files changed."
exit 0
fi
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
style-check:
@@ -475,17 +461,6 @@ jobs:
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed == 'true'
uses: ./.github/workflows/vdb-tests.yml
vdb-tests-skip:
name: Skip VDB Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped VDB tests
run: echo "No VDB-related changes detected; skipping VDB tests."
vdb-tests:
name: VDB Tests
if: ${{ always() }}
@@ -493,21 +468,26 @@ jobs:
- pre_job
- check-changes
- vdb-tests-run
- vdb-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize VDB Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.vdb-changed }}
RUN_RESULT: ${{ needs.vdb-tests-run.result }}
SKIP_RESULT: ${{ needs.vdb-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "VDB tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "VDB tests ran successfully."
@@ -518,12 +498,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "VDB tests were skipped because no VDB-related files changed."
exit 0
fi
echo "VDB tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
db-migration-test-run:
@@ -534,17 +514,6 @@ jobs:
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed == 'true'
uses: ./.github/workflows/db-migration-test.yml
db-migration-test-skip:
name: Skip DB Migration Test
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped DB migration tests
run: echo "No migration-related changes detected; skipping DB migration tests."
db-migration-test:
name: DB Migration Test
if: ${{ always() }}
@@ -552,21 +521,26 @@ jobs:
- pre_job
- check-changes
- db-migration-test-run
- db-migration-test-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize DB Migration Test status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.migration-changed }}
RUN_RESULT: ${{ needs.db-migration-test-run.result }}
SKIP_RESULT: ${{ needs.db-migration-test-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "DB migration tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "DB migration tests ran successfully."
@@ -577,12 +551,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "DB migration tests were skipped because no migration-related files changed."
exit 0
fi
echo "DB migration tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
sandbox-runtime-tests-run:
@@ -594,17 +568,6 @@ jobs:
uses: ./.github/workflows/sandbox-runtime-tests.yml
secrets: inherit
sandbox-runtime-tests-skip:
name: Skip Sandbox Runtime Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.sandbox-runtime-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped sandbox runtime tests
run: echo "No sandbox-runtime-related changes detected; skipping sandbox runtime tests."
sandbox-runtime-tests:
name: Sandbox Runtime Tests
if: ${{ always() }}
@@ -612,21 +575,26 @@ jobs:
- pre_job
- check-changes
- sandbox-runtime-tests-run
- sandbox-runtime-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Sandbox Runtime Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.sandbox-runtime-changed }}
RUN_RESULT: ${{ needs.sandbox-runtime-tests-run.result }}
SKIP_RESULT: ${{ needs.sandbox-runtime-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Sandbox runtime tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Sandbox runtime tests ran successfully."
@@ -637,12 +605,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Sandbox runtime tests were skipped because no sandbox-runtime-related files changed."
exit 0
fi
echo "Sandbox runtime tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
dify-agent-tests-run:
@@ -654,17 +622,6 @@ jobs:
uses: ./.github/workflows/dify-agent-tests.yml
secrets: inherit
dify-agent-tests-skip:
name: Skip Dify Agent Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.dify-agent-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped dify agent tests
run: echo "No dify-agent-related changes detected; skipping dify agent tests."
dify-agent-tests:
name: Dify Agent Tests
if: ${{ always() }}
@@ -672,21 +629,26 @@ jobs:
- pre_job
- check-changes
- dify-agent-tests-run
- dify-agent-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Dify Agent Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.dify-agent-changed }}
RUN_RESULT: ${{ needs.dify-agent-tests-run.result }}
SKIP_RESULT: ${{ needs.dify-agent-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Dify agent tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Dify agent tests ran successfully."
@@ -697,10 +659,10 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Dify agent tests were skipped because no dify-agent-related files changed."
exit 0
fi
echo "Dify agent tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1

View File

@@ -36,7 +36,6 @@ jobs:
- 'e2e/package.json'
- 'e2e/test-env.ts'
- 'e2e/tsconfig.json'
- 'e2e/tsx-register.js'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/post-merge.yml'
@@ -114,6 +113,9 @@ jobs:
name: External Runtime E2E
needs: check-changes
if: needs.check-changes.outputs.external-e2e-changed == 'true'
permissions:
actions: read
contents: read
uses: ./.github/workflows/web-e2e.yml
with:
run-external-runtime: true

View File

@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Python & UV
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true

View File

@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true

55
.github/workflows/size-labeler.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Pull Request Size Labeler
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review, edited]
permissions:
pull-requests: write
concurrency:
group: size-labeler-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
label:
runs-on: depot-ubuntu-24.04
steps:
# Only read PR metadata. Never check out or execute the PR's code here.
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo
const issue_number = context.payload.pull_request.number
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number })
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: issue_number, per_page: 100,
})
// GitHub caps this endpoint at 3,000 files; avoid labeling a partial count.
if (files.length !== pr.changed_files) {
core.warning('Skipping size label because the changed-file list is incomplete')
return
}
const ignoredNames = new Set(['uv.lock', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'])
const changes = files
.filter(file => !ignoredNames.has(file.filename.split('/').pop()))
.reduce((total, file) => total + file.additions + file.deletions, 0)
// Match the descriptions of the repository's existing size labels.
const buckets = [
[10, 'size:XS'], [30, 'size:S'], [100, 'size:M'],
[500, 'size:L'], [1000, 'size:XL'], [Infinity, 'size:XXL'],
]
const label = buckets.find(([limit]) => changes < limit)[1]
const sizeLabels = new Set(buckets.map(([, name]) => name))
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number, per_page: 100,
})
// Add first so an API error cannot leave the PR without a size label.
if (!labels.some(item => item.name === label)) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] })
}
for (const item of labels) {
if (sizeLabels.has(item.name) && item.name !== label) {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: item.name })
}
}

View File

@@ -61,7 +61,7 @@ jobs:
fetch-depth: 0
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: false
python-version: '3.12'
@@ -89,6 +89,10 @@ jobs:
if: inputs.run-python-style
run: uv run --project api python scripts/check_no_new_controller_sqlalchemy.py --base-rev "${{ inputs.base-rev }}"
- name: Run No New Session Mock Guard
if: inputs.run-python-style
run: uv run --project api python scripts/check_no_new_session_mock.py --base-rev "${{ inputs.base-rev }}"
- name: Run Type Checks
if: inputs.run-python-style
env:
@@ -107,8 +111,18 @@ jobs:
if: inputs.run-python-style
run: uv run --project api dotenv-linter ./api/.env.example ./web/.env.example
web-style:
name: Web Style
web-build:
name: Web Build and Bundle Analysis
if: ${{ !inputs.skip-checks && inputs.classification-succeeded && inputs.run-web-style }}
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/web-bundle-size.yml
with:
base-rev: ${{ inputs.base-rev }}
web-style-checks:
name: Web Static Checks
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-web-style) }}
runs-on: depot-ubuntu-24.04
defaults:
@@ -134,6 +148,9 @@ jobs:
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Check generated i18n types
run: vp run i18n:check-types
- name: Web tsslint
env:
NODE_OPTIONS: --max-old-space-size=4096
@@ -143,11 +160,6 @@ jobs:
working-directory: .
run: vp run knip
- name: Web build and unused translations check
env:
NODE_OPTIONS: --max-old-space-size=8192
run: vp run build:vinext
- name: Web dead code check production
working-directory: .
run: vp run knip:production
@@ -156,6 +168,24 @@ jobs:
working-directory: .
run: vp run knip:production-unused-check
# Preserve the existing required check while build and static checks run in parallel.
web-style:
name: Web Style
if: ${{ always() && !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-web-style) }}
needs: [web-build, web-style-checks]
runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Verify web checks
env:
BUILD_RESULT: ${{ needs.web-build.result }}
STYLE_RESULT: ${{ needs.web-style-checks.result }}
run: |
if [[ "$BUILD_RESULT" != 'success' || "$STYLE_RESULT" != 'success' ]]; then
echo "Web checks failed: build=$BUILD_RESULT, static checks=$STYLE_RESULT" >&2
exit 1
fi
ts-common-style:
name: TS Common
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-ts-common-style) }}
@@ -179,9 +209,25 @@ jobs:
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Restore static check task cache
id: static-check-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: node_modules/.vite/task-cache
key: vite-task-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
vite-task-static-v1-${{ runner.os }}-${{ runner.arch }}-
- name: Static check
run: vp run -w check
- name: Save static check task cache
if: success() && github.event_name != 'merge_group'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: node_modules/.vite/task-cache
key: ${{ steps.static-check-cache.outputs.cache-primary-key }}
superlinter:
name: SuperLinter
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-superlinter) }}

View File

@@ -9,6 +9,8 @@ on:
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .github/actions/setup-web/**
- .github/workflows/tool-test-sdks.yaml
concurrency:
group: sdk-tests-${{ github.head_ref || github.run_id }}
@@ -28,15 +30,13 @@ jobs:
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: ''
cache-dependency-path: 'pnpm-lock.yaml'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Test
run: pnpm test
- name: Build and verify package entry points
run: |
pnpm build
node --input-type=module -e "import { access } from 'node:fs/promises'; import 'dify-client'; await access('dist/index.d.ts')"

View File

@@ -47,6 +47,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.event.client_payload.head_sha || github.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
@@ -162,7 +163,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@3b8197d3d486006dd4af54613517f21ac6ac625e # v1.0.227
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1.0.235
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -285,6 +286,7 @@ jobs:
BASE_SHA: ${{ steps.context.outputs.BASE_SHA }}
HEAD_SHA: ${{ steps.context.outputs.HEAD_SHA }}
REPO_NAME: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
shell: bash
run: |
PR_BODY_FILE=/tmp/i18n-pr-body.md
@@ -295,6 +297,7 @@ jobs:
export LANG_COUNT
node <<'NODE' > "$PR_BODY_FILE"
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const changesPath = '/tmp/i18n-changes.json'
@@ -303,7 +306,25 @@ jobs:
: { changes: {} }
const filesInScope = (process.env.FILES_IN_SCOPE || '').split(/\s+/).filter(Boolean)
const sourceCommit = process.env.HEAD_SHA
let sourcePr
if (process.env.EVENT_NAME === 'repository_dispatch') {
try {
const pulls = JSON.parse(execFileSync('gh', [
'api', `repos/${process.env.REPO_NAME}/commits/${sourceCommit}/pulls`,
], { encoding: 'utf8' }))
sourcePr = pulls.find(pr => pr.merged_at && pr.merge_commit_sha === sourceCommit)
} catch (error) {
console.error(`Could not resolve source PR: ${error.message}`)
}
}
const lines = [
'## Source',
'',
...(sourcePr ? [`- **PR**: [#${sourcePr.number}](${sourcePr.html_url})`] : []),
`- **Commit**: [\`${sourceCommit.slice(0, 12)}\`](https://github.com/${process.env.REPO_NAME}/commit/${sourceCommit})`,
'',
'## Summary',
'',
`- **Files synced**: \`${process.env.FILES_IN_SCOPE || '<none>'}\``,

View File

@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

162
.github/workflows/web-bundle-size.yml vendored Normal file
View File

@@ -0,0 +1,162 @@
name: Web Bundle Analysis
on:
workflow_call:
inputs:
base-rev:
description: Exact base revision selected by the main CI event.
required: true
type: string
permissions:
contents: read
pull-requests: write
jobs:
measure:
name: Measure ${{ matrix.revision }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- revision: base
sha: ${{ inputs.base-rev }}
- revision: merge
sha: ${{ github.sha }}
env:
NEXT_TELEMETRY_DISABLED: '1'
NODE_OPTIONS: --max-old-space-size=6144
steps:
# Keep one collector for both revisions, including when it does not exist on base yet.
- name: Checkout reporting tools
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Preserve collector
run: cp web/scripts/bundle-analysis.ts "$RUNNER_TEMP/bundle-analysis.ts"
- name: Checkout measured revision
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ matrix.sha }}
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Instrument selected revision
run: node "$RUNNER_TEMP/bundle-analysis.ts" instrument web
- name: Full Vinext build with official Rolldown analysis
working-directory: web
shell: bash
run: |
set -o pipefail
started=$SECONDS
pnpm run build:vinext 2>&1 | tee "$RUNNER_TEMP/vinext-build.log"
printf 'Full Vinext build: **%s seconds**.\n' "$((SECONDS - started))" >> "$GITHUB_STEP_SUMMARY"
- name: Collect sizes
env:
COMMIT_SHA: ${{ matrix.sha }}
run: node "$RUNNER_TEMP/bundle-analysis.ts" collect web "$COMMIT_SHA" "$RUNNER_TEMP/bundle-size/summary.json"
- name: Upload size snapshot
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: web-bundle-summary-${{ matrix.revision }}
path: ${{ runner.temp }}/bundle-size/summary.json
if-no-files-found: error
retention-days: 14
- name: Upload official Rolldown analysis
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: web-bundle-analyzer-${{ matrix.revision }}
path: |
web/dist/client/bundle-analysis.json
web/dist/client/bundle-analysis.md
${{ runner.temp }}/vinext-build.log
include-hidden-files: true
if-no-files-found: error
retention-days: 14
report:
name: Compare bundle sizes
needs: measure
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout reporting tools
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node.js
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
with:
install: false
- name: Download size snapshots
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: web-bundle-summary-*
path: bundle-results
- name: Compare and summarize
id: compare
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
node web/scripts/bundle-analysis.ts compare \
bundle-results/web-bundle-summary-base/summary.json \
bundle-results/web-bundle-summary-merge/summary.json \
bundle-report.md
printf '\n[Download base/merge snapshots and official Rolldown analysis reports](%s#artifacts).\n' "$RUN_URL" >> bundle-report.md
printf '\nComparison: event base SHA vs the tested merge commit at GITHUB_SHA (PR or merge queue).\n' >> bundle-report.md
cat bundle-report.md >> "$GITHUB_STEP_SUMMARY"
# Forks still receive the job summary and artifacts, without a privileged PR build.
- name: Update PR report
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SHOULD_COMMENT: ${{ steps.compare.outputs.should-comment }}
with:
script: |
const fs = require('node:fs');
const { owner, repo } = context.repo;
const pull_number = context.payload.pull_request.number;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
if (pr.state !== 'open' || pr.head.sha !== context.payload.pull_request.head.sha ||
pr.base.sha !== context.payload.pull_request.base.sha) {
core.info('Skipping a superseded bundle report.');
return;
}
const body = fs.readFileSync('bundle-report.md', 'utf8');
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pull_number, per_page: 100,
});
const existing = comments.find(comment =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.startsWith('<!-- dify-vinext-bundle-size -->'));
if (process.env.SHOULD_COMMENT !== 'true') {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
core.info('Bundle changes are below the PR comment thresholds. See the job summary.');
return;
}
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body });
}

View File

@@ -9,6 +9,7 @@ on:
type: boolean
permissions:
actions: read
contents: read
jobs:
@@ -24,6 +25,9 @@ jobs:
with:
persist-credentials: false
- name: Test Web build synchronization
run: node --test .github/actions/wait-e2e-web-build/*.test.cjs
- name: Compute build source hash
id: build-source
shell: bash
@@ -34,10 +38,13 @@ jobs:
- name: Setup web dependencies
uses: ./.github/actions/setup-web
with:
# Browser jobs already save this dependency cache; avoid duplicate uploads.
cache-save: 'false'
- name: Restore Next.js build cache
id: next-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.next/cache
key: e2e-next-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }}-${{ steps.build-source.outputs.hash }}
@@ -73,15 +80,14 @@ jobs:
- name: Upload Web build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: core-e2e-web-build
name: core-e2e-web-build-${{ github.run_attempt }}
path: e2e-web-build.tar
if-no-files-found: error
overwrite: true
retention-days: 7
- name: Save Next.js build cache
if: github.event_name != 'merge_group' && steps.next-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.next/cache
key: ${{ steps.next-cache.outputs.cache-primary-key }}
@@ -89,7 +95,6 @@ jobs:
chromium-full:
name: Chromium Full (${{ matrix.shard }}/3)
if: ${{ !inputs.run-external-runtime }}
needs: core-build
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
@@ -114,17 +119,12 @@ jobs:
with:
browser: chromium
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: core-e2e-web-build
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
# The runner prepares the backend while waiting for and downloading the build.
- name: Run Chromium full shard
working-directory: ./e2e
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
env:
GH_TOKEN: ${{ github.token }}
run: vp run e2e:full --download-web-build -- --shard ${{ matrix.shard }}/3
- name: Upload Cucumber report
if: ${{ !cancelled() }}
@@ -150,9 +150,8 @@ jobs:
webkit-smoke:
name: WebKit Browser Smoke
if: ${{ !inputs.run-external-runtime }}
needs: core-build
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 45
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
@@ -172,17 +171,12 @@ jobs:
with:
browser: webkit
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: core-e2e-web-build
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
# Keep the same overlap and teardown ownership for the WebKit smoke lane.
- name: Run WebKit browser smoke
working-directory: ./e2e
run: vp run e2e:full -- --tags '@browser-smoke'
env:
GH_TOKEN: ${{ github.token }}
run: vp run e2e:full --download-web-build -- --tags '@browser-smoke'
- name: Upload Cucumber report
if: ${{ !cancelled() }}
@@ -221,7 +215,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -40,7 +40,7 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: blob-report-${{ matrix.shardIndex }}
path: web/.vitest-reports/*
path: web/.vitest/blob/*
include-hidden-files: true
retention-days: 1
@@ -103,7 +103,7 @@ jobs:
- name: Download blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: web/.vitest-reports
path: web/.vitest/blob
pattern: blob-report-*
merge-multiple: true

View File

@@ -77,9 +77,6 @@ How we prioritize:
AI tools are welcome, but you are responsible for understanding and verifying every change you submit. Describe the problem, your solution, and actual test results in your own words. Unreviewed or repetitive low-quality submissions may be closed.
> [!NOTE]
> If you are an automated agent, append `🤖🤖🤖` to the PR or issue title to opt into expedited review. Your submission will be prioritized.
### Setup the project
#### Frontend

View File

@@ -90,15 +90,13 @@ api-contract-lint:
@echo "✅ Response contract lint complete"
type-check:
@echo "📝 Running type checks (pyrefly + mypy)..."
@echo "📝 Running type checks (pyrefly)..."
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
@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 "✅ Type checks complete"
type-check-core:
@echo "📝 Running core type checks (pyrefly + mypy)..."
@echo "📝 Running core type checks (pyrefly)..."
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
@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"
test:
@@ -214,8 +212,8 @@ help:
@echo " make check - Check code with ruff"
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
@echo " make api-contract-lint - Check Flask response docs against returned schemas"
@echo " make type-check - Run type checks (pyrefly, mypy)"
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
@echo " make type-check - Run type checks (pyrefly)"
@echo " make type-check-core - Run core type checks (pyrefly)"
@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 ""

View File

@@ -58,6 +58,7 @@
<a href="./docs/sl-SI/README.md"><img alt="README Slovenščina" src="https://img.shields.io/badge/Sloven%C5%A1%C4%8Dina-d9d9d9"></a>
<a href="./docs/bn-BD/README.md"><img alt="README in বাংলা" src="https://img.shields.io/badge/বাংলা-d9d9d9"></a>
<a href="./docs/hi-IN/README.md"><img alt="README in हिन्दी" src="https://img.shields.io/badge/Hindi-d9d9d9"></a>
<a href="./docs/id-ID/README.md"><img alt="README dalam Bahasa Indonesia" src="https://img.shields.io/badge/Bahasa%20Indonesia-d9d9d9"></a>
</p>
Dify is an open-source LLM app development platform. Its intuitive interface combines AI workflow, RAG pipeline, agent capabilities, model management, observability features (including [Opik](https://www.comet.com/docs/opik/integrations/dify), [Langfuse](https://docs.langfuse.com), and [Arize Phoenix](https://docs.arize.com/phoenix)) and more, letting you quickly go from prototype to production. Here's a list of the core features:

View File

@@ -156,6 +156,7 @@ ARCHIVE_STORAGE_EXPORT_BUCKET=
ARCHIVE_STORAGE_ACCESS_KEY=
ARCHIVE_STORAGE_SECRET_KEY=
ARCHIVE_STORAGE_REGION=auto
ARCHIVE_STORAGE_ADDRESS_STYLE=path
# Azure Blob Storage configuration
AZURE_BLOB_ACCOUNT_NAME=your-account-name

View File

@@ -97,9 +97,6 @@ ignore_imports =
core.workflow.nodes.agent_v2.session_store -> services.agent.workspace_service
core.workflow.nodes.agent_v2.validators -> services.agent.knowledge_datasets
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
libs.device_flow_security -> controllers.openapi._models
libs.device_flow_security -> services.entities.feature_entities
libs.device_flow_security -> services.system_feature_service
libs.email_i18n -> services.entities.feature_entities
libs.email_i18n -> services.system_feature_service
libs.external_api -> core
@@ -199,6 +196,97 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:installed-app-access-boundary]
name = Installed app access application service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_access_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-management-boundary]
name = Installed app management service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-conversation-boundary]
name = Installed app conversation service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_conversation_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-message-boundary]
name = Installed app message service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_message_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:installed-app-message-persistence-boundary]
name = Installed app message persistence does not call the legacy message service
type = forbidden
source_modules =
repositories.installed_app_message_repository
forbidden_modules =
services.message_service
allow_indirect_imports = True
[importlinter:contract:conversation-persistence-boundary]
name = Conversation persistence does not call legacy conversation or workspace services
type = forbidden
source_modules =
repositories.installed_app_conversation_repository
repositories.conversation_lifecycle
repositories.agent_workspace_repository
forbidden_modules =
services.conversation_service
services.agent.workspace_service
allow_indirect_imports = True
[importlinter:contract:installed-app-generation-boundary]
name = Installed app generation service is framework and persistence neutral
type = forbidden
source_modules =
services.installed_app_generation_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:account-application-boundary]
name = Account application services and contracts are framework and persistence neutral
type = forbidden
@@ -264,6 +352,37 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:end-user-query-service-boundary]
name = App-scoped end user query service is framework and persistence neutral
type = forbidden
source_modules =
services.app_scoped_end_user_query_service
services.entities.app_scoped_end_user_entities
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:end-user-command-service-boundary]
name = App-scoped end user command service is framework and persistence neutral
type = forbidden
source_modules =
services.app_scoped_end_user_service
forbidden_modules =
configs
controllers
extensions
flask
models.model
repositories
sqlalchemy
werkzeug
[importlinter:contract:app-tracing-config-service-boundary]
name = App tracing configuration service is framework and persistence neutral
type = forbidden
@@ -390,6 +509,24 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:oauth-device-application-service-boundary]
name = OAuth device application service and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.oauth_device_application_service
services.oauth_device_contracts
forbidden_modules =
configs
controllers
extensions
flask
httpx
libs
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:workspace-member-query-service-boundary]
name = Workspace member query application service is framework and persistence neutral
type = forbidden
@@ -560,6 +697,20 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-package-service-boundary]
name = Recommended package application service is framework and persistence neutral
type = forbidden
source_modules =
services.recommended_app_package_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-catalog-gateway-boundary]
name = Recommended app catalog gateway does not depend on Flask
type = forbidden
@@ -568,6 +719,52 @@ source_modules =
forbidden_modules =
flask
[importlinter:contract:trial-app-application-boundary]
name = Trial app access and generation services are framework and persistence neutral
type = forbidden
source_modules =
services.trial_app_access_service
services.trial_app_generation_service
forbidden_modules =
controllers
extensions
flask
models
repositories
services.app_generate_service
sqlalchemy
werkzeug
[importlinter:contract:app-preview-query-boundary]
name = App preview queries are framework and persistence neutral
type = forbidden
source_modules =
services.app_preview_query_service
services.app_preview_details_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:message-suggested-questions-boundary]
name = Message suggested questions port is framework and persistence neutral
type = forbidden
source_modules =
services.message_suggested_questions_service
forbidden_modules =
controllers
extensions
flask
models
repositories
services.message_service
sqlalchemy
werkzeug
[importlinter:contract:billing-application-boundary]
name = Billing application services and ports are framework and infrastructure neutral
type = forbidden
@@ -602,3 +799,59 @@ forbidden_modules =
sqlalchemy
tasks
werkzeug
[importlinter:contract:app-mcp-server-service-boundary]
name = App MCP server application service is framework and persistence neutral
type = forbidden
source_modules =
services.app.mcp_server_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:console-app-mcp-server-controller-boundary]
name = Console App MCP server controller reaches persistence only through the composition root
type = forbidden
source_modules =
controllers.console.app.mcp_server
forbidden_modules =
extensions.ext_database
models.model
repositories
sqlalchemy
# The composition root (extensions.ext_application_services) wires repositories,
# so only direct imports are checked here.
allow_indirect_imports = True
[importlinter:contract:console-app-wraps-boundary]
name = Console app decorators load App rows without reaching into repositories
type = forbidden
source_modules =
controllers.console.app.wraps
forbidden_modules =
repositories
# models.model / models.workflow lazily import repositories.factory (legacy debt),
# so only direct imports are checked here.
allow_indirect_imports = True
[importlinter:contract:app-audio-boundary]
name = App audio operations and values are framework and persistence neutral
type = forbidden
source_modules =
services.app_audio_service
services.audio_types
forbidden_modules =
controllers
extensions
flask
models
repositories
services.audio_service
sqlalchemy
werkzeug

View File

@@ -23,3 +23,8 @@ Run direct Python commands through `uv run --project api`. Docker-backed integra
- Use Pydantic v2 for request and response models. Reuse domain-specific exceptions and translate them at the controller boundary.
- Use existing Celery task and queue owners for asynchronous work; do not route unrelated jobs through workflow-specific services.
- Celery tasks that may be retried or redelivered must keep side effects idempotent and log affected resource identifiers.
## Typing Best Practises
- Treat nullable types (`T | None`) strictly as union types; document the semantic meaning for each branch.
- Treat default parameter values as introducing distinct function overloads; verify that every added default has demonstrable practical utility and avoids masking unsafe runtime states.

View File

@@ -7,6 +7,18 @@ class AgentBackendConfig(BaseSettings):
Configuration settings for the Agent backend runtime integration.
"""
AGENT_SANDBOX_METERING_ENABLED: bool = Field(default=False, description="Persist independent E2B runtime usage.")
AGENT_SANDBOX_METERING_PROJECT_ID: str = Field(
default="", description="Allowed E2B project/team for this database."
)
AGENT_SANDBOX_METERING_START_AT: str = Field(
default="", description="Immutable UTC activation instant, aligned to a whole second; required when enabled."
)
AGENT_SANDBOX_METERING_INTERVAL_SECONDS: int | str = Field(
default=60,
description="Celery Beat interval in seconds; validated only when registering optional usage collection.",
)
AGENT_BACKEND_BASE_URL: str | None = Field(
description="Base URL for the Dify Agent backend service.",
default=None,

View File

@@ -1,3 +1,5 @@
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings
@@ -41,3 +43,8 @@ class ArchiveStorageConfig(BaseSettings):
description="Region for storage (use 'auto' if the provider supports it)",
default="auto",
)
ARCHIVE_STORAGE_ADDRESS_STYLE: Literal["path", "virtual", "auto"] = Field(
description="S3 addressing style for archive storage: 'path' (default), 'virtual' (virtual-hosted), or 'auto'",
default="path",
)

View File

@@ -970,6 +970,24 @@ class WorkflowConfig(BaseSettings):
ge=0.1,
)
DIFY_BUILDER_MAX_ADVANCE_SECONDS: PositiveInt = Field(
description=(
"Max wall-clock seconds for one dify_builder advance; also the advance-lock TTL and Celery soft_time_limit."
),
default=600,
)
DIFY_BUILDER_ENABLED: bool = Field(
description="Expose the Dify Builder feature (console routes + UI flag).",
# TODO: Restore this to False before the production release; it is enabled by default for testing only.
default=True,
)
DIFY_BUILDER_SKILL_LEARNING_POLICY: str = Field(
description="Dify Builder governance tail policy: ask | automatic | disabled.",
default="ask",
)
class WorkflowNodeExecutionConfig(BaseSettings):
"""
@@ -1537,6 +1555,15 @@ class CeleryScheduleTasksConfig(BaseSettings):
default=60 * 60,
)
ENABLE_DIFY_BUILDER_RECONCILER: bool = Field(
description="Enable the dify-builder interrupted-session reconciler (beat sweeper)",
default=False,
)
DIFY_BUILDER_RECONCILE_INTERVAL: PositiveInt = Field(
description="Minutes between dify-builder reconciler sweeps",
default=5,
)
class PositionConfig(BaseSettings):
POSITION_PROVIDER_PINS: str = Field(

View File

@@ -6,17 +6,6 @@ class PyProjectConfig(BaseModel):
version: str = Field(description="Dify version", default="")
class DifyToolConfig(BaseModel):
min_difyctl_version: str = Field(
description="Oldest difyctl version served on /openapi/v1",
default="0.0.0",
)
class ToolConfig(BaseModel):
dify: DifyToolConfig = Field(default=DifyToolConfig())
class PyProjectTomlConfig(BaseSettings):
"""
configs in api/pyproject.toml
@@ -26,8 +15,3 @@ class PyProjectTomlConfig(BaseSettings):
description="configs in the project section of pyproject.toml",
default=PyProjectConfig(),
)
tool: ToolConfig = Field(
description="configs in the [tool.*] section of pyproject.toml",
default=ToolConfig(),
)

View File

@@ -79,3 +79,4 @@ COOKIE_NAME_PASSPORT = "passport"
HEADER_NAME_CSRF_TOKEN = "X-CSRF-Token"
HEADER_NAME_APP_CODE = "X-App-Code"
HEADER_NAME_PASSPORT = "X-App-Passport"
HEADER_NAME_CATALOG = "X-Dify-Catalog"

View File

@@ -0,0 +1,65 @@
"""Stable OAuth bearer vocabulary shared across application layers."""
from __future__ import annotations
from enum import StrEnum
class Scope(StrEnum):
"""Catalog of bearer scopes recognised by the openapi surface.
`FULL` is the catch-all carried by `dfoa_` account tokens — it satisfies
any per-route scope requirement. `dfoe_` tokens carry the per-feature
scopes (`APPS_RUN`, `APPS_READ_PERMITTED_EXTERNAL`).
"""
FULL = "full"
APPS_READ = "apps:read"
APPS_READ_PERMITTED_EXTERNAL = "apps:read:permitted-external"
APPS_RUN = "apps:run"
WORKSPACE_READ = "workspace:read"
WORKSPACE_WRITE = "workspace:write"
class SubjectType(StrEnum):
# Annotation-only names are not members; they declare what `__new__` attaches.
scopes: frozenset[Scope]
# Whether the subject's token rows carry an `account_id`. A subject that does
# not is keyed by its external issuer instead.
bound_to_account: bool
ACCOUNT = ("account", frozenset({Scope.FULL}), True)
EXTERNAL_SSO = ("external_sso", frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), False)
def __new__(cls, value: str, scopes: frozenset[Scope], bound_to_account: bool) -> SubjectType:
obj = str.__new__(cls, value)
obj._value_ = value
obj.scopes = scopes
obj.bound_to_account = bound_to_account
return obj
class TokenType(StrEnum):
"""Many token types may serve one subject; the prefix is the only thing a
bearer string reveals, so it lives here and nowhere else.
"""
prefix: str
subject: SubjectType
OAUTH_ACCOUNT = ("oauth_account", "dfoa_", SubjectType.ACCOUNT)
OAUTH_EXTERNAL_SSO = ("oauth_external_sso", "dfoe_", SubjectType.EXTERNAL_SSO)
def __new__(cls, value: str, prefix: str, subject: SubjectType) -> TokenType:
obj = str.__new__(cls, value)
obj._value_ = value
obj.prefix = prefix
obj.subject = subject
return obj
@classmethod
def for_token(cls, token: str) -> TokenType | None:
return next((token_type for token_type in cls if token.startswith(token_type.prefix)), None)
TOKEN_CACHE_KEY_FMT = "auth:token:{hash}"

View File

@@ -267,3 +267,17 @@ also provide the source patch used to produce it; replace that patch with a clea
The manual import, annotation review, build, and validation commands live in `tools/api-pipeline/README.md` in
`dify-docs`. Regenerate this repository's OpenAPI Markdown and TypeScript/Zod contracts whenever their inputs change.
## Catalog metadata on /openapi/v1
Every `@endpoint` on `/openapi/v1` declares `op` (dotted lowercase id), `kind`
(`object|list|sse|text|file`) and a one-line `summary`; they live on the route's
`EndpointSpec`. `controllers/openapi/_catalog.py` derives the rest (input schema, bind,
tags) from the route and its Pydantic models. Files are declared in the body model with
`UploadPart`; never add a separate files decorator or parse `request.files` in a handler.
Run ops are per app mode (`console_app.<mode>.run`), each a self-contained route with the exact
body that mode takes.
Next steps for the caller go in a `hints` field (see `_hints.py`); `hints` is reserved.
Frozen after release: kind names, bind values, the pagination envelope, and any field the
CLI's fold table reads from an SSE event. Breaking input changes get a new op id; the old
one stays and is marked `deprecated=True`.

View File

@@ -0,0 +1,27 @@
"""Create binary audio responses and own their stream cleanup."""
from flask import Response, stream_with_context
from core.base.tts.audio_mime import inspect_audio_stream, resolve_audio_mime_type
from services.audio_types import AudioOutput
def audio_binary_response(output: AudioOutput | None) -> Response | None:
"""Preserve audio MIME and request context while releasing provider streams."""
if output is None:
return None
if isinstance(output.data, (bytes, bytearray, memoryview)):
data = bytes(output.data)
return Response(data, content_type=resolve_audio_mime_type(data, output.mime_type))
audio_stream, mime_type = inspect_audio_stream(output.data, output.mime_type)
try:
response = Response(
stream_with_context(audio_stream), # pyrefly: ignore[no-matching-overload]
content_type=mime_type,
)
response.call_on_close(audio_stream.close)
return response
except BaseException:
audio_stream.close()
raise

View File

@@ -116,7 +116,7 @@ class AgentBehindApp(_ParamLocator):
binding = agent_binding(tenant_id, _required(path_args, self.param))
if binding is None or binding.scope == AgentScope.WORKFLOW_ONLY:
return None
return ResourceIdentity(self.scope, str(binding.id))
return ResourceIdentity(self.scope, binding.id)
def owner_id(self, tenant_id: str, identity: ResourceIdentity) -> str | None:
return None

View File

@@ -14,6 +14,8 @@ from flask import request
from flask_restx import Namespace
from pydantic import BaseModel, TypeAdapter
from libs.flask_restx_compat import TYPED_EVENT_STREAM_RESPONSE_VENDOR_KEY
DEFAULT_REF_TEMPLATE_OPENAPI_3_0 = "#/components/schemas/{model}"
@@ -106,6 +108,19 @@ def register_response_schema_models(namespace: Namespace, *models: type[BaseMode
register_response_schema_model(namespace, model)
def typed_event_stream_response(namespace: Namespace, event_model: type[BaseModel]):
"""Document a pure SSE response whose data frames have a registered schema.
Flask-RESTX applies ``produces`` to every status. The OpenAPI finalizer uses
this marker to retain the 2xx event schema and document pre-stream errors as
JSON responses.
"""
return namespace.doc(
produces=["text/event-stream"],
vendor={TYPED_EVENT_STREAM_RESPONSE_VENDOR_KEY: event_model.__name__},
)
def get_or_create_model(model_name: str, field_def):
# Import lazily to avoid circular imports between console controllers and schema helpers.
from controllers.console import console_ns

View File

@@ -1,5 +1,7 @@
from collections import OrderedDict
from http import HTTPStatus
from importlib import import_module
from typing import cast
from flask import Blueprint, current_app, got_request_exception
from flask_restx import Namespace
@@ -29,6 +31,11 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError)
}, status.value
# Flask-RESTX dispatches in registration order; the shared Exception handler
# must not shadow this Console-specific error mapping.
cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(ActiveWorkspaceRequiredError, last=False)
console_ns = Namespace("console", description="Console management API operations", path="/")
RESOURCE_MODULES = (
@@ -49,6 +56,7 @@ for module_name in RESOURCE_MODULES:
# Import other controllers
from . import (
apikey,
dify_builder,
extension,
feature,
human_input_form,
@@ -199,6 +207,7 @@ __all__ = [
"datasets_segments",
"datasource_auth",
"datasource_content_preview",
"dify_builder",
"email_register",
"endpoint",
"extension",

View File

@@ -6,11 +6,6 @@ from models.model import App
from services.agent.roster_service import AgentRosterService
def resolve_agent_app_model(*, session: Session, tenant_id: str, agent_id: UUID) -> App:
"""Resolve a roster Agent's public Agent App."""
return AgentRosterService(session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
def resolve_agent_runtime_app_model(*, session: Session, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the App that backs an Agent runtime surface.

View File

@@ -18,7 +18,7 @@ from controllers.common.schema import (
)
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
from controllers.console.apikey import API_KEY_DELETE_ROLES, API_KEY_EDIT_ROLES, api_key_errors
from controllers.console.app.app import (
APP_LIST_QUERY_ARRAY_FIELDS,
AppListQuery,
@@ -35,6 +35,7 @@ from controllers.console.app.app import (
from controllers.console.app.app import (
UpdateAppPayload as GenericUpdateAppPayload,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
@@ -49,6 +50,7 @@ from controllers.console.wraps import (
)
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from fields.agent_fields import (
AgentConfigDraftSummaryResponse,
AgentConfigSnapshotDetailResponse,
@@ -63,10 +65,12 @@ from fields.agent_fields import (
AgentRosterListResponse,
AgentStatisticSummaryEnvelopeResponse,
)
from fields.api_key_fields import ApiKeyItem, ApiKeyList
from fields.base import ResponseModel
from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from machinery.context import RequestContext
from models import Account
from models.agent import Agent, AgentStatus
from models.agent_config_entities import AgentSoulConfig
@@ -80,7 +84,7 @@ from services.agent.observability_service import (
AgentStatisticsQueryParams,
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams
from services.app_service import AgentAppPublicationCounts, AppListParams, AppResponseView, AppService, CreateAppParams
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
@@ -270,6 +274,7 @@ class AgentAppPartial(GenericAppPartial):
class AgentAppDetailWithSite(GenericAppDetailWithSite):
permission_keys: list[str]
bound_agent_id: str | None = None
app_id: str | None = None
backing_app_id: str | None = None
hidden_app_backed: bool = False
@@ -292,6 +297,13 @@ class AgentPublishPayload(BaseModel):
class AgentPublishResponse(ResponseModel):
result: str
publication_kind: Literal["first", "update"] = Field(
description=(
"Classifies the publication by whether the Agent had a publish-visible active snapshot before publishing: "
"'first' if none existed, otherwise 'update'. This is not a historical first-publish indicator. "
"It does not depend on draft edits, Web App or API enablement, or the requesting user's access permissions."
)
)
active_config_snapshot_id: str
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
draft: AgentConfigDraftSummaryResponse | None = None
@@ -393,16 +405,15 @@ def _serialize_agent_app_detail(
roster persona fields without widening the shared /apps detail schema.
"""
app_model = AppService().get_app(app_model, session=session)
access_mode = None
if SystemFeatureService.is_webapp_auth_enabled():
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
access_mode = app_setting.access_mode
roster_service = _agent_roster_service(session)
payload = GenericAppDetailWithSite.model_validate(
app_model,
AppResponseView(app_model, session=session, account=current_user, access_mode=access_mode),
from_attributes=True,
context={"session": session},
).model_dump(mode="json")
agent = (
session.scalar(
@@ -495,9 +506,8 @@ def _serialize_agent_app_pagination(
"limit": app_pagination.per_page,
"total": app_pagination.total,
"has_more": app_pagination.has_next,
"data": app_pagination.items,
"data": [AppResponseView(app, session=session) for app in app_pagination.items],
},
context={"session": session},
).model_dump(mode="json")
payload["publication_counts"] = {
"published": publication_counts.published,
@@ -1032,56 +1042,44 @@ class AgentApiStatusApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/api-keys")
class AgentApiKeyListApi(BaseApiKeyListResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
token_prefix = "app-"
class AgentApiKeyListApi(Resource):
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentId()))
@with_current_tenant_id
@edit_permission_required
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
return dump_response(ApiKeyList, self._get_api_key_list(str(app_model.id), tenant_id, session=session))
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentId())],
)
def get(self, request_context: RequestContext, agent_id: UUID) -> dict[str, object]:
with api_key_errors():
keys = application_services().app_api_keys.list_agent_keys(request_context, str(agent_id))
return dump_response(ApiKeyList, {"data": keys})
@console_ns.response(201, "Agent service API key created", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId()))
@with_session
def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
return dump_response(
ApiKeyItem,
self._create_api_key(str(app_model.id), tenant_id, session=session),
), 201
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId())],
)
def post(self, request_context: RequestContext, agent_id: UUID) -> tuple[dict[str, object], int]:
with api_key_errors():
key = application_services().app_api_keys.create_agent_key(request_context, str(agent_id))
return dump_response(ApiKeyItem, key), 201
@console_ns.route("/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>")
class AgentApiKeyApi(BaseApiKeyResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
class AgentApiKeyApi(Resource):
@console_ns.response(204, "Agent service API key deleted")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId()))
@with_session
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId())],
)
def delete(
self,
session: Session,
tenant_id: str,
current_user: Account,
request_context: RequestContext,
agent_id: UUID,
api_key_id: UUID,
) -> tuple[str, int]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
self._delete_api_key(str(app_model.id), str(api_key_id), tenant_id, current_user, session=session)
with api_key_errors():
application_services().app_api_keys.delete_agent_key(request_context, str(agent_id), str(api_key_id))
return "", 204

View File

@@ -1,390 +1,151 @@
from collections.abc import Iterable, Mapping
from datetime import datetime
"""Console transport adapters for resource-scoped API key management."""
from collections.abc import Generator
from contextlib import contextmanager
from uuid import UUID
import flask_restx
from flask_restx import Resource
from flask_restx._http import HTTPStatus
from pydantic import field_validator
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.rbac import AgentBehindApp, DatasetId, PlainApp, RBACCheck
from controllers.common.schema import register_response_schema_models
from controllers.common.session import with_session
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account
from models.dataset import Dataset
from models.enums import ApiTokenType
from models.model import ApiToken, App
from services.api_token_service import ApiTokenCache
from services.app_service import AppService
from . import console_ns
from .wraps import (
RBACPermission,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
from controllers.console import console_ns
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission
from extensions.ext_application_services import application_services
from fields.api_key_fields import ApiKeyItem, ApiKeyList, build_masked_api_key_list
from libs.helper import dump_response
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.agent.errors import AgentAccessNotReadyError
from services.app.api_key_service import AppApiKeyNotReadyError
from services.auth.api_key_contracts import (
ApiKeyLimitExceededError,
ApiKeyNotFoundError,
ApiKeyResourceNotFoundError,
)
from services.errors.account import NoPermissionError
from services.knowledge.api_key_service import UnknownDatasetIdsError
class ApiKeyItem(ResponseModel):
id: str
type: str
token: str
# Dataset keys only: the knowledge bases this key is bound to. Empty = the key can
# access every dataset in the tenant (default). App keys are always empty.
dataset_ids: list[str] = []
last_used_at: int | None = None
created_at: int | None = None
@field_validator("last_used_at", "created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class ApiKeyList(ResponseModel):
data: list[ApiKeyItem]
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
API_KEY_DELETE_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
register_response_schema_models(console_ns, ApiKeyItem, ApiKeyList)
def mask_api_token(token: str) -> str:
"""Mask a secret token for list responses.
Reveal-once: the full secret is only returned by the create endpoint. List
endpoints expose just enough (prefix + last 4) to identify a key, never the
full value, so an existing key's secret cannot be retrieved after creation.
"""
if len(token) <= 8:
return "***"
return f"{token[:5]}...{token[-4:]}"
def build_masked_api_key_list(
api_tokens: Iterable[ApiToken],
bindings_by_token: Mapping[str, list[str]] | None = None,
) -> ApiKeyList:
"""Build an ApiKeyList from ORM tokens with their secrets masked.
``bindings_by_token`` maps an api_token id to the dataset ids it is bound to
(from DatasetApiTokenBinding); tokens absent from the map are unbound (empty =
access all). App-key lists omit it entirely.
"""
bindings_by_token = bindings_by_token or {}
items: list[ApiKeyItem] = []
for api_token in api_tokens:
item = ApiKeyItem.model_validate(api_token, from_attributes=True)
item.token = mask_api_token(item.token)
item.dataset_ids = bindings_by_token.get(str(api_token.id), [])
items.append(item)
return ApiKeyList(data=items)
def _get_resource(resource_id, tenant_id, resource_model, *, session: Session):
resource = session.execute(
select(resource_model).filter_by(id=resource_id, tenant_id=tenant_id)
).scalar_one_or_none()
if resource is None:
flask_restx.abort(HTTPStatus.NOT_FOUND, message=f"{resource_model.__name__} not found.")
return resource
class BaseApiKeyListResource(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
resource_type: ApiTokenType | None = None
resource_model: type | None = None
resource_id_field: str | None = None
token_prefix: str | None = None
max_keys = 10
@with_session(write=False)
def get(self, session: Session, resource_id: str, current_tenant_id: str) -> dict[str, object]:
return dump_response(
ApiKeyList,
self._get_api_key_list(resource_id, current_tenant_id, session=session),
)
def _get_api_key_list(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiKeyList:
assert self.resource_id_field is not None, "resource_id_field must be set"
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
keys = session.scalars(
select(ApiToken).where(
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == self.resource_type,
getattr(ApiToken, self.resource_id_field) == resource_id,
)
).all()
# App and agent keys keep their existing (unmasked) list behavior; reveal-once
# masking is scoped to dataset keys, which build their list in datasets.py.
return ApiKeyList(data=[ApiKeyItem.model_validate(key, from_attributes=True) for key in keys])
@edit_permission_required
@with_session
def post(self, session: Session, resource_id: str, current_tenant_id: str) -> tuple[dict[str, object], int]:
return dump_response(
ApiKeyItem,
self._create_api_key(resource_id, current_tenant_id, session=session),
), 201
def _create_api_key(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiToken:
assert self.resource_id_field is not None, "resource_id_field must be set"
resource = _get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
if isinstance(resource, App):
AppService.ensure_agent_app_access_ready(resource, session=session)
current_key_count: int = (
session.scalar(
select(func.count(ApiToken.id)).where(
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == self.resource_type,
getattr(ApiToken, self.resource_id_field) == resource_id,
)
)
or 0
)
if current_key_count >= self.max_keys:
flask_restx.abort(
HTTPStatus.BAD_REQUEST,
message=f"Cannot create more than {self.max_keys} API keys for this resource type.",
custom="max_keys_exceeded",
)
key = ApiToken.generate_api_key(self.token_prefix or "", 24, session=session)
assert self.resource_type is not None, "resource_type must be set"
api_token = ApiToken()
setattr(api_token, self.resource_id_field, resource_id)
api_token.tenant_id = current_tenant_id
api_token.token = key
api_token.type = self.resource_type
session.add(api_token)
session.commit()
return api_token
class BaseApiKeyResource(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
resource_type: ApiTokenType | None = None
resource_model: type | None = None
resource_id_field: str | None = None
@with_session
def delete(
self,
session: Session,
resource_id: str,
api_key_id: str,
current_tenant_id: str,
current_user: Account,
) -> tuple[str, int]:
self._delete_api_key(resource_id, api_key_id, current_tenant_id, current_user, session=session)
return "", 204
def _delete_api_key(
self,
resource_id: str,
api_key_id: str,
current_tenant_id: str,
current_user: Account,
*,
session: Session,
) -> None:
assert self.resource_id_field is not None, "resource_id_field must be set"
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
if not dify_config.RBAC_ENABLED and not current_user.is_admin_or_owner:
raise Forbidden()
key = session.scalar(
select(ApiToken)
.where(
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
getattr(ApiToken, self.resource_id_field) == resource_id,
ApiToken.type == self.resource_type,
ApiToken.id == api_key_id,
)
.limit(1)
)
if key is None:
flask_restx.abort(HTTPStatus.NOT_FOUND, message="API key not found")
# Invalidate cache before deleting from database
# Type assertion: key is guaranteed to be non-None here because abort() raises
assert key is not None # nosec - for type checker only
ApiTokenCache.delete(key.token, key.type)
session.delete(key)
session.commit()
@contextmanager
def api_key_errors() -> Generator[None]:
"""Translate application failures without changing the Console error contract."""
try:
yield
except (ApiKeyResourceNotFoundError, ApiKeyNotFoundError) as error:
flask_restx.abort(404, message=str(error))
except ApiKeyLimitExceededError as error:
flask_restx.abort(400, message=str(error), custom="max_keys_exceeded")
except UnknownDatasetIdsError as error:
flask_restx.abort(400, message=str(error))
except NoPermissionError as error:
flask_restx.abort(403, message=str(error))
except AppApiKeyNotReadyError as error:
raise AgentAccessNotReadyError from error
@console_ns.route("/apps/<uuid:resource_id>/api-keys")
class AppApiKeyListResource(BaseApiKeyListResource):
class AppApiKeyListResource(Resource):
@console_ns.doc("get_app_api_keys")
@console_ns.doc(description="Get all API keys for an app")
@console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp("resource_id")),
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp("resource_id")),
],
)
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
"""Get all API keys for an app"""
return dump_response(
ApiKeyList,
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
)
def get(self, request_context: RequestContext, resource_id: UUID) -> dict[str, object]:
with api_key_errors():
keys = application_services().app_api_keys.list_keys(request_context, str(resource_id))
return dump_response(ApiKeyList, {"data": keys})
@console_ns.doc("create_app_api_key")
@console_ns.doc(description="Create a new API key for an app")
@console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
],
)
@with_session
def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
"""Create a new API key for an app"""
return dump_response(
ApiKeyItem,
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
), 201
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
token_prefix = "app-"
def post(self, request_context: RequestContext, resource_id: UUID) -> tuple[dict[str, object], int]:
with api_key_errors():
key = application_services().app_api_keys.create_key(request_context, str(resource_id))
return dump_response(ApiKeyItem, key), 201
@console_ns.route("/apps/<uuid:resource_id>/api-keys/<uuid:api_key_id>")
class AppApiKeyResource(BaseApiKeyResource):
class AppApiKeyResource(Resource):
@console_ns.doc("delete_app_api_key")
@console_ns.doc(description="Delete an API key for an app")
@console_ns.doc(params={"resource_id": "App ID", "api_key_id": "API key ID"})
@console_ns.response(204, "API key deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
],
)
@with_session
def delete(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
resource_id: UUID,
api_key_id: UUID,
) -> tuple[str, int]:
"""Delete an API key for an app"""
self._delete_api_key(
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
str(api_key_id),
current_tenant_id,
current_user,
session=session,
)
def delete(self, request_context: RequestContext, resource_id: UUID, api_key_id: UUID) -> tuple[str, int]:
with api_key_errors():
application_services().app_api_keys.delete_key(request_context, str(resource_id), str(api_key_id))
return "", 204
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
# Dataset service-API keys are also managed at the workspace level (create with a set of
# knowledge bases, list, delete) by DatasetApiKeyApi in
# controllers/console/datasets/datasets.py, using DatasetApiTokenBinding for scoping.
# The per-dataset routes below remain for callers that key an API token to a single dataset.
@console_ns.route("/datasets/<uuid:resource_id>/api-keys")
class DatasetApiKeyListResource(BaseApiKeyListResource):
class DatasetApiKeyListResource(Resource):
@console_ns.doc("get_dataset_api_keys")
@console_ns.doc(description="Get all API keys for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
"""Get all API keys for a dataset"""
return dump_response(
ApiKeyList,
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
)
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
)
def get(self, request_context: RequestContext, resource_id: UUID) -> dict[str, object]:
with api_key_errors():
keys = application_services().dataset_api_keys.list_keys(request_context, str(resource_id))
return dump_response(ApiKeyList, build_masked_api_key_list(keys))
@console_ns.doc("create_dataset_api_key")
@console_ns.doc(description="Create a new API key for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
@with_session
def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
"""Create a new API key for a dataset"""
return dump_response(
ApiKeyItem,
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
), 201
resource_type = ApiTokenType.DATASET
resource_model = Dataset
resource_id_field = "dataset_id"
token_prefix = "ds-"
@console_account_admission(
allowed_roles=API_KEY_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
)
def post(self, request_context: RequestContext, resource_id: UUID) -> tuple[dict[str, object], int]:
with api_key_errors():
key = application_services().dataset_api_keys.create_key(request_context, str(resource_id))
return dump_response(ApiKeyItem, key), 201
@console_ns.route("/datasets/<uuid:resource_id>/api-keys/<uuid:api_key_id>")
class DatasetApiKeyResource(BaseApiKeyResource):
class DatasetApiKeyResource(Resource):
@console_ns.doc("delete_dataset_api_key")
@console_ns.doc(description="Delete an API key for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID", "api_key_id": "API key ID"})
@console_ns.response(204, "API key deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
@with_session
def delete(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
resource_id: UUID,
api_key_id: UUID,
) -> tuple[str, int]:
"""Delete an API key for a dataset"""
self._delete_api_key(
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
str(api_key_id),
current_tenant_id,
current_user,
session=session,
)
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
)
def delete(self, request_context: RequestContext, resource_id: UUID, api_key_id: UUID) -> tuple[str, int]:
with api_key_errors():
application_services().dataset_api_keys.delete_key(request_context, str(resource_id), str(api_key_id))
return "", 204
resource_type = ApiTokenType.DATASET
resource_model = Dataset
resource_id_field = "dataset_id"

View File

@@ -9,10 +9,11 @@ from controllers.common.schema import (
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, model_validate, setup_required
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import validate_request
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.login import login_required
from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService
from machinery.context import RequestContext
class AdvancedPromptTemplateQuery(BaseModel):
@@ -45,15 +46,12 @@ class AdvancedPromptTemplateList(Resource):
console_ns.models[AdvancedPromptTemplateResponse.__name__],
)
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@model_validate(AdvancedPromptTemplateQuery)
def get(self, req_data: AdvancedPromptTemplateQuery):
prompt_args: AdvancedPromptTemplateArgs = {
"app_mode": req_data.app_mode,
"model_mode": req_data.model_mode,
"model_name": req_data.model_name,
"has_context": req_data.has_context,
}
return AdvancedPromptTemplateService.get_prompt(prompt_args)
@console_account_admission()
def get(self, context: RequestContext):
req_data = validate_request(AdvancedPromptTemplateQuery)
result = application_services().advanced_prompt_templates.get_prompt(
app_mode=req_data.app_mode,
model_mode=req_data.model_mode,
has_context=req_data.has_context,
)
return AdvancedPromptTemplateResponse.model_validate(result).model_dump(mode="json", exclude_unset=True)

View File

@@ -9,23 +9,18 @@ from uuid import UUID
from flask_restx import Resource
from pydantic import Field
from sqlalchemy.orm import Session
from controllers.common.rbac import AgentId, RBACCheck
from controllers.common.schema import register_response_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.login import login_required
from services.agent.roster_service import AgentRosterService
from libs.helper import dump_response
from machinery.context import RequestContext
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import AgentAppNotFoundError
class AgentReferencingWorkflowResponse(ResponseModel):
@@ -59,17 +54,10 @@ class AgentAppReferencingWorkflowsResource(Resource):
console_ns.models[AgentReferencingWorkflowsResponse.__name__],
)
@console_ns.response(404, "Agent not found")
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()))
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
workflows = AgentRosterService(session).list_workflows_referencing_app_agent(
tenant_id=tenant_id, app_id=app_model.id
)
return AgentReferencingWorkflowsResponse(
data=[AgentReferencingWorkflowResponse.model_validate(workflow) for workflow in workflows]
).model_dump(mode="json")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID):
try:
workflows = application_services().agent_apps.access.list_referencing_workflows(context, str(agent_id))
except AgentAppNotFoundError as exc:
raise AgentNotFoundError from exc
return dump_response(AgentReferencingWorkflowsResponse, {"data": workflows})

View File

@@ -13,34 +13,24 @@ from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleResultResponse
from controllers.common.rbac import AgentId, RBACCheck
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
edit_permission_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from events.app_event import app_model_config_was_updated
from libs.login import login_required
from models import Account
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
from machinery.context import RequestContext
from models.account import TenantAccountRole
from models.agent_config_entities import (
AgentFeatureToggleConfig,
AgentSensitiveWordAvoidanceFeatureConfig,
AgentSuggestedQuestionsAfterAnswerFeatureConfig,
AgentTextToSpeechFeatureConfig,
)
from services.agent_app_feature_service import AgentAppFeatureConfigService
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import AgentAppNotFoundError
class AgentAppFeaturesPayload(BaseModel):
@@ -80,37 +70,16 @@ class AgentAppFeatureConfigResource(Resource):
@console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(400, "Invalid configuration")
@console_ns.response(404, "Agent not found")
@setup_required
@login_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_EDIT, AgentId()))
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
@model_validate(AgentAppFeaturesPayload)
def post(
self,
req_data: AgentAppFeaturesPayload,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
new_app_model_config = AgentAppFeatureConfigService.update_features(
app_model=app_model,
account=current_user,
config=req_data.model_dump(exclude_none=True),
session=session,
)
app_model_config_was_updated.send(
app_model,
app_model_config=new_app_model_config,
session=session,
)
session.commit()
@console_account_admission(
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}),
rbac_checks=(RBACCheck(RBACPermission.AGENT_EDIT, AgentId()),),
)
def post(self, context: RequestContext, agent_id: UUID):
payload = validate_request(AgentAppFeaturesPayload)
try:
application_services().agent_apps.features.update_features(
context, str(agent_id), payload.model_dump(exclude_none=True)
)
except AgentAppNotFoundError as exc:
raise AgentNotFoundError from exc
return SimpleResultResponse(result="success").model_dump(mode="json")

View File

@@ -24,25 +24,21 @@ from controllers.common.schema import (
)
from controllers.console import console_ns
from controllers.console.app.error import AppNotFoundError
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.login import login_required
from models import Account
from models.model import App, AppMode
from services.agent_app_sandbox_service import (
AgentAppSandboxService,
AgentSandboxInspectorError,
WorkflowAgentSandboxService,
from libs.helper import dump_response
from machinery.context import RequestContext
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import (
AgentAppNotFoundError,
AgentSandboxBindingNotFoundError,
AgentSandboxCaller,
AgentSandboxDownloadUnavailableError,
AgentSandboxUnavailableError,
WorkflowSandboxAppNotFoundError,
WorkflowSandboxCaller,
)
_BINDING_PATH_DESCRIPTION = (
@@ -134,8 +130,16 @@ register_response_schema_models(
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
if isinstance(exc, AgentSandboxInspectorError):
return {"code": exc.code, "message": exc.message}, exc.status_code
if isinstance(exc, AgentAppNotFoundError):
raise AgentNotFoundError from exc
if isinstance(exc, WorkflowSandboxAppNotFoundError):
raise AppNotFoundError from exc
if isinstance(exc, AgentSandboxBindingNotFoundError):
return {"code": "no_active_binding", "message": str(exc)}, 404
if isinstance(exc, AgentSandboxUnavailableError):
return {"code": "inspector_unavailable", "message": str(exc)}, 503
if isinstance(exc, AgentSandboxDownloadUnavailableError):
return {"code": "binding_file_download_unavailable", "message": str(exc)}, 502
if isinstance(exc, DifyAgentHTTPError) and backend_reported_failure(exc):
code, message = backend_error_detail(exc)
return {"code": code, "message": message}, exc.status_code
@@ -150,28 +154,16 @@ class AgentAppSandboxInfoResource(Resource):
@console_ns.doc(description="Get basic information for an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxInfoQuery)})
@console_ns.response(200, "Sandbox information returned", console_ns.models[SandboxInfoResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()))
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID):
query = query_params_from_request(AgentSandboxInfoQuery)
try:
result = service.get_info(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
result = application_services().agent_apps.sandbox.get_info(
context, AgentSandboxCaller(str(agent_id), query.caller_type, query.caller_id)
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
return dump_response(SandboxInfoResponse, result)
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files")
@@ -180,29 +172,16 @@ class AgentAppSandboxListResource(Resource):
@console_ns.doc(description="List a directory in an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()))
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID):
query = query_params_from_request(AgentSandboxListQuery)
try:
result = service.list_files(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
path=query.path,
result = application_services().agent_apps.sandbox.list_files(
context, AgentSandboxCaller(str(agent_id), query.caller_type, query.caller_id), query.path
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
return dump_response(SandboxListResponse, result)
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/read")
@@ -211,29 +190,16 @@ class AgentAppSandboxReadResource(Resource):
@console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()))
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_PREVIEW, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID):
query = query_params_from_request(AgentSandboxFileQuery)
try:
result = service.read_file(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
account_id=current_user.id,
path=query.path,
result = application_services().agent_apps.sandbox.read_file(
context, AgentSandboxCaller(str(agent_id), query.caller_type, query.caller_id), query.path
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
return dump_response(SandboxReadResponse, result)
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/download")
@@ -242,35 +208,16 @@ class AgentAppSandboxDownloadResource(Resource):
@console_ns.doc(description="Create a ToolFile from one Agent App Binding file and return its download URL")
@console_ns.expect(console_ns.models[AgentSandboxDownloadPayload.__name__])
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_EDIT, AgentId()))
@with_current_tenant_id
@with_current_user
@model_validate(AgentSandboxDownloadPayload)
def post(
self,
req_data: AgentSandboxDownloadPayload,
current_user: Account,
tenant_id: str,
agent_id: UUID,
):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_EDIT, AgentId()),))
def post(self, context: RequestContext, agent_id: UUID):
query = validate_request(AgentSandboxDownloadPayload)
try:
result = service.download_file(
tenant_id=tenant_id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=req_data.caller_type,
caller_id=req_data.caller_id,
account_id=current_user.id,
path=req_data.path,
result = application_services().agent_apps.sandbox.download_file(
context, AgentSandboxCaller(str(agent_id), query.caller_type, query.caller_id), query.path
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
return dump_response(SandboxDownloadResponse, result)
@console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files")
@@ -286,27 +233,18 @@ class WorkflowAgentSandboxListResource(Resource):
}
)
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def get(self, context: RequestContext, app_id: UUID, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentSandboxListQuery)
try:
result = WorkflowAgentSandboxService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
session=db.session(),
result = application_services().agent_apps.sandbox.list_files(
context,
WorkflowSandboxCaller(str(app_id), str(workflow_run_id), node_id, query.node_execution_id),
query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
return dump_response(SandboxListResponse, result)
@console_ns.route(
@@ -324,27 +262,18 @@ class WorkflowAgentSandboxReadResource(Resource):
}
)
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def get(self, context: RequestContext, app_id: UUID, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentSandboxFileQuery)
try:
result = WorkflowAgentSandboxService().read_file(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
session=db.session(),
result = application_services().agent_apps.sandbox.read_file(
context,
WorkflowSandboxCaller(str(app_id), str(workflow_run_id), node_id, query.node_execution_id),
query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
return dump_response(SandboxReadResponse, result)
@console_ns.route(
@@ -355,36 +284,15 @@ class WorkflowAgentSandboxDownloadResource(Resource):
@console_ns.doc(description="Create a ToolFile from one workflow Agent Binding file and return its download URL")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxDownloadPayload.__name__])
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@with_current_user
@with_current_tenant_id
@model_validate(WorkflowAgentSandboxDownloadPayload)
def post(
self,
req_data: WorkflowAgentSandboxDownloadPayload,
tenant_id: str,
current_user: Account,
app_id: UUID,
workflow_run_id: UUID,
node_id: str,
):
service = WorkflowAgentSandboxService()
resolved_app_id = service.resolve_app_id(tenant_id=tenant_id, app_id=str(app_id))
if resolved_app_id is None:
raise AppNotFoundError()
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def post(self, context: RequestContext, app_id: UUID, workflow_run_id: UUID, node_id: str):
query = validate_request(WorkflowAgentSandboxDownloadPayload)
try:
result = service.download_file(
tenant_id=tenant_id,
app_id=resolved_app_id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=req_data.node_execution_id,
account_id=current_user.id,
path=req_data.path,
result = application_services().agent_apps.sandbox.download_file(
context,
WorkflowSandboxCaller(str(app_id), str(workflow_run_id), node_id, query.node_execution_id),
query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
return dump_response(SandboxDownloadResponse, result)

View File

@@ -7,14 +7,16 @@ import io
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Literal
from functools import wraps
from typing import Any, Concatenate, Literal
from uuid import UUID
from flask import Response, request, send_file, url_for
from flask import Response, make_response, request, send_file, url_for
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from controllers.common.fields import BinaryFileResponse
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
from controllers.common.schema import (
query_params_from_model,
@@ -25,6 +27,8 @@ from controllers.common.schema import (
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import AppUnavailableError
from controllers.console.app.preview_admission import get_preview_app
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -36,7 +40,11 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.model import App, AppMode
@@ -50,6 +58,8 @@ from services.agent_config_service import (
ConfigPushPayload,
ConfigPushSkillItem,
)
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import AppPreviewRef
class AgentConfigQuery(BaseModel):
@@ -215,6 +225,7 @@ class AgentConfigDeleteResponse(ResponseModel):
register_schema_models(console_ns, AgentConfigFileUploadPayload)
register_response_schema_models(
console_ns,
BinaryFileResponse,
AgentConfigDeleteResponse,
AgentConfigDownloadResponse,
AgentConfigFileItemResponse,
@@ -239,7 +250,7 @@ register_response_schema_models(
class _ResolvedConsoleTarget:
tenant_id: str
agent_id: str
account_id: str
account_id: str | None
version_id: str
version_kind: AgentConfigVersionKind
@@ -247,6 +258,51 @@ class _ResolvedConsoleTarget:
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
class TrialAgentConfigQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID | None = Field(default=None, description="Must match the current published template snapshot")
class TrialAgentSkillFileQuery(TrialAgentConfigQuery):
path: str = Field(description="Normalized member path inside the published Skill package")
register_schema_models(console_ns, TrialAgentConfigQuery, TrialAgentSkillFileQuery)
def _with_trial_config[T, Q: TrialAgentConfigQuery, **P](
view: Callable[Concatenate[T, _ResolvedConsoleTarget, Q, P], Response | dict[str, object]],
) -> Callable[Concatenate[T, Q, AppPreviewRef, P], Response]:
@wraps(view)
def decorated(self: T, query: Q, app: AppPreviewRef, /, *args: P.args, **kwargs: P.kwargs) -> Response:
try:
state = AgentAppComposerResponse.model_validate(
application_services().app_previews.get_agent_composer(app=app)
)
snapshot = state.active_config_snapshot
if snapshot is None or (query.version_id is not None and str(query.version_id) != snapshot.id):
raise AgentConfigServiceError(
"config_version_not_found", "published template version is unavailable", status_code=404
)
target = _ResolvedConsoleTarget(
tenant_id=app.tenant_id,
agent_id=state.agent.id,
account_id=None,
version_id=snapshot.id,
version_kind=AgentConfigVersionKind.SNAPSHOT,
)
response = make_response(view(self, target, query, *args, **kwargs))
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except AgentConfigServiceError as exc:
response = make_response(_handle(exc))
response.headers["Cache-Control"] = "no-store"
return response
return decorated
def _service() -> AgentConfigService:
return AgentConfigService()
@@ -481,6 +537,7 @@ def _read_single_upload() -> tuple[bytes, str]:
def _skill_upload_response(target: _ResolvedConsoleTarget) -> tuple[dict[str, object], int]:
assert target.account_id is not None
return _upload_skill_for_target(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -517,6 +574,7 @@ def _upload_skill_for_target(
def _file_upload_response(
target: _ResolvedConsoleTarget, payload: AgentConfigFileUploadPayload
) -> tuple[dict[str, object], int]:
assert target.account_id is not None
manifest = _service().push_file_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -616,6 +674,7 @@ def _skill_file_raw_download_response(target: _ResolvedConsoleTarget, name: str,
def _skill_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -654,6 +713,7 @@ def _file_download_response(target: _ResolvedConsoleTarget, name: str) -> Respon
def _file_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -1360,5 +1420,124 @@ class AgentConfigFileApi(Resource):
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills")
class TrialAgentConfigSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigSkillListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigSkillListResponse, _skill_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files")
class TrialAgentConfigFilesApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFileListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigFileListResponse, _file_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/inspect")
class TrialAgentConfigSkillInspectApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillInspectResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_inspect_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/download")
class TrialAgentConfigSkillDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_download_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/preview")
class TrialAgentConfigSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> dict[str, object]:
return dump_response(
AgentConfigSkillFilePreviewResponse, _skill_file_preview_response(target, name, query.path)
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/download")
class TrialAgentConfigSkillFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
assert request.view_args is not None
return _skill_file_download_response(
target,
name=name,
path=query.path,
raw_endpoint="console.trial_agent_config_skill_file_content",
route_params={"app_id": str(request.view_args["app_id"])},
)
@console_ns.route(
"/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/content",
endpoint="trial_agent_config_skill_file_content",
)
class TrialAgentConfigSkillFileContentApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.doc(
produces=["application/octet-stream"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/octet-stream"]},
)
@console_ns.response(200, "Published template resource", console_ns.models[BinaryFileResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
return _skill_file_raw_download_response(target, name, query.path)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/preview")
class TrialAgentConfigFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFilePreviewResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> dict[str, object]:
return dump_response(AgentConfigFilePreviewResponse, _file_preview_response(target, name))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/download")
class TrialAgentConfigFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _file_download_response(target, name)
# pyrefly: ignore [unresolvable-dunder-all]
__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)]

File diff suppressed because it is too large Load Diff

View File

@@ -1,92 +1,37 @@
from typing import BinaryIO, Literal, cast
from uuid import uuid4
from http import HTTPStatus
from typing import BinaryIO, cast
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.rbac import PlainApp, RBACCheck, Workspace
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
cloud_edition_billing_resource_check,
edit_permission_required,
rbac_permission_required,
setup_required,
validate_request,
with_current_user,
)
from core.plugin.entities.plugin import PluginDependency
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from libs.login import current_account_with_tenant, login_required
from models.account import Account
from models.model import App, AppMode
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console.app.error import AppNotFoundError
from controllers.console.explore.error import RecommendedAppNotFoundError as RecommendedAppNotFoundHttpError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.agent.errors import InvalidRosterAgentPackageError
from services.agent.roster_package_importer import RosterAgentPackageImporter
from services.app_dsl_service import (
IMPORT_INFO_REDIS_KEY_PREFIX,
AppDslService,
Import,
PendingData,
)
from services.app_import_source import download_app_import_source, try_read_yaml
from services.app_package_service import AppPackageService
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.app.console_service import ConsoleAppNotFoundError
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
from services.errors.account import NoPermissionError
from services.system_feature_service import SystemFeatureService
from services.recommended_app_query_service import RecommendedAppNotFoundError
from .. import console_ns
from .permission_keys import get_app_permission_keys
class AppImportPayload(BaseModel):
mode: str = Field(..., description="Import mode")
yaml_content: str | None = Field(None)
yaml_url: str | None = Field(None)
name: str | None = Field(None)
description: str | None = Field(None)
icon_type: str | None = Field(None)
icon: str | None = Field(None)
icon_background: str | None = Field(None)
app_id: str | None = Field(None)
class RosterAgentPackageConflictResponse(ResponseModel):
code: str
message: str
status: Literal[409] = 409
leaked_dependencies: list[PluginDependency] = Field(default_factory=list)
class AppImportPayload(AppImportParams):
pass
register_enum_models(console_ns, ImportStatus)
register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult)
register_response_schema_models(console_ns, RosterAgentPackageConflictResponse)
def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account, str | None]:
if current_user is None:
account, tenant_id = current_account_with_tenant()
return account, str(tenant_id) if tenant_id else None
current_tenant_id = getattr(current_user, "current_tenant_id", None)
if current_tenant_id:
return current_user, str(current_tenant_id)
current_tenant = getattr(current_user, "current_tenant", None)
current_tenant_object_id = getattr(current_tenant, "id", None)
if current_tenant_object_id:
return current_user, str(current_tenant_object_id)
account, fallback_tenant_id = current_account_with_tenant()
return account, str(fallback_tenant_id) if fallback_tenant_id else None
_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
@console_ns.route("/apps/imports")
@@ -120,209 +65,68 @@ class AppImportApi(Resource):
}
},
)
@console_ns.response(200, "Import completed", console_ns.models[Import.__name__])
@console_ns.response(202, "Import pending confirmation", console_ns.models[Import.__name__])
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
@console_ns.response(403, "Insufficient import or plugin installation permissions")
@console_ns.response(
409, "Agent name conflict or missing plugins", console_ns.models[RosterAgentPackageConflictResponse.__name__]
)
@console_ns.response(413, "Roster Agent package exceeds the size limit")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
def post(self, current_user: Account):
@console_ns.response(HTTPStatus.OK, "Import completed", console_ns.models[Import.__name__])
@console_ns.response(HTTPStatus.ACCEPTED, "Import pending confirmation", console_ns.models[Import.__name__])
@console_ns.response(HTTPStatus.BAD_REQUEST, "Import failed", console_ns.models[Import.__name__])
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient import permissions")
@console_ns.response(HTTPStatus.CONFLICT, "Agent name conflict")
@console_ns.response(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Roster Agent package exceeds the size limit")
@console_account_admission(allowed_roles=_EDIT_ROLES)
def post(self, context: RequestContext):
source: BinaryIO | None = None
if request.mimetype == "multipart/form-data":
return self._import_package(current_user)
payload = validate_request(AppImportPayload)
if payload.mode == "yaml-url" and payload.yaml_url:
return self._import_url(payload, current_user)
return self._import_dsl(payload, current_user)
@rbac_permission_required(
RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()),
RBACCheck(RBACPermission.AGENT_IMPORT_EXPORT_DSL, Workspace()),
)
def _import_url(self, payload: AppImportPayload, current_user: Account):
with download_app_import_source(payload.yaml_url or "") as source:
content = try_read_yaml(source)
if content is not None:
return self._import_dsl(
payload.model_copy(update={"mode": "yaml-content", "yaml_content": content, "yaml_url": None}),
current_user,
)
return self._import_package(current_user, payload, source)
def _import_package(
self, current_user: Account, payload: AppImportPayload | None = None, source: BinaryIO | None = None
):
if source is None:
uploaded = request.files.get("file")
if uploaded is None or not uploaded.filename:
raise InvalidRosterAgentPackageError("App package file is required")
if not uploaded.filename.lower().endswith(".ifpkg"):
raise InvalidRosterAgentPackageError("App package file must use the .ifpkg extension")
source = cast(BinaryIO, uploaded.stream)
dsl = AppPackageService().read_dsl(source)
if dsl is not None:
if payload is None:
payload = AppImportPayload.model_validate({**request.form.to_dict(), "mode": "yaml-content"})
return self._import_dsl(
payload.model_copy(update={"mode": "yaml-content", "yaml_content": dsl, "yaml_url": None}),
current_user,
)
return self._import_agent_package(current_user, source, payload)
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_CREATE, Workspace()))
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_IMPORT_EXPORT_DSL, Workspace()))
def _import_agent_package(self, current_user: Account, source: BinaryIO, payload: AppImportPayload | None = None):
app_id = payload.app_id if payload is not None else request.form.get("app_id")
if app_id:
raise InvalidRosterAgentPackageError("Roster Agent package import does not support overwriting an App")
account, tenant_id = _current_user_and_tenant_id(current_user)
if tenant_id is None:
raise Forbidden("Current workspace is required")
importer = RosterAgentPackageImporter()
result = importer.import_package(source=source, tenant_id=tenant_id, account=account)
return Import(
id=str(uuid4()),
status=ImportStatus.COMPLETED_WITH_WARNINGS if result.warnings else ImportStatus.COMPLETED,
app_id=result.app_id,
app_mode=AppMode.AGENT,
warnings=result.warnings,
).model_dump(mode="json"), 200
@cloud_edition_billing_resource_check("apps")
@rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()))
def _import_dsl(self, req_data: AppImportPayload, current_user: Account | None = None):
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
# AppDslService performs internal commits for some creation paths, so use a plain
# Session here instead of nesting it inside sessionmaker(...).begin().
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
# Import app
account = current_user
try:
result = import_service.import_app(
account=account,
import_mode=req_data.mode,
yaml_content=req_data.yaml_content,
yaml_url=req_data.yaml_url,
name=req_data.name,
description=req_data.description,
icon_type=req_data.icon_type,
icon=req_data.icon,
icon_background=req_data.icon_background,
app_id=req_data.app_id,
)
except NoPermissionError as e:
raise Forbidden(str(e))
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
is_created_app = req_data.app_id is None and result.status in {
ImportStatus.COMPLETED,
ImportStatus.COMPLETED_WITH_WARNINGS,
}
if dify_config.RBAC_ENABLED and is_created_app and result.app_id:
current_user, current_tenant_id = _current_user_and_tenant_id(current_user)
if current_tenant_id:
result.permission_keys = get_app_permission_keys(
current_tenant_id,
current_user.id,
result.app_id,
)
if result.app_id and SystemFeatureService.is_webapp_auth_enabled():
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
# Return appropriate status code based on result
status = result.status
match status:
case ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
case ImportStatus.PENDING:
return result.model_dump(mode="json"), 202
case ImportStatus.COMPLETED | ImportStatus.COMPLETED_WITH_WARNINGS:
return result.model_dump(mode="json"), 200
payload = AppImportPayload.model_validate({**request.form.to_dict(), "mode": "yaml-content"})
else:
payload = validate_request(AppImportPayload)
try:
result = application_services().apps.console.import_app(context, payload, source=source)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
except RecommendedAppNotFoundError:
raise RecommendedAppNotFoundHttpError() from None
status_code = {ImportStatus.FAILED: HTTPStatus.BAD_REQUEST, ImportStatus.PENDING: HTTPStatus.ACCEPTED}.get(
result.status, HTTPStatus.OK
)
return result.model_dump(mode="json"), status_code
@console_ns.route("/apps/imports/<string:import_id>/confirm")
class AppImportConfirmApi(Resource):
@console_ns.response(200, "Import confirmed", console_ns.models[Import.__name__])
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()))
@with_current_user
def post(self, current_user: Account | None = None, import_id: str = ""):
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}"
pending_data_raw = redis_client.get(redis_key)
pending_data: PendingData | None = None
if pending_data_raw:
pending_data = PendingData.model_validate_json(pending_data_raw)
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
# Confirm import
account = current_user
try:
result = import_service.confirm_import(import_id=import_id, account=account)
except NoPermissionError as e:
raise Forbidden(str(e))
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
is_created_app = bool(
pending_data
and pending_data.app_id is None
and result.status
in {
ImportStatus.COMPLETED,
ImportStatus.COMPLETED_WITH_WARNINGS,
}
)
if dify_config.RBAC_ENABLED and is_created_app and result.app_id:
current_user, current_tenant_id = _current_user_and_tenant_id(current_user)
if current_tenant_id:
result.permission_keys = get_app_permission_keys(
current_tenant_id,
current_user.id,
result.app_id,
)
# Return appropriate status code based on result
if result.status == ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
return result.model_dump(mode="json"), 200
@console_ns.response(HTTPStatus.OK, "Import confirmed", console_ns.models[Import.__name__])
@console_ns.response(HTTPStatus.BAD_REQUEST, "Import failed", console_ns.models[Import.__name__])
@console_account_admission(
allowed_roles=_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())],
)
def post(self, context: RequestContext, import_id: str):
try:
result = application_services().apps.console.confirm_import(context, import_id)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
status_code = HTTPStatus.BAD_REQUEST if result.status == ImportStatus.FAILED else HTTPStatus.OK
return result.model_dump(mode="json"), status_code
@console_ns.route("/apps/imports/<string:app_id>/check-dependencies")
class AppImportCheckDependenciesApi(Resource):
@console_ns.response(
200,
HTTPStatus.OK,
"Dependencies checked",
console_ns.models[CheckDependenciesResult.__name__],
)
@setup_required
@login_required
@get_app_model
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
def get(self, app_model: App):
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
result = import_service.check_dependencies(app_model=app_model)
return result.model_dump(mode="json"), 200
@console_account_admission(
allowed_roles=_EDIT_ROLES,
rbac_checks=[RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())],
)
def get(self, context: RequestContext, app_id: str):
try:
result = application_services().apps.console.check_import_dependencies(context, app_id)
except ConsoleAppNotFoundError as exc:
raise AppNotFoundError() from exc
return result.model_dump(mode="json"), HTTPStatus.OK

View File

@@ -455,14 +455,15 @@ class AgentTextToSpeechVoicesApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@model_validate(TextToSpeechVoiceQuery)
def get(
self,
req_data: TextToSpeechVoiceQuery,
session: Session,
current_tenant_id: str,
current_user: Account,
agent_id: UUID,
):
query = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
enforce_rbac_checks(
tenant_id=current_tenant_id,
account_id=current_user.id,
@@ -474,4 +475,4 @@ class AgentTextToSpeechVoicesApi(Resource):
tenant_id=current_tenant_id,
agent_id=agent_id,
)
return _get_text_to_speech_voices(tenant_id=app_model.tenant_id, language=query.language)
return _get_text_to_speech_voices(tenant_id=app_model.tenant_id, language=req_data.language)

View File

@@ -132,6 +132,7 @@ class ConversationVariablesApi(Resource):
"created_at": row.created_at,
"updated_at": row.updated_at,
**row.to_variable().model_dump(),
"id": row.id,
}
)
for row in rows

View File

@@ -89,6 +89,12 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException):
code = 400
class ProviderNotSupportTextToSpeechError(BaseHTTPException):
error_code = "provider_not_support_text_to_speech"
description = "Provider does not support text to speech."
code = 400
class SpeechToTextDisabledError(BaseHTTPException):
error_code = "speech_to_text_disabled"
description = "Speech to text is disabled."
@@ -125,6 +131,12 @@ class UnsupportedTracingProviderError(BaseHTTPException):
code = 400
class TracingProviderUnavailableError(BaseHTTPException):
error_code = "tracing_provider_unavailable"
description = "The tracing provider is not available in this deployment."
code = 400
class InvalidTracingConfigError(BaseHTTPException):
error_code = "invalid_tracing_config"
description = "The tracing configuration is invalid."

View File

@@ -146,6 +146,22 @@ class WorkflowInstructionSuggestionsPayload(BaseModel):
count: int = Field(default=4, ge=1, le=6, description="Number of suggestions to return (1-6)")
class WorkflowInstructionImprovePayload(BaseModel):
"""Payload for the workflow-generator instruction-improvement endpoint.
Backs the composer's "Add missing details" action. It runs before an app
exists and before the user picks a model, so the rewrite comes from the
tenant's default model. The underlying generator never raises -- the
original text with ``changed: false`` is a valid 200 (soft-fail).
"""
instruction: str = Field(..., description="The build instruction to expand")
mode: Literal["workflow", "advanced-chat"] = Field(
default="workflow", description="Target app mode the instruction is written for"
)
language: str | None = Field(default=None, description="Optional language to write the rewrite in")
class WorkflowGenerateErrorResponse(ResponseModel):
code: WorkflowGenerateErrorCode
detail: str
@@ -199,6 +215,17 @@ class WorkflowInstructionSuggestionsResponse(ResponseModel):
suggestions: list[str]
class WorkflowInstructionImproveResponse(ResponseModel):
"""``instruction`` is always usable: the rewrite, or the caller's own text.
``changed`` is what distinguishes the two, so a client can tell an
improvement from a soft failure without diffing the strings itself.
"""
instruction: str
changed: bool
class GeneratorResponse(RootModel[Any]):
root: Any
@@ -213,6 +240,7 @@ register_schema_models(
InstructionTemplatePayload,
WorkflowGeneratePayload,
WorkflowInstructionSuggestionsPayload,
WorkflowInstructionImprovePayload,
ModelConfig,
)
register_response_schema_models(
@@ -224,6 +252,7 @@ register_response_schema_models(
WorkflowGenerateResultEventResponse,
WorkflowGenerateStreamEventResponse,
WorkflowInstructionSuggestionsResponse,
WorkflowInstructionImproveResponse,
)
@@ -401,6 +430,7 @@ class InstructionGenerateApi(Resource):
instruction=req_data.instruction,
model_config=req_data.model_config_data,
ideal_output=req_data.ideal_output,
session=session,
)
if req_data.node_id != "" and req_data.current != "": # For workflow node
return LLMGenerator.instruction_modify_workflow(
@@ -450,6 +480,32 @@ class InstructionGenerationTemplateApi(Resource):
raise ValueError(f"Invalid type: {req_data.type}")
def _instruction_text_guard(instruction: str) -> tuple[dict, int] | None:
"""Reject an empty / oversized instruction with the shared error codes.
Separate from ``_workflow_instruction_guard`` because the improvement
endpoint carries no ``ideal_output`` field, and that guard's wording names
both. Rejecting here keeps a whitespace-only or pasted-document input from
spending an LLM round-trip.
"""
if not instruction.strip():
return {
"error": "Instruction is required",
"errors": [{"code": WorkflowGenerateErrorCode.EMPTY_INSTRUCTION, "detail": "Instruction is required"}],
}, 400
if len(instruction) > _MAX_INSTRUCTION_LENGTH:
return {
"error": "Instruction is too long",
"errors": [
{
"code": WorkflowGenerateErrorCode.INSTRUCTION_TOO_LONG,
"detail": f"Instruction must be at most {_MAX_INSTRUCTION_LENGTH} characters",
}
],
}, 400
return None
def _workflow_instruction_guard(args: WorkflowGeneratePayload) -> tuple[dict, int] | None:
"""Shared boundary guard for the workflow-generate endpoints.
@@ -567,6 +623,49 @@ class WorkflowInstructionSuggestionsApi(Resource):
return dump_response(WorkflowInstructionSuggestionsResponse, {"suggestions": suggestions})
@console_ns.route("/workflow-generate/improve")
class WorkflowInstructionImproveApi(Resource):
"""Expand a build instruction with the details it leaves unstated.
Backs the composer's "Add missing details" action. Runs before an app exists
and before a model is selected, so it is tenant-scoped and uses the tenant's
default model. The underlying generator never raises: when it cannot improve
the text this returns the caller's own instruction with ``changed: false``
rather than an error, so the action can never strand what the user typed.
"""
@console_ns.doc("improve_workflow_instruction")
@console_ns.doc(description="Expand a workflow-generator instruction with the details it leaves unstated")
@console_ns.expect(console_ns.models[WorkflowInstructionImprovePayload.__name__])
@console_ns.response(
200,
"Instruction returned successfully",
console_ns.models[WorkflowInstructionImproveResponse.__name__],
)
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@model_validate(WorkflowInstructionImprovePayload)
def post(self, req_data: WorkflowInstructionImprovePayload, current_tenant_id: str):
guard = _instruction_text_guard(req_data.instruction)
if guard is not None:
return guard
original = req_data.instruction.strip()
improved = LLMGenerator.improve_workflow_instruction(
tenant_id=current_tenant_id,
instruction=original,
mode=req_data.mode,
language=req_data.language,
)
return dump_response(
WorkflowInstructionImproveResponse,
{"instruction": improved or original, "changed": bool(improved)},
)
@console_ns.route("/workflow-generate/stream")
class WorkflowGenerateStreamApi(Resource):
"""Plan-first streaming variant of ``/workflow-generate`` (Server-Sent Events).

View File

@@ -1,32 +1,31 @@
import json
from datetime import datetime
from typing import Any
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Conflict, NotFound
from controllers.common.rbac import PlainApp, RBACCheck
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 (
RBACPermission,
account_initialization_required,
edit_permission_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
)
from extensions.ext_database import db
from controllers.console.app.error import AppNotFoundError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models.enums import AppMCPServerStatus
from models.model import App, AppMCPServer
from services.app_ref_service import AppRefService
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.app.mcp_server_service import (
AppMCPServerAlreadyExistsError,
AppMCPServerAppNotFoundError,
AppMCPServerNotFoundError,
AppMCPServerStatus,
)
_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
class MCPServerCreatePayload(BaseModel):
@@ -38,13 +37,13 @@ class MCPServerCreatePayload(BaseModel):
class MCPServerUpdatePayload(BaseModel):
id: str = Field(..., description="Server ID")
id: UUID = Field(..., description="Server ID")
description: str | None = Field(default=None, description="Server description")
parameters: dict[str, Any] = Field(
...,
description="Server parameters configuration",
)
status: str | None = Field(default=None, description="Server status")
status: AppMCPServerStatus | None = Field(default=None, description="Server status")
class AppMCPServerResponse(ResponseModel):
@@ -84,13 +83,12 @@ class AppMCPServerController(Resource):
@console_ns.response(
200, "MCP server configuration retrieved successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@login_required
@account_initialization_required
@setup_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@get_app_model
def get(self, app_model: App):
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
@console_account_admission(rbac_checks=[RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())])
def get(self, request_context: RequestContext, app_id: UUID):
try:
server = application_services().app_mcp_servers.get(request_context, str(app_id))
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
if server is None:
return {}
return dump_response(AppMCPServerResponse, server)
@@ -103,30 +101,18 @@ class AppMCPServerController(Resource):
201, "MCP server configuration created successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@console_ns.response(403, "Insufficient permissions")
@account_initialization_required
@login_required
@setup_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@with_current_tenant_id
@get_app_model
@model_validate(MCPServerCreatePayload)
def post(self, req_data: MCPServerCreatePayload, current_tenant_id: str, app_model: App):
description = req_data.description
if not description:
description = app_model.description or ""
server = AppMCPServer(
name=app_model.name,
description=description,
parameters=json.dumps(req_data.parameters, ensure_ascii=False),
status=AppMCPServerStatus.ACTIVE,
app_id=app_model.id,
tenant_id=current_tenant_id,
server_code=AppMCPServer.generate_server_code(16, session=db.session()),
)
db.session.add(server)
db.session.commit()
@console_ns.response(409, "MCP server already exists for this app")
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def post(self, request_context: RequestContext, app_id: UUID):
payload = validate_request(MCPServerCreatePayload)
try:
server = application_services().app_mcp_servers.create(
request_context, str(app_id), description=payload.description, parameters=payload.parameters
)
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerAlreadyExistsError as error:
raise Conflict(str(error)) from error
return dump_response(AppMCPServerResponse, server), 201
@console_ns.doc("update_app_mcp_server")
@@ -138,43 +124,22 @@ class AppMCPServerController(Resource):
)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@login_required
@setup_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@get_app_model
@model_validate(MCPServerUpdatePayload)
def put(self, req_data: MCPServerUpdatePayload, app_model: App):
app_ref = AppRefService.create_app_ref(app_model)
server_ref = AppRefService.create_mcp_server_ref(app_ref, req_data.id)
server = db.session.scalar(
select(AppMCPServer)
.where(
AppMCPServer.id == server_ref.server_id,
AppMCPServer.tenant_id == server_ref.app.tenant_id,
AppMCPServer.app_id == server_ref.app.app_id,
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def put(self, request_context: RequestContext, app_id: UUID):
payload = validate_request(MCPServerUpdatePayload)
try:
server = application_services().app_mcp_servers.update(
request_context,
str(app_id),
server_id=str(payload.id),
description=payload.description,
parameters=payload.parameters,
status=payload.status,
)
.limit(1)
)
if not server:
raise NotFound()
description = req_data.description
if description is None or not description:
server.description = app_model.description or ""
else:
server.description = description
server.name = app_model.name
server.parameters = json.dumps(req_data.parameters, ensure_ascii=False)
if req_data.status:
try:
server.status = AppMCPServerStatus(req_data.status)
except ValueError:
raise ValueError("Invalid status")
db.session.commit()
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerNotFoundError as error:
raise NotFound from error
return dump_response(AppMCPServerResponse, server)
@@ -186,21 +151,12 @@ class AppMCPServerRefreshController(Resource):
@console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@with_current_tenant_id
@get_app_model
def post(self, current_tenant_id: str, app_model: App):
server = db.session.scalar(
select(AppMCPServer)
.where(AppMCPServer.app_id == app_model.id, AppMCPServer.tenant_id == current_tenant_id)
.limit(1)
)
if not server:
raise NotFound()
server.server_code = AppMCPServer.generate_server_code(16, session=db.session())
db.session.commit()
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def post(self, request_context: RequestContext, app_id: UUID):
try:
server = application_services().app_mcp_servers.refresh(request_context, str(app_id))
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerNotFoundError as error:
raise NotFound from error
return dump_response(AppMCPServerResponse, server)

View File

@@ -1,4 +1,5 @@
import logging
from http import HTTPStatus
from typing import Literal
from uuid import UUID
@@ -6,7 +7,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.fields import SimpleResultResponse, TextFileResponse
@@ -16,6 +17,8 @@ from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import (
AppNotFoundError,
AppUnavailableError,
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
@@ -23,6 +26,7 @@ from controllers.console.app.error import (
)
from controllers.console.app.wraps import get_app_model
from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
@@ -33,9 +37,9 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.app.entities.app_invoke_entities import InvokeFrom
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.conversation_fields import (
@@ -46,13 +50,19 @@ from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import dump_response, uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import login_required
from machinery.context import RequestContext
from models.account import Account
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import AgentAppNotFoundError
from services.app.console_service import ConsoleAppNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.message_service import MessageService, attach_message_extra_contents
from services.message_service import attach_message_extra_contents
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
logger = logging.getLogger(__name__)
@@ -296,21 +306,25 @@ class MessageSuggestedQuestionApi(Resource):
@console_ns.doc(description="Get suggested questions for a message")
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
@console_ns.response(
200,
HTTPStatus.OK,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Message or conversation not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@with_session(write=False)
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def get(self, session: Session, current_user: Account, app_model: App, message_id: UUID):
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
@console_ns.response(HTTPStatus.NOT_FOUND, "App, message, or conversation not found")
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def get(self, context: RequestContext, app_id: UUID, message_id: UUID) -> dict[str, object]:
try:
app = application_services().apps.console.get_reference(context, str(app_id))
except ConsoleAppNotFoundError as exc:
raise AppNotFoundError() from exc
if app.mode not in (AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT):
raise AppNotFoundError("App mode does not support suggested questions")
return _get_message_suggested_questions(
session=session, current_user=current_user, app_model=app_model, message_id=message_id
context=context, app_id=app.id, app_mode=app.mode, message_id=message_id
)
@@ -320,26 +334,23 @@ class AgentMessageSuggestedQuestionApi(Resource):
@console_ns.doc(description="Get suggested questions for an Agent App message")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(
200,
HTTPStatus.OK,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Agent, message, or conversation not found")
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()))
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_runtime_app_model(
session=session,
tenant_id=current_tenant_id,
agent_id=agent_id,
)
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
@console_ns.response(HTTPStatus.NOT_FOUND, "Agent, message, or conversation not found")
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID, message_id: UUID) -> dict[str, object]:
try:
app_id = application_services().agent_apps.access.resolve_existing_runtime_app_id(context, str(agent_id))
except AgentAppNotFoundError as exc:
raise AgentNotFoundError() from exc
return _get_message_suggested_questions(
session=session, current_user=current_user, app_model=app_model, message_id=message_id
context=context, app_id=app_id, app_mode=AppMode.AGENT, message_id=message_id
)
@@ -552,17 +563,22 @@ def _update_message_feedback(
return SimpleResultResponse(result="success").model_dump(mode="json")
def _get_message_suggested_questions(*, session: Session, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
def _get_message_suggested_questions(
*, context: RequestContext, app_id: str, app_mode: str, message_id: UUID
) -> dict[str, object]:
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model,
message_id=message_id_str,
user=current_user,
invoke_from=InvokeFrom.DEBUGGER,
session=session,
questions = application_services().message_suggested_questions.get_suggested_questions(
app_id=app_id,
app_owner_tenant_id=context.active_workspace_id,
expected_app_mode=app_mode,
actor=SuggestedQuestionsAccount(account_id=context.account_id, invoke_from="debugger"),
message_id=str(message_id),
)
except AppDefinitionUnavailableError as exc:
raise AppUnavailableError() from exc
except SuggestedQuestionsActorNotFoundError as exc:
raise Unauthorized("Account no longer exists") from exc
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
@@ -577,6 +593,8 @@ def _get_message_suggested_questions(*, session: Session, current_user: Account,
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except HTTPException:
raise
except Exception:
logger.exception("internal server error.")
raise InternalServerError()

View File

@@ -1,9 +1,6 @@
import json
from typing import Any, cast
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleResultResponse
@@ -11,6 +8,7 @@ from controllers.common.rbac import PlainApp, RBACCheck
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.app.model_config_payload import AppModelConfigPayload
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -30,47 +28,7 @@ from libs.login import login_required
from models.model import App, AppMode, AppModelConfig
from services.app_model_config_service import AppModelConfigService
class ModelConfigRequest(BaseModel):
provider: str | None = Field(default=None, description="Model provider")
model: str | None = Field(default=None, description="Model name")
configs: dict[str, Any] | None = Field(
default=None,
description="Model configuration parameters",
)
opening_statement: str | None = Field(default=None, description="Opening statement")
suggested_questions: list[str] | None = Field(default=None, description="Suggested questions")
more_like_this: dict[str, Any] | None = Field(
default=None,
description="More like this configuration",
)
speech_to_text: dict[str, Any] | None = Field(
default=None,
description="Speech to text configuration",
)
text_to_speech: dict[str, Any] | None = Field(
default=None,
description="Text to speech configuration",
)
retrieval_model: dict[str, Any] | None = Field(
default=None,
description="Retrieval model configuration",
)
tools: list[dict[str, Any]] | None = Field(
default=None,
description="Available tools",
)
dataset_configs: dict[str, Any] | None = Field(
default=None,
description="Dataset configurations",
)
agent_mode: dict[str, Any] | None = Field(
default=None,
description="Agent mode configuration",
)
register_schema_models(console_ns, ModelConfigRequest)
register_schema_models(console_ns, AppModelConfigPayload)
register_response_schema_models(console_ns, SimpleResultResponse)
@@ -79,7 +37,7 @@ class ModelConfigResource(Resource):
@console_ns.doc("update_app_model_config")
@console_ns.doc(description="Update application model configuration")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ModelConfigRequest.__name__])
@console_ns.expect(console_ns.models[AppModelConfigPayload.__name__])
@console_ns.response(
200,
"Model configuration updated successfully",
@@ -98,10 +56,11 @@ class ModelConfigResource(Resource):
@get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION])
def post(self, session: Session, current_tenant_id: str, current_user_id: str, app_model: App):
"""Modify the app model config and dataset joins in one request transaction."""
# validate config
payload = AppModelConfigPayload.model_validate(console_ns.payload or {})
model_configuration = AppModelConfigService.validate_configuration(
tenant_id=current_tenant_id,
config=cast(dict, request.json),
# Preserve omission so feature validators retain their existing defaults.
config=payload.model_dump(exclude_unset=True),
app_mode=AppMode.value_of(app_model.mode),
session=session,
)

View File

@@ -0,0 +1,384 @@
from typing import Literal, NotRequired
from pydantic import BaseModel, ConfigDict, Field, with_config
from typing_extensions import TypedDict
from core.entities.agent_entities import PlanningStrategy
from core.rag.entities.metadata_entities import SupportedComparisonOperator
from core.rag.rerank.rerank_type import RerankMode
from core.tools.entities.tool_entities import ToolProviderType
from graphon.file.enums import FileTransferMethod, FileType
from graphon.model_runtime.entities.llm_entities import LLMMode
# The recursive schema also keeps generated clients from turning JSON values into `any`.
type AppConfigJsonValue = str | int | float | bool | None | list[AppConfigJsonValue] | dict[str, AppConfigJsonValue]
# Mypy does not yet support PEP 728 extra_items; pyrefly and Pydantic check these declarations.
# https://github.com/python/mypy/issues/18176
class AppModelSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
provider: str
name: str
completion_params: dict[str, AppConfigJsonValue]
mode: NotRequired[str | None]
# These config managers persist nested extensions alongside their known fields.
# Keep those JSON values when an existing configuration is published again.
class AppPromptMessagePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
text: str
role: str
class AppChatPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
prompt: NotRequired[list[AppPromptMessagePayload]]
class AppConversationRolesPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
user_prefix: str
assistant_prefix: str
class AppCompletionPromptTextPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
text: str
class AppCompletionPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
prompt: NotRequired[AppCompletionPromptTextPayload]
conversation_histories_role: NotRequired[AppConversationRolesPayload]
@with_config(ConfigDict(extra="allow"))
class AppInputFieldBasePayload(TypedDict):
label: str
variable: str
description: NotRequired[str]
required: NotRequired[bool | None]
default: NotRequired[AppConfigJsonValue]
max_length: NotRequired[int | None]
hide: NotRequired[bool]
type: NotRequired[str]
enabled: NotRequired[bool]
config: NotRequired[dict[str, AppConfigJsonValue]]
icon: NotRequired[str | None]
icon_background: NotRequired[str | None]
json_schema: NotRequired[str | dict[str, AppConfigJsonValue] | None]
class AppInputFieldPayload(AppInputFieldBasePayload, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
options: NotRequired[list[str]]
class AppSelectFieldPayload(AppInputFieldBasePayload, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
options: NotRequired[list[str] | None]
class AppTextInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid", serialize_by_alias=True)
text_input: AppInputFieldPayload = Field(alias="text-input")
class AppSelectInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
select: AppSelectFieldPayload
class AppParagraphInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
paragraph: AppInputFieldPayload
class AppNumberInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
number: AppInputFieldPayload
class AppCheckboxInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
checkbox: AppInputFieldPayload
class AppExternalDataInputPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
external_data_tool: AppInputFieldPayload
type AppUserInputFormPayload = (
AppTextInputPayload
| AppSelectInputPayload
| AppParagraphInputPayload
| AppNumberInputPayload
| AppCheckboxInputPayload
| AppExternalDataInputPayload
)
class AppFeaturePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
class AppSuggestedQuestionsModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
provider: str
name: str
mode: NotRequired[LLMMode | Literal[""]]
completion_params: NotRequired[dict[str, AppConfigJsonValue]]
class AppSuggestedQuestionsPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
prompt: NotRequired[str]
model: NotRequired[AppSuggestedQuestionsModelPayload]
class AppTextToSpeechPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
voice: NotRequired[str]
language: NotRequired[str]
autoPlay: NotRequired[Literal["enabled", "disabled"]]
class AppModerationContentPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
preset_response: NotRequired[str | None]
class AppModerationConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
inputs_config: NotRequired[AppModerationContentPayload | None]
outputs_config: NotRequired[AppModerationContentPayload | None]
keywords: NotRequired[str | None]
api_based_extension_id: NotRequired[str | None]
class AppModerationPayload(BaseModel):
enabled: bool | None = None
type: str | None = None
config: AppModerationConfigPayload | None = None
class AppExternalDataToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
type: NotRequired[str]
label: NotRequired[str]
variable: NotRequired[str]
config: NotRequired[dict[str, AppConfigJsonValue]]
icon: NotRequired[str]
icon_background: NotRequired[str]
class AppDatasetSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
id: NotRequired[str]
enabled: NotRequired[bool]
class AppLegacyDatasetSelectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
id: NotRequired[str]
enabled: NotRequired[bool | None]
class AppDatasetToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
dataset: AppDatasetSelectionPayload
class AppEmptyDatasetCollectionPayload(TypedDict, closed=True):
pass
class AppDatasetCollectionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
strategy: NotRequired[str]
datasets: list[AppDatasetToolPayload]
class AppRerankingModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
reranking_provider_name: NotRequired[str]
reranking_model_name: NotRequired[str]
class AppVectorWeightPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
vector_weight: float
embedding_provider_name: str
embedding_model_name: str
class AppKeywordWeightPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
keyword_weight: float
class AppRetrievalWeightsPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
weight_type: NotRequired[Literal["semantic_first", "keyword_first", "customized"]]
vector_setting: AppVectorWeightPayload
keyword_setting: AppKeywordWeightPayload
class AppMetadataModelPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
provider: NotRequired[str]
name: NotRequired[str]
mode: NotRequired[LLMMode | Literal[""]]
completion_params: NotRequired[dict[str, AppConfigJsonValue]]
class AppMetadataConditionPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
id: NotRequired[str]
name: str
metadata_id: NotRequired[str]
comparison_operator: SupportedComparisonOperator
value: NotRequired[str | int | float | list[str] | None]
class AppMetadataFilteringPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
logical_operator: NotRequired[Literal["and", "or"] | None]
conditions: NotRequired[list[AppMetadataConditionPayload] | None]
class AppDatasetConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
retrieval_model: NotRequired[Literal["single", "multiple"]]
datasets: NotRequired[AppDatasetCollectionPayload | AppEmptyDatasetCollectionPayload | None]
top_k: NotRequired[int]
score_threshold: NotRequired[float | None]
score_threshold_enabled: NotRequired[bool]
reranking_model: NotRequired[AppRerankingModelPayload | None]
weights: NotRequired[AppRetrievalWeightsPayload | None]
reranking_enabled: NotRequired[bool]
reranking_enable: NotRequired[bool]
reranking_mode: NotRequired[RerankMode]
metadata_filtering_mode: NotRequired[Literal["disabled", "automatic", "manual"]]
metadata_model_config: NotRequired[AppMetadataModelPayload | None]
metadata_filtering_conditions: NotRequired[AppMetadataFilteringPayload | None]
class AppAgentPromptPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
first_prompt: NotRequired[str]
next_iteration: NotRequired[str]
class AppProviderAgentToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
provider_type: ToolProviderType
provider_id: str
provider_name: NotRequired[str]
tool_name: str
tool_label: NotRequired[str]
tool_parameters: dict[str, AppConfigJsonValue]
plugin_unique_identifier: NotRequired[str | None]
credential_id: NotRequired[str | None]
isDeleted: NotRequired[bool]
notAuthor: NotRequired[bool]
class AppLegacyDatasetToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
dataset: AppLegacyDatasetSelectionPayload
class AppLegacyGoogleSearchToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
google_search: AppFeaturePayload
class AppLegacyWebReaderToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
web_reader: AppFeaturePayload
class AppLegacyWikipediaToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
wikipedia: AppFeaturePayload
class AppLegacyCurrentDatetimeToolPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
current_datetime: AppFeaturePayload
class AppLegacySensitiveWordConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: bool
words: list[str]
canned_response: str
AppLegacySensitiveWordToolPayload = TypedDict( # type: ignore[misc]
"AppLegacySensitiveWordToolPayload",
{"sensitive-word-avoidance": AppLegacySensitiveWordConfigPayload},
extra_items=AppConfigJsonValue,
)
type AppAgentToolPayload = (
AppProviderAgentToolPayload
| AppLegacyDatasetToolPayload
| AppLegacyGoogleSearchToolPayload
| AppLegacyWebReaderToolPayload
| AppLegacyWikipediaToolPayload
| AppLegacyCurrentDatetimeToolPayload
| AppLegacySensitiveWordToolPayload
)
class AppAgentModePayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
strategy: NotRequired[PlanningStrategy | Literal["cot", "function-calling", ""] | None]
max_iteration: NotRequired[int]
tools: NotRequired[list[AppAgentToolPayload] | None]
prompt: NotRequired[AppAgentPromptPayload | str | None]
class AppImageUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool]
number_limits: NotRequired[int]
detail: NotRequired[Literal["low", "high"] | None]
transfer_methods: NotRequired[list[FileTransferMethod]]
class AppImageConfigPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
number_limits: NotRequired[int]
detail: NotRequired[Literal["low", "high"] | None]
transfer_methods: NotRequired[list[FileTransferMethod]]
class AppFileTypeUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool | None]
number_limits: NotRequired[int | None]
transfer_methods: NotRequired[list[FileTransferMethod] | None]
class AppFilePreviewPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
mode: NotRequired[str | None]
file_type_list: NotRequired[list[str] | None]
class AppFileUploadPayload(TypedDict, extra_items=AppConfigJsonValue): # type: ignore[call-arg]
enabled: NotRequired[bool]
image: NotRequired[AppImageUploadPayload]
image_config: NotRequired[AppImageConfigPayload | None]
document: NotRequired[AppFileTypeUploadPayload | None]
audio: NotRequired[AppFileTypeUploadPayload | None]
video: NotRequired[AppFileTypeUploadPayload | None]
custom: NotRequired[AppFileTypeUploadPayload | None]
preview_config: NotRequired[AppFilePreviewPayload | None]
allowed_file_types: NotRequired[list[FileType]]
allowed_file_extensions: NotRequired[list[str]]
allowed_file_upload_methods: NotRequired[list[FileTransferMethod]]
number_limits: NotRequired[int]
class AppModelConfigPayload(BaseModel):
"""Write transport; app-mode validators own defaults and feature-specific rules."""
model: AppModelSelectionPayload
prompt_type: Literal["simple", "advanced", ""] | None = None
pre_prompt: str | None = None
chat_prompt_config: AppChatPromptPayload | None = None
completion_prompt_config: AppCompletionPromptPayload | None = None
user_input_form: list[AppUserInputFormPayload] | None = None
dataset_query_variable: str | None = None
dataset_configs: AppDatasetConfigPayload | None = None
agent_mode: AppAgentModePayload | None = None
external_data_tools: list[AppExternalDataToolPayload] | None = None
file_upload: AppFileUploadPayload | None = None
opening_statement: str | None = None
suggested_questions: list[str] | None = None
suggested_questions_after_answer: AppSuggestedQuestionsPayload | None = None
more_like_this: AppFeaturePayload | None = None
speech_to_text: AppFeaturePayload | None = None
text_to_speech: AppTextToSpeechPayload | None = None
retriever_resource: AppFeaturePayload | None = None
sensitive_word_avoidance: AppModerationPayload | None = None

View File

@@ -14,6 +14,7 @@ from controllers.console.app.error import (
TracingConfigNotFoundError,
TracingConfigProcessingError,
TracingConfigVerificationFailedError,
TracingProviderUnavailableError,
UnsupportedTracingProviderError,
)
from controllers.console.flask_admission import console_account_admission
@@ -33,6 +34,7 @@ from services.app_tracing_config_service import (
AppTracingConfigInvalidProviderError,
AppTracingConfigNotFoundError,
AppTracingConfigProcessingError,
AppTracingConfigProviderUnavailableError,
AppTracingConfigRecord,
AppTracingConfigVerificationFailedError,
)
@@ -125,6 +127,8 @@ class TraceAppConfigApi(Resource):
raise AppNotFoundError() from error
except AppTracingConfigInvalidProviderError as error:
raise UnsupportedTracingProviderError() from error
except AppTracingConfigProviderUnavailableError as error:
raise TracingProviderUnavailableError() from error
except AppTracingConfigProcessingError as error:
raise TracingConfigProcessingError() from error
except ValueError as error:
@@ -177,6 +181,8 @@ class TraceAppConfigApi(Resource):
raise TracingConfigAlreadyExistsError() from error
except AppTracingConfigInvalidProviderError as error:
raise UnsupportedTracingProviderError() from error
except AppTracingConfigProviderUnavailableError as error:
raise TracingProviderUnavailableError() from error
except AppTracingConfigInvalidConfigurationError as error:
raise InvalidTracingConfigError() from error
except AppTracingConfigVerificationFailedError as error:
@@ -226,6 +232,8 @@ class TraceAppConfigApi(Resource):
raise TracingConfigNotFoundError() from error
except AppTracingConfigInvalidProviderError as error:
raise UnsupportedTracingProviderError() from error
except AppTracingConfigProviderUnavailableError as error:
raise TracingProviderUnavailableError() from error
except AppTracingConfigInvalidConfigurationError as error:
raise InvalidTracingConfigError() from error
except AppTracingConfigVerificationFailedError as error:

View File

@@ -0,0 +1,26 @@
"""Public catalog preview admission, separate from authenticated trial execution."""
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from controllers.console.app.error import AppNotFoundError
from extensions.ext_application_services import application_services
from services.app_preview_query_service import AppPreviewRef, AppPreviewUnavailableError
def get_preview_app[T, **P, R](
view: Callable[Concatenate[T, AppPreviewRef, P], R],
) -> Callable[Concatenate[T, P], R]:
@wraps(view)
def decorated(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
app_id = kwargs.pop("app_id", None)
if app_id is None:
raise RuntimeError("The app preview admission route must provide app_id")
try:
app = application_services().app_previews.get_access(app_id=str(app_id))
except AppPreviewUnavailableError as error:
raise AppNotFoundError() from error
return view(self, app, *args, **kwargs)
return decorated

View File

@@ -2,7 +2,7 @@ import json
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import Any, NotRequired, Self, TypedDict
from typing import Any, Literal, NotRequired, Self, TypedDict
from flask import abort, request
from flask_restx import Resource
@@ -21,7 +21,6 @@ from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotF
import services
from configs import dify_config
from controllers.common.app_access import resolve_app_access_filter
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse
@@ -53,7 +52,6 @@ from controllers.console.wraps import (
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
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.entities.app_invoke_entities import InvokeFrom
from core.app.file_access import DatabaseFileAccessController
from core.db.session_factory import session_factory
@@ -97,11 +95,16 @@ from models.model import AppMode
from models.workflow import Workflow
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.app.access import resolve_app_access_filter
from services.app_generate_service import AppGenerateService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.workflow_ref_service import WorkflowRefService
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
from services.workflow_variable_reference_validator import (
format_variable_reference_errors,
validate_variable_references,
)
logger = logging.getLogger(__name__)
@@ -217,7 +220,7 @@ class WorkflowSuggestedQuestionsAfterAnswerPayload(WorkflowFeatureTogglePayload)
class WorkflowTextToSpeechPayload(WorkflowFeatureTogglePayload):
language: str | None = None
voice: str | None = None
autoPlay: str | None = None
autoPlay: Literal["enabled", "disabled"] | None = None
class WorkflowSensitiveWordAvoidancePayload(WorkflowFeatureTogglePayload):
@@ -402,6 +405,10 @@ class WorkflowOnlineUsersResponse(ResponseModel):
class WorkflowPublishResponse(ResponseModel):
result: str
created_at: int
warning: str | None = Field(
default=None,
description="Advisory warning for variable references that can read a skipped branch. Publish still succeeds.",
)
class SyncDraftWorkflowResponse(ResponseModel):
@@ -1267,6 +1274,21 @@ class DraftWorkflowNodeRunApi(Resource):
).model_dump(mode="json")
def _advisory_variable_reference_warning(graph_text: str | None) -> str | None:
"""Return a non-blocking publish warning. A checker failure must not fail publish."""
if not graph_text:
return None
try:
graph = json.loads(graph_text)
if not isinstance(graph, dict):
return None
issues = validate_variable_references(graph)
return format_variable_reference_errors(issues) if issues else None
except Exception:
logger.warning("Skipped advisory variable reference check", exc_info=True)
return None
@console_ns.route("/apps/<uuid:app_id>/workflows/publish")
class PublishedWorkflowApi(Resource):
@console_ns.doc("get_published_workflow")
@@ -1331,11 +1353,16 @@ class PublishedWorkflowApi(Resource):
app_model_in_session.updated_at = naive_utc_now()
workflow_created_at = TimestampField().format(workflow.created_at)
graph_text = workflow.graph
return {
warning = _advisory_variable_reference_warning(graph_text)
payload: dict[str, object] = {
"result": "success",
"created_at": workflow_created_at,
}
if warning:
payload["warning"] = warning
return payload
@console_ns.route("/apps/<uuid:app_id>/workflows/default-workflow-block-configs")
@@ -1737,15 +1764,12 @@ class DraftWorkflowTriggerRunApi(Resource):
event = poller.poll()
if not event:
return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN})
workflow_args = dict(event.workflow_args)
workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True
return helper.compact_generate_response(
AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=workflow_args,
args=event.workflow_args,
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
root_node_id=node_id,
@@ -1894,14 +1918,11 @@ class DraftWorkflowTriggerRunAllApi(Resource):
return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN})
try:
workflow_args = dict(trigger_debug_event.workflow_args)
workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=workflow_args,
args=trigger_debug_event.workflow_args,
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
root_node_id=trigger_debug_event.node_id,

View File

@@ -24,9 +24,7 @@ from models.model import AppMode
class WorkflowAppLogQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword for filtering logs")
status: WorkflowExecutionStatus | None = Field(
default=None, description="Execution status filter (succeeded, failed, stopped, partial-succeeded)"
)
status: WorkflowExecutionStatus | None = Field(default=None, description="Execution status filter")
created_at__before: datetime | None = Field(default=None, description="Filter logs created before this timestamp")
created_at__after: datetime | None = Field(default=None, description="Filter logs created after this timestamp")
created_by_end_user_session_id: str | None = Field(default=None, description="Filter by end user session ID")

View File

@@ -1,9 +1,8 @@
"""Controller decorators for console app resources.
`get_app_model` still supports legacy handlers backed by Flask-SQLAlchemy's
scoped session. Preview handlers compose `get_previewable_app_model` under
`controllers.common.session.with_session`; preview admission finishes before
the request Session loads the accepted App.
scoped session. Public catalog previews use `app.preview_admission` and
detached application queries.
"""
from collections.abc import Callable
@@ -15,52 +14,40 @@ from sqlalchemy.orm import Session, scoped_session
from controllers.common.session import with_session
from controllers.console.app.error import AppNotFoundError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode
from models.agent import AgentScope
from services.app_service import AppService
from models.enums import AppStatus
__all__ = [
"get_app_model",
"get_previewable_app_model",
"with_session",
]
def _is_hidden_backing_app(app_model: App, session: Session | scoped_session) -> bool:
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
return binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY
def _load_app_model(session: Session, app_id: str) -> App | None:
"""Load the tenant-scoped app row with the request session owned by `with_session`."""
def _load_app_model(session: Session | scoped_session, app_id: str) -> App | None:
"""Load the tenant-scoped Console app row through the given session."""
_, current_tenant_id = current_account_with_tenant()
app_model = session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
select(App)
.where(
App.id == app_id,
App.tenant_id == current_tenant_id,
App.status == AppStatus.NORMAL,
)
.limit(1)
)
if app_model is not None and _is_hidden_backing_app(app_model, session):
return None
if app_model is not None:
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY:
return None
return app_model
def _load_app_model_from_scoped_session(app_id: str) -> App | None:
"""Load the app row for legacy handlers that have not adopted request session injection yet."""
_, current_tenant_id = current_account_with_tenant()
app_model = db.session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if app_model is not None and _is_hidden_backing_app(app_model, db.session):
return None
return app_model
def _load_previewable_app_model(session: Session, app_id: str) -> App | None:
"""Load a normal App after preview admission completes outside the request Session."""
if not application_services().recommended_app_queries.is_previewable(app_id):
return None
return AppService.get_normal_app_by_id(app_id, session)
return _load_app_model(db.session, app_id)
def _get_injected_session(args: tuple[object, ...]) -> Session | None:
@@ -148,75 +135,3 @@ def get_app_model[**P, R](
return decorator
else:
return decorator(view)
@overload
def get_previewable_app_model[**P, R](
view: Callable[P, R],
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[P, R]: ...
@overload
def get_previewable_app_model[**P, R](
view: None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def get_previewable_app_model[**P, R](
view: Callable[P, R] | None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
"""Inject an App authorized for read-only template preview.
Preview reads accept either an explicit TrialApp registration or membership
in the recommended catalog. This does not grant trial execution, which is
separately protected by TrialAppResource's feature, registration, and quota
checks.
"""
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
if not kwargs.get("app_id"):
raise ValueError("missing app_id in path parameters")
app_id = kwargs.get("app_id")
app_id = str(app_id)
del kwargs["app_id"]
session = _get_injected_session(args)
if session is None:
raise RuntimeError("get_previewable_app_model requires @with_session")
app_model = _load_previewable_app_model(session, app_id)
if not app_model:
raise AppNotFoundError()
app_mode = AppMode.value_of(app_model.mode)
if mode is not None:
if isinstance(mode, list):
modes = mode
else:
modes = [mode]
if app_mode not in modes:
mode_values = {m.value for m in modes}
raise AppNotFoundError(f"App mode is not in the supported list: {mode_values}")
kwargs["app_model"] = app_model
return view_func(*args, **kwargs)
return decorated_view
if view is None:
return decorator
else:
return decorator(view)

View File

@@ -115,14 +115,14 @@ class ActivateApi(Resource):
console_ns.models[ActivationResponse.__name__],
)
@console_ns.response(400, "Already activated or invalid token")
def post(self):
@model_validate(ActivatePayload)
def post(self, args: ActivatePayload):
"""Accept an invitation without letting an existing session act for another account.
Token-only activation remains available for legacy clients. When the request already
carries a console session, that session must belong to the account encoded in the
invitation before the token is consumed or tenant membership is changed.
"""
args = ActivatePayload.model_validate(console_ns.payload or {})
authenticated_account_id: str | None = None
if extract_access_token(request) is not None:
authenticated_account_id = current_account_with_tenant().account.id

View File

@@ -1,12 +1,13 @@
from dataclasses import dataclass
from datetime import datetime
from http import HTTPStatus
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
@@ -17,15 +18,15 @@ from controllers.common.rbac import DatasetId, RBACCheck, Workspace, enforce_rba
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.apikey import ApiKeyItem, ApiKeyList, build_masked_api_key_list
from controllers.console.apikey import API_KEY_DELETE_ROLES, api_key_errors
from controllers.console.app.error import ProviderNotInitializeError
from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
cloud_edition_billing_rate_limit_check,
enterprise_license_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
@@ -41,6 +42,8 @@ from core.rag.extractor.entity.datasource_type import DatasourceType
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo
from core.rag.index_processor.constant.index_type import IndexTechniqueType
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from extensions.ext_application_services import application_services
from fields.api_key_fields import ApiKeyItem, ApiKeyList, build_masked_api_key_list
from fields.base import ResponseModel
from fields.dataset_fields import (
DatasetDetailResponse,
@@ -52,13 +55,10 @@ from libs.helper import build_icon_url, dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import clamp_pagination
from libs.url_utils import normalize_api_base_url
from models import Account, ApiToken, App, Dataset, Document, UploadFile
from machinery.context import RequestContext
from models import Account, Dataset, Document, UploadFile
from models.dataset import DatasetPermission, DatasetPermissionEnum, DatasetQuery
from models.enums import ApiTokenType
from models.provider_ids import ModelProviderID
from services import dataset_api_key_service
from services.api_token_service import ApiTokenCache
from services.app_service import AppService
from services.dataset_ref_service import DatasetRefService
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
from services.enterprise import rbac_service as enterprise_rbac_service
@@ -76,11 +76,10 @@ def _get_accessible_dataset(dataset_id: UUID, tenant_id: str, current_user: Acco
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), tenant_id, session=session)
if dataset is None:
raise NotFound("Dataset not found.")
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
return dataset
@@ -269,21 +268,6 @@ class RelatedAppResponse(ResponseModel):
return self
@dataclass(frozen=True)
class _RelatedAppResponseSource:
"""Expose the compatible app mode through the request's database session."""
app: App
session: Session
@property
def mode_compatible_with_agent(self) -> str:
return self.app.mode_compatible_with_agent_with_session(session=self.session)
def __getattr__(self, name: str) -> Any:
return getattr(self.app, name) # guard-ignore: no-new-getattr -- delegates model fields
class RelatedAppListResponse(ResponseModel):
data: list[RelatedAppResponse]
total: int
@@ -452,7 +436,9 @@ class DatasetListApi(Resource):
@console_ns.doc("get_datasets")
@console_ns.doc(description="Get list of datasets")
@console_ns.doc(params=query_params_from_model(ConsoleDatasetListQuery))
@console_ns.response(200, "Datasets retrieved successfully", console_ns.models[DatasetListResponse.__name__])
@console_ns.response(
HTTPStatus.OK, "Datasets retrieved successfully", console_ns.models[DatasetListResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@@ -527,6 +513,7 @@ class DatasetListApi(Resource):
query.keyword,
query.tag_ids,
query.include_all,
tags=application_services().tags,
accessible_dataset_ids=accessible_dataset_ids,
include_own_datasets=include_own_datasets,
)
@@ -591,7 +578,7 @@ class DatasetListApi(Resource):
"total": total,
"page": effective_page,
}
return dump_response(DatasetListResponse, response), 200
return dump_response(DatasetListResponse, response), HTTPStatus.OK
@console_ns.doc("create_dataset")
@console_ns.doc(description="Create a new dataset")
@@ -671,7 +658,6 @@ class DatasetApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()))
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -681,11 +667,10 @@ class DatasetApi(Resource):
if dataset is None:
raise NotFound("Dataset not found.")
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
current_tenant_id,
current_user.id,
@@ -807,7 +792,7 @@ class DatasetApi(Resource):
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.response(204, "Dataset deleted successfully")
@with_current_user
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_DELETE, DatasetId()))
@with_session
def delete(self, session: Session, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
@@ -1031,7 +1016,7 @@ class DatasetRelatedAppListApi(Resource):
@console_ns.doc(description="Get applications related to dataset")
@console_ns.doc(params={"dataset_id": "Dataset ID"})
@console_ns.response(
200,
HTTPStatus.OK,
"Related apps retrieved successfully",
console_ns.models[RelatedAppListResponse.__name__],
)
@@ -1054,13 +1039,11 @@ class DatasetRelatedAppListApi(Resource):
app_dataset_joins = DatasetService.get_related_apps(dataset.id, session)
related_apps = []
for app_dataset_join in app_dataset_joins:
app_model = AppService.get_app_by_id(app_dataset_join.app_id, session)
if app_model:
related_apps.append(_RelatedAppResponseSource(app=app_model, session=session))
related_apps = application_services().apps.queries.related_apps(
dataset.tenant_id, [join.app_id for join in app_dataset_joins]
)
return dump_response(RelatedAppListResponse, {"data": related_apps, "total": len(related_apps)}), 200
return dump_response(RelatedAppListResponse, {"data": related_apps, "total": len(related_apps)}), HTTPStatus.OK
@console_ns.route("/datasets/<uuid:dataset_id>/indexing-status")
@@ -1114,121 +1097,49 @@ class DatasetIndexingStatusApi(Resource):
@console_ns.route("/datasets/api-keys")
class DatasetApiKeyApi(Resource):
max_keys = 10
token_prefix = "dataset-"
resource_type = ApiTokenType.DATASET
@console_ns.doc("get_dataset_api_keys")
@console_ns.doc(description="Get dataset API keys")
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace()))
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str):
keys = session.scalars(
select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id)
).all()
token_ids = [str(key.id) for key in keys]
bindings_by_token = dataset_api_key_service.list_bindings_by_token(session, token_ids)
return dump_response(ApiKeyList, build_masked_api_key_list(keys, bindings_by_token))
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace())],
)
def get(self, request_context: RequestContext):
with api_key_errors():
keys = application_services().dataset_api_keys.list_workspace_keys(request_context)
return dump_response(ApiKeyList, build_masked_api_key_list(keys))
@console_ns.expect(console_ns.models[DatasetApiKeyCreatePayload.__name__])
@console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace()))
@account_initialization_required
@with_current_tenant_id
@with_session
def post(self, session: Session, current_tenant_id: str):
# Optional list of knowledge bases to scope the key to. Absent/empty => the key
# can access every dataset in the tenant (default). Duplicates are de-duplicated.
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace())],
)
def post(self, request_context: RequestContext):
# Preserve the optional scope and validation response of the workspace route.
payload = request.get_json(silent=True) or {}
raw_dataset_ids = payload.get("dataset_ids") or []
if not isinstance(raw_dataset_ids, list) or any(not isinstance(item, str) for item in raw_dataset_ids):
console_ns.abort(400, message="dataset_ids must be a list of strings.")
dataset_ids = list(dict.fromkeys(raw_dataset_ids))
if dataset_ids:
unknown = dataset_api_key_service.find_unknown_dataset_ids(session, dataset_ids, current_tenant_id)
if unknown:
console_ns.abort(400, message=f"Unknown knowledge base id(s): {', '.join(unknown)}")
current_key_count = (
session.scalar(
select(func.count(ApiToken.id)).where(
ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id
)
)
or 0
)
if current_key_count >= self.max_keys:
console_ns.abort(
400,
message=f"Cannot create more than {self.max_keys} API keys for this resource type.",
custom="max_keys_exceeded",
)
key = ApiToken.generate_api_key(self.token_prefix, 24, session=session)
api_token = ApiToken()
api_token.tenant_id = current_tenant_id
api_token.token = key
api_token.type = self.resource_type
session.add(api_token)
session.flush()
dataset_api_key_service.bind_datasets(session, api_token.id, dataset_ids)
session.flush()
# Reveal-once: the create response carries the full secret and its bound scope.
item = ApiKeyItem.model_validate(api_token, from_attributes=True)
item.dataset_ids = dataset_ids
return dump_response(ApiKeyItem, item), 200
with api_key_errors():
key = application_services().dataset_api_keys.create_workspace_key(request_context, tuple(raw_dataset_ids))
return dump_response(ApiKeyItem, key), 200
@console_ns.route("/datasets/api-keys/<uuid:api_key_id>")
class DatasetApiDeleteApi(Resource):
resource_type = ApiTokenType.DATASET
@console_ns.doc("delete_dataset_api_key")
@console_ns.doc(description="Delete dataset API key")
@console_ns.doc(params={"api_key_id": "API key ID"})
@console_ns.response(204, "API key deleted successfully")
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace()))
@account_initialization_required
@with_current_tenant_id
@with_session
def delete(self, session: Session, current_tenant_id: str, api_key_id: UUID):
api_key_id_str = str(api_key_id)
key = session.scalar(
select(ApiToken)
.where(
ApiToken.tenant_id == current_tenant_id,
ApiToken.type == self.resource_type,
ApiToken.id == api_key_id_str,
)
.limit(1)
)
if key is None:
console_ns.abort(404, message="API key not found")
# Invalidate cache before deleting from database
# Type assertion: key is guaranteed to be non-None here because abort() raises
assert key is not None # nosec - for type checker only
ApiTokenCache.delete(key.token, key.type)
session.delete(key)
@console_account_admission(
allowed_roles=API_KEY_DELETE_ROLES,
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace())],
)
def delete(self, request_context: RequestContext, api_key_id: UUID):
with api_key_errors():
application_services().dataset_api_keys.delete_workspace_key(request_context, str(api_key_id))
return "", 204

View File

@@ -302,11 +302,10 @@ class DocumentResource(Resource):
if not dataset:
raise NotFound("Dataset not found.")
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
document_ref = DatasetRefService.create_document_ref_from_id(dataset_ref, document_id)
@@ -544,7 +543,7 @@ class DatasetDocumentListApi(Resource):
@console_ns.expect(console_ns.models[KnowledgeConfig.__name__])
@console_ns.response(200, "Documents created successfully", console_ns.models[DatasetAndDocumentResponse.__name__])
@with_current_user
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_USE, DatasetId()))
@with_session
def post(self, session: Session, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
@@ -595,7 +594,7 @@ class DatasetDocumentListApi(Resource):
@console_ns.response(204, "Documents deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_DELETE_FILE, DatasetId()))
@with_session
def delete(
self,
@@ -612,11 +611,10 @@ class DatasetDocumentListApi(Resource):
if not current_user.is_dataset_editor:
raise Forbidden()
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
check_knowledge_rate_limit()
try:
@@ -704,6 +702,12 @@ class DatasetInitApi(Resource):
dataset.id,
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=False),
)
enterprise_rbac_service.try_sync_creator_access_policy_member_bindings(
current_tenant_id,
current_user.id,
enterprise_rbac_service.RBACResourceType.DATASET,
dataset.id,
)
return dump_response(
DatasetAndDocumentResponse,
@@ -1125,7 +1129,7 @@ class DocumentApi(DocumentResource):
@console_ns.response(204, "Document deleted successfully")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_DELETE_FILE, DatasetId()))
@with_session
def delete(
self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID
@@ -1185,7 +1189,7 @@ class DocumentBatchDownloadZipApi(DocumentResource):
@console_ns.expect(console_ns.models[DocumentBatchDownloadZipPayload.__name__])
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_DOCUMENT_DOWNLOAD, DatasetId()))
@with_session(write=False)
def post(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
"""Stream a ZIP archive containing the requested uploaded documents."""
@@ -1499,11 +1503,10 @@ class DocumentRetryApi(DocumentResource):
if not current_user.is_dataset_editor:
raise Forbidden()
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
documents = DocumentService.get_documents_by_ids(
DatasetRefService.create_dataset_ref(dataset), req_data.document_ids, session

View File

@@ -6,7 +6,6 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.controller_schemas import MetadataUpdatePayload
from controllers.common.rbac import DatasetId, RBACCheck
from controllers.common.schema import register_response_schema_models, register_schema_models
@@ -99,11 +98,10 @@ class DatasetMetadataCreateApi(Resource):
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
if dataset is None:
raise NotFound("Dataset not found.")
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
metadata = MetadataService.get_dataset_metadatas(dataset, session)
return dump_response(DatasetMetadataListResponse, metadata), 200

View File

@@ -97,6 +97,12 @@ class CreateRagPipelineDatasetApi(Resource):
dataset_id,
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=False),
)
enterprise_rbac_service.try_sync_creator_access_policy_member_bindings(
current_tenant_id,
current_user.id,
enterprise_rbac_service.RBACResourceType.DATASET,
dataset_id,
)
return dump_response(RagPipelineImportResponse, import_info), 201

View File

@@ -45,6 +45,7 @@ from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpErr
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity
from extensions.ext_database import db
from factories import variable_factory
from fields.base import ResponseModel
@@ -57,7 +58,6 @@ from fields.workflow_run_fields import (
workflow_run_pagination_response_source,
workflow_run_response_source,
)
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs import helper
from libs.helper import TimestampField, UUIDStrOrEmpty, dump_response
from libs.login import login_required
@@ -151,6 +151,14 @@ class RagPipelineOpaqueResponse(RootModel[Any]):
root: Any
class RagPipelineDatasourceProviderResponse(PluginDatasourceProviderEntity, ResponseModel):
pass
class RagPipelineDatasourceListResponse(RootModel[list[RagPipelineDatasourceProviderResponse]]):
pass
class RagPipelineStepParametersResponse(ResponseModel):
variables: Any
@@ -175,6 +183,8 @@ register_response_schema_models(
console_ns,
DefaultBlockConfigResponse,
DefaultBlockConfigsResponse,
RagPipelineDatasourceListResponse,
RagPipelineDatasourceProviderResponse,
RagPipelineOpaqueResponse,
RagPipelineStepParametersResponse,
RagPipelineWorkflowPublishResponse,
@@ -1000,13 +1010,14 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource):
@console_ns.route("/rag/pipelines/datasource-plugins")
class DatasourceListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[RagPipelineDatasourceListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
return jsonable_encoder(RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id))
providers = RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id)
return RagPipelineDatasourceListResponse.model_validate(providers, from_attributes=True).model_dump(mode="json")
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/draft/nodes/<string:node_id>/last-run")

View File

@@ -0,0 +1,427 @@
"""Console HTTP surface for Dify Builder sessions and command streams.
Flask-RESTX resources under /console/api/dify-builder/*. Auth + CSRF come from
the console guard stack (CSRF is enforced inside login_required for every
method); RBAC/authz is enforced here at the
boundary — the Celery advance task trusts the Actor. The route LOGIC lives in
undecorated module functions (_create/_get_session/_conversation/_stream/
_action/_message) so they are unit testable without the Flask request stack;
the Resource methods are thin auth-wrappers.
Decorator injection contract (verified against controllers/console/wraps.py
and the multi-decorator precedent in controllers/console/app/workflow_comment.py,
e.g. ``def post(self, current_tenant_id, current_user, app_model, comment_id)``):
``@with_current_user`` and ``@with_current_tenant_id`` do NOT inject their
values as keyword arguments. Each calls its wrapped view as
``view(self, injected_value, *args, **kwargs)`` — i.e. it PREPENDS the value
as a positional argument immediately after ``self``. Stacking both means the
decorator closer to the function (here, ``@with_current_tenant_id``, directly
above ``@dify_builder_required``) contributes the positional argument
closest to ``self``; the one above it (``@with_current_user``) contributes
the next one. URL path parameters (e.g. ``session_id``) are untouched by
either decorator and keep arriving as keyword arguments from Flask-RESTX's
routing. ``dify_builder_required`` sits directly below
``@with_current_tenant_id`` in the stack, so it receives ``current_tenant_id``
positionally (as the argument right after ``self``), not as a keyword-only
argument.
"""
import dataclasses
import functools
import logging
from collections.abc import Callable, Iterator
from flask import Response, request
from flask_restx import Resource
from pydantic import ValidationError
from controllers.common.schema import (
query_params_from_model,
register_response_schema_models,
register_schema_models,
typed_event_stream_response,
)
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from core.dify_builder.models import Action, Actor
from libs.helper import dump_response
from libs.login import login_required
from services.dify_builder import progress_bus
from services.dify_builder.service import resolve_submitted_action
from services.dify_builder.wiring import (
build_service,
dify_builder_error_response,
session_view_to_dict,
stream_advance_frames,
)
from services.feature_service import FeatureService
from . import console_ns
from .dify_builder_fields import (
DifyBuilderConversationListQuery,
DifyBuilderConversationPageResponse,
DifyBuilderCreateBuildSessionPayload,
DifyBuilderCreateChecklistFixSessionPayload,
DifyBuilderCreateEditSessionPayload,
DifyBuilderCreateFixSessionPayload,
DifyBuilderCreateSessionPayload,
DifyBuilderErrorResponse,
DifyBuilderSessionViewResponse,
DifyBuilderStreamEventResponse,
DifyBuilderSubmitActionPayload,
DifyBuilderSubmitMessagePayload,
)
logger = logging.getLogger(__name__)
register_schema_models(
console_ns,
DifyBuilderCreateSessionPayload,
DifyBuilderConversationListQuery,
DifyBuilderSubmitActionPayload,
DifyBuilderSubmitMessagePayload,
)
register_response_schema_models(
console_ns,
DifyBuilderConversationPageResponse,
DifyBuilderSessionViewResponse,
DifyBuilderStreamEventResponse,
DifyBuilderErrorResponse,
)
def dify_builder_required(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(self, current_tenant_id: str, *args, **kwargs):
if not FeatureService.get_features(current_tenant_id).dify_builder_enabled:
return {"code": "feature_unavailable"}, 403
return func(self, current_tenant_id, *args, **kwargs)
return wrapper
def _actor(current_user, current_tenant_id: str) -> Actor:
return Actor(account_id=current_user.id, tenant_id=current_tenant_id)
_SSE_HEADERS = {"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
def _stream_response(make_generator: Callable[[], Iterator[str]]) -> Response | tuple[dict, int]:
"""Call make_generator() (which runs the service's eager validate+subscribe+dispatch
and returns an SSE generator); map pre-flight errors to an HTTP tuple; else return
the stream. Unknown exceptions propagate to Flask's error handler."""
try:
generator = make_generator()
except Exception as exc: # re-raised below if not a known dify_builder error
mapped = dify_builder_error_response(exc)
if mapped is None:
raise
return mapped
return Response(generator, mimetype="text/event-stream", headers=_SSE_HEADERS)
def _create(body, actor: Actor) -> Response | tuple[dict, int]:
try:
payload = DifyBuilderCreateSessionPayload.model_validate(body).root
except ValidationError:
return {"code": "bad_request"}, 400
model_config = dataclasses.asdict(payload.model_config_data) if payload.model_config_data else None
if isinstance(payload, DifyBuilderCreateBuildSessionPayload):
return _stream_response(
lambda: build_service().create_build_session_stream(
app_id=payload.app_id,
actor=actor,
goal_text=payload.goal_text,
model_config=model_config,
derive_app_name=payload.derive_app_name,
)
)
if isinstance(payload, DifyBuilderCreateEditSessionPayload):
return _stream_response(
lambda: build_service().create_edit_session_stream(
app_id=payload.app_id,
actor=actor,
model_config=model_config,
goal_text=payload.goal_text,
)
)
if isinstance(payload, DifyBuilderCreateChecklistFixSessionPayload):
return _stream_response(
lambda: build_service().create_fix_session_stream(
app_id=payload.app_id,
actor=actor,
failed_run_id=payload.failed_run_id,
checklist_errors=[error.model_dump() for error in payload.checklist_errors],
model_config=model_config,
)
)
if isinstance(payload, DifyBuilderCreateFixSessionPayload):
return _stream_response(
lambda: build_service().create_fix_session_stream(
app_id=payload.app_id,
actor=actor,
failed_run_id=payload.failed_run_id,
model_config=model_config,
)
)
raise AssertionError(f"Unsupported Dify Builder create payload: {type(payload)}")
def _action(session_id: str, body, actor: Actor) -> Response | tuple[dict, int]:
try:
payload = DifyBuilderSubmitActionPayload.model_validate(body)
except ValidationError:
return {"code": "bad_request"}, 400
action = Action(
kind=resolve_submitted_action(payload.action_id, payload.payload),
payload=payload.payload,
base_version=payload.base_version,
base_app_revision=payload.base_app_revision,
)
return _stream_response(lambda: build_service().submit_action_stream(session_id, actor, action))
def _message(session_id: str, body, actor: Actor) -> Response | tuple[dict, int]:
try:
payload = DifyBuilderSubmitMessagePayload.model_validate(body)
except ValidationError:
return {"code": "bad_request"}, 400
return _stream_response(
lambda: build_service().submit_message_stream(
session_id,
actor,
payload.text,
payload.base_version,
payload.client_turn_id,
)
)
def _get_session(session_id: str, actor: Actor) -> dict | tuple[dict, int]:
try:
view = build_service().get_session_view(session_id, actor)
except Exception as exc:
mapped = dify_builder_error_response(exc)
if mapped is None:
raise
return mapped
return dump_response(DifyBuilderSessionViewResponse, session_view_to_dict(view))
def _conversation(session_id: str, query_args, actor: Actor) -> dict | tuple[dict, int]:
try:
query = DifyBuilderConversationListQuery.model_validate(query_args)
except ValidationError:
return {"code": "bad_request"}, 400
try:
page = build_service().get_conversation_page(
session_id,
actor,
limit=query.limit,
before_seq=query.before_seq,
after_seq=query.after_seq,
)
except Exception as exc:
mapped = dify_builder_error_response(exc)
if mapped is None:
raise
return mapped
return dump_response(DifyBuilderConversationPageResponse, page)
def _stream(session_id: str, actor: Actor) -> Response | tuple[dict, int]:
service = build_service()
try:
service.authorize_session(session_id, actor)
except Exception as exc:
mapped = dify_builder_error_response(exc)
if mapped is None:
raise
return mapped
# Subscribe before reading the bounded state. This closes the race where
# an advance could settle between the read and subscription. Conversation
# rows are recovered independently through the JSON pagination endpoint.
subscription = progress_bus.subscribe(session_id)
try:
view = service.get_session_view(session_id, actor)
except Exception as exc: # re-raised below if not a known dify_builder error
subscription.close()
mapped = dify_builder_error_response(exc)
if mapped is None:
raise
return mapped
return Response(
stream_advance_frames(
session_view_to_dict(view),
subscription,
expect_advance=view.run_status == "processing" and not view.interrupted,
emit_command_finished_when_settled=True,
emit_command_started=False,
command_id=view.last_command_id,
),
mimetype="text/event-stream",
headers=_SSE_HEADERS,
)
def _ping(body, actor: Actor) -> tuple[dict, int]:
model_config = body.get("model_config") if isinstance(body, dict) else None
try:
from services.dify_builder.agent.ping import ping_model
return ping_model(actor.tenant_id, model_config), 200
except Exception as exc: # a health check returns ok:false rather than 500
logger.warning("dify_builder agent ping failed: %s", exc)
return {"ok": False, "error": str(exc)}, 200
@console_ns.route("/dify-builder/sessions")
class DifyBuilderSessionsApi(Resource):
@typed_event_stream_response(console_ns, DifyBuilderStreamEventResponse)
@console_ns.expect(console_ns.models[DifyBuilderCreateSessionPayload.__name__])
@console_ns.response(
200,
"Dify Builder event stream",
console_ns.models[DifyBuilderStreamEventResponse.__name__],
)
@console_ns.response(400, "Invalid request", console_ns.models[DifyBuilderErrorResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
@dify_builder_required
def post(self, current_tenant_id, current_user):
return _create(request.get_json(silent=True), _actor(current_user, current_tenant_id))
@console_ns.route("/dify-builder/sessions/<string:session_id>")
class DifyBuilderSessionApi(Resource):
@console_ns.response(
200,
"Dify Builder session state",
console_ns.models[DifyBuilderSessionViewResponse.__name__],
)
@console_ns.response(404, "Session not found", console_ns.models[DifyBuilderErrorResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
@dify_builder_required
def get(self, current_tenant_id, current_user, session_id):
return _get_session(session_id, _actor(current_user, current_tenant_id))
@console_ns.route("/dify-builder/sessions/<string:session_id>/conversation")
class DifyBuilderConversationApi(Resource):
@console_ns.doc(params=query_params_from_model(DifyBuilderConversationListQuery))
@console_ns.response(
200,
"Dify Builder conversation page",
console_ns.models[DifyBuilderConversationPageResponse.__name__],
)
@console_ns.response(400, "Invalid request", console_ns.models[DifyBuilderErrorResponse.__name__])
@console_ns.response(404, "Session not found", console_ns.models[DifyBuilderErrorResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
@dify_builder_required
def get(self, current_tenant_id, current_user, session_id):
return _conversation(
session_id,
request.args.to_dict(flat=True),
_actor(current_user, current_tenant_id),
)
@console_ns.route("/dify-builder/sessions/<string:session_id>/stream")
class DifyBuilderSessionStreamApi(Resource):
@typed_event_stream_response(console_ns, DifyBuilderStreamEventResponse)
@console_ns.response(
200,
"Dify Builder reconnect event stream",
console_ns.models[DifyBuilderStreamEventResponse.__name__],
)
@console_ns.response(404, "Session not found", console_ns.models[DifyBuilderErrorResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
@dify_builder_required
def get(self, current_tenant_id, current_user, session_id):
return _stream(session_id, _actor(current_user, current_tenant_id))
@console_ns.route("/dify-builder/sessions/<string:session_id>/actions")
class DifyBuilderActionsApi(Resource):
@typed_event_stream_response(console_ns, DifyBuilderStreamEventResponse)
@console_ns.expect(console_ns.models[DifyBuilderSubmitActionPayload.__name__])
@console_ns.response(
200,
"Dify Builder event stream",
console_ns.models[DifyBuilderStreamEventResponse.__name__],
)
@console_ns.response(400, "Invalid request", console_ns.models[DifyBuilderErrorResponse.__name__])
@console_ns.response(404, "Session not found", console_ns.models[DifyBuilderErrorResponse.__name__])
@console_ns.response(409, "Session conflict", console_ns.models[DifyBuilderErrorResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
@dify_builder_required
def post(self, current_tenant_id, current_user, session_id):
return _action(session_id, request.get_json(silent=True), _actor(current_user, current_tenant_id))
@console_ns.route("/dify-builder/sessions/<string:session_id>/messages")
class DifyBuilderMessagesApi(Resource):
@typed_event_stream_response(console_ns, DifyBuilderStreamEventResponse)
@console_ns.expect(console_ns.models[DifyBuilderSubmitMessagePayload.__name__])
@console_ns.response(
200,
"Dify Builder event stream",
console_ns.models[DifyBuilderStreamEventResponse.__name__],
)
@console_ns.response(400, "Invalid request", console_ns.models[DifyBuilderErrorResponse.__name__])
@console_ns.response(404, "Session not found", console_ns.models[DifyBuilderErrorResponse.__name__])
@console_ns.response(409, "Session conflict", console_ns.models[DifyBuilderErrorResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
@dify_builder_required
def post(self, current_tenant_id, current_user, session_id):
return _message(session_id, request.get_json(silent=True), _actor(current_user, current_tenant_id))
@console_ns.route("/dify-builder/agent/ping")
class DifyBuilderAgentPingApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@dify_builder_required
def post(self, current_tenant_id, current_user):
return _ping(request.get_json(silent=True), _actor(current_user, current_tenant_id))

View File

@@ -0,0 +1,410 @@
"""Pydantic request and response schemas for the Console Dify Builder API."""
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, RootModel, StrictInt, StringConstraints, model_validator
from core.dify_builder.contract import (
ActionKind,
AssistantTurnItem,
BuilderErrorCode,
CanvasEvent,
Decision,
DecisionItem,
ExecutionActivityState,
ExecutionProgressStatus,
FormCard,
InteractionResponseItem,
NoticeItem,
Phase,
PlanCard,
PreflightContextCard,
RecoveryRef,
ResourceSelectCard,
RunContextCard,
RunStatus,
SessionModel,
TestResultCard,
UserItem,
)
from fields.base import ResponseModel
from fields.workflow_stream_fields import WorkflowStreamPayload
NonEmptyString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
ClientTurnId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=128)]
class DifyBuilderPayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
class DifyBuilderCreateBuildSessionPayload(DifyBuilderPayload):
app_id: NonEmptyString
scenario: Literal["build"]
goal_text: NonEmptyString
# True when the app was created from this same prompt and still carries the
# name cut from it, so Builder may rewrite it once it understands the goal.
# False is create-from-blank: the user named the app, Builder inherits it.
derive_app_name: bool = False
model_config_data: SessionModel | None = Field(default=None, alias="model_config")
class DifyBuilderCreateEditSessionPayload(DifyBuilderPayload):
app_id: NonEmptyString
scenario: Literal["edit"]
goal_text: NonEmptyString
model_config_data: SessionModel | None = Field(default=None, alias="model_config")
class DifyBuilderCreateFixSessionPayload(DifyBuilderPayload):
app_id: NonEmptyString
scenario: Literal["fix"]
failed_run_id: NonEmptyString
model_config_data: SessionModel | None = Field(default=None, alias="model_config")
class DifyBuilderChecklistErrorPayload(DifyBuilderPayload):
node_id: str
node_type: str
title: str
messages: list[str]
unconnected: bool
plugin_missing: bool
class DifyBuilderCreateChecklistFixSessionPayload(DifyBuilderPayload):
app_id: NonEmptyString
scenario: Literal["fix"]
checklist_errors: Annotated[list[DifyBuilderChecklistErrorPayload], Field(min_length=1)]
failed_run_id: NonEmptyString | None = None
model_config_data: SessionModel | None = Field(default=None, alias="model_config")
class DifyBuilderCreateSessionPayload(
RootModel[
DifyBuilderCreateBuildSessionPayload
| DifyBuilderCreateEditSessionPayload
| DifyBuilderCreateFixSessionPayload
| DifyBuilderCreateChecklistFixSessionPayload
]
):
"""Create a Build, Edit, failed-run Fix, or checklist Fix session."""
class DifyBuilderSubmitActionPayload(DifyBuilderPayload):
action_id: NonEmptyString
payload: dict[str, Any] = Field(default_factory=dict)
base_version: StrictInt
base_app_revision: NonEmptyString
class DifyBuilderSubmitMessagePayload(DifyBuilderPayload):
text: NonEmptyString
base_version: StrictInt
client_turn_id: ClientTurnId
class DifyBuilderConversationListQuery(DifyBuilderPayload):
before_seq: int | None = Field(default=None, ge=0, description="Load groups before this sequence")
after_seq: int | None = Field(default=None, ge=-1, description="Load groups after this sequence")
limit: int = Field(default=20, ge=1, le=100, description="Number of conversation groups to return")
@model_validator(mode="after")
def validate_cursor(self) -> "DifyBuilderConversationListQuery":
if self.before_seq is not None and self.after_seq is not None:
raise ValueError("before_seq and after_seq are mutually exclusive")
return self
class DifyBuilderConversationItemBase(ResponseModel):
seq: int
at_version: int
class DifyBuilderUserConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["user"]
payload: UserItem
class DifyBuilderDecisionConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["decision"]
payload: DecisionItem
class DifyBuilderInteractionResponseConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["interaction_response"]
payload: InteractionResponseItem
class DifyBuilderNoticeConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["notice"]
payload: NoticeItem
class DifyBuilderRunContextConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["run_context"]
payload: RunContextCard
class DifyBuilderPreflightContextConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["preflight_context"]
payload: PreflightContextCard
class DifyBuilderAssistantTurnConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["assistant_turn"]
payload: AssistantTurnItem
class DifyBuilderPlanConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["plan"]
payload: PlanCard
class DifyBuilderFormConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["form"]
payload: FormCard
class DifyBuilderResourceSelectConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["resource_select"]
payload: ResourceSelectCard
class DifyBuilderTestResultConversationItemResponse(DifyBuilderConversationItemBase):
kind: Literal["test_result"]
payload: TestResultCard
DifyBuilderConversationItem = Annotated[
DifyBuilderUserConversationItemResponse
| DifyBuilderDecisionConversationItemResponse
| DifyBuilderInteractionResponseConversationItemResponse
| DifyBuilderNoticeConversationItemResponse
| DifyBuilderRunContextConversationItemResponse
| DifyBuilderPreflightContextConversationItemResponse
| DifyBuilderAssistantTurnConversationItemResponse
| DifyBuilderPlanConversationItemResponse
| DifyBuilderFormConversationItemResponse
| DifyBuilderResourceSelectConversationItemResponse
| DifyBuilderTestResultConversationItemResponse,
Field(discriminator="kind"),
]
# Live item events intentionally exclude assistant_turn. Assistant text has a
# dedicated streaming contract with a preallocated sequence; allowing it here
# would reintroduce a second, conflicting text source for the frontend.
DifyBuilderAppendedConversationItem = Annotated[
DifyBuilderUserConversationItemResponse
| DifyBuilderDecisionConversationItemResponse
| DifyBuilderInteractionResponseConversationItemResponse
| DifyBuilderNoticeConversationItemResponse
| DifyBuilderRunContextConversationItemResponse
| DifyBuilderPreflightContextConversationItemResponse
| DifyBuilderPlanConversationItemResponse
| DifyBuilderFormConversationItemResponse
| DifyBuilderResourceSelectConversationItemResponse
| DifyBuilderTestResultConversationItemResponse,
Field(discriminator="kind"),
]
class DifyBuilderActiveInteractionResponse(ResponseModel):
action_id: str
card_seq: int
valid_at_version: int
class DifyBuilderActionResponse(ResponseModel):
id: str
label: str
kind: ActionKind
class DifyBuilderAppRevisionResponse(ResponseModel):
current: str
conflicted: bool
class DifyBuilderSessionStateResponse(ResponseModel):
session_id: str
version: int
canvas_read_only: bool
run_status: RunStatus
interrupted: bool
conversation_last_seq: int
phase: Phase
actions: list[DifyBuilderActionResponse] = Field(default_factory=list)
decision: Decision | None = None
active_interaction: DifyBuilderActiveInteractionResponse | None = None
recovery: RecoveryRef | None = None
model: SessionModel | None = None
app_revision: DifyBuilderAppRevisionResponse | None = None
class DifyBuilderSessionViewResponse(DifyBuilderSessionStateResponse):
last_command_id: str = ""
class DifyBuilderConversationPageResponse(ResponseModel):
data: list[DifyBuilderConversationItem]
has_more: bool
first_seq: int | None
last_seq: int | None
class DifyBuilderCommandFinishedEventData(DifyBuilderSessionStateResponse):
command_id: str
post_canvas_action_id: str | None = None
class DifyBuilderCommandStartedEventData(ResponseModel):
session_id: str
command_id: str
version: int
phase: Phase
run_status: RunStatus
class DifyBuilderCommandStartedEventResponse(ResponseModel):
event: Literal["command_started"]
data: DifyBuilderCommandStartedEventData
class DifyBuilderWorkflowEventData(ResponseModel):
session_id: str
operation_id: str
at_version: int
revision: int
payload: WorkflowStreamPayload
class DifyBuilderWorkflowEventResponse(ResponseModel):
event: Literal["workflow"]
data: DifyBuilderWorkflowEventData
class DifyBuilderCanvasEventData(ResponseModel):
session_id: str
operation_id: str
at_version: int
revision: int
event: CanvasEvent
node_id: str | None = None
class DifyBuilderCanvasEventResponse(ResponseModel):
event: Literal["canvas"]
data: DifyBuilderCanvasEventData
class DifyBuilderExecutionActivityResponse(ResponseModel):
id: str
label: str
state: ExecutionActivityState
parent_id: str | None = None
class DifyBuilderExecutionProgressResponse(ResponseModel):
status: ExecutionProgressStatus
activities: list[DifyBuilderExecutionActivityResponse] = Field(default_factory=list)
class DifyBuilderAgentMessageEventData(ResponseModel):
session_id: str
command_id: str
operation_id: str
turn_id: str
delta: str
seq: int
at_version: int
revision: int
done: bool
text_bytes: int
execution: DifyBuilderExecutionProgressResponse | None = None
cards: list[str] = Field(default_factory=list)
class DifyBuilderAgentMessageEventResponse(ResponseModel):
event: Literal["agent_message"]
data: DifyBuilderAgentMessageEventData
class DifyBuilderConversationItemAppendedEventData(ResponseModel):
session_id: str
command_id: str
item: DifyBuilderAppendedConversationItem
class DifyBuilderConversationItemAppendedEventResponse(ResponseModel):
event: Literal["conversation_item_appended"]
data: DifyBuilderConversationItemAppendedEventData
class DifyBuilderReasoningEventData(ResponseModel):
session_id: str
operation_id: str
at_version: int
revision: int
delta: str
class DifyBuilderReasoningEventResponse(ResponseModel):
event: Literal["reasoning"]
data: DifyBuilderReasoningEventData
class DifyBuilderProgressEventData(ResponseModel):
session_id: str
operation_id: str
at_version: int
revision: int
status: ExecutionProgressStatus
activity: DifyBuilderExecutionActivityResponse | None
class DifyBuilderProgressEventResponse(ResponseModel):
event: Literal["progress"]
data: DifyBuilderProgressEventData
class DifyBuilderCommandFinishedEventResponse(ResponseModel):
event: Literal["command_finished"]
data: DifyBuilderCommandFinishedEventData
class DifyBuilderErrorEventData(ResponseModel):
session_id: str | None = None
command_id: str | None = None
code: str | None = None
message: str
class DifyBuilderErrorEventResponse(ResponseModel):
event: Literal["error"]
data: DifyBuilderErrorEventData
class DifyBuilderStreamEventResponse(
RootModel[
Annotated[
DifyBuilderCommandStartedEventResponse
| DifyBuilderWorkflowEventResponse
| DifyBuilderCanvasEventResponse
| DifyBuilderAgentMessageEventResponse
| DifyBuilderConversationItemAppendedEventResponse
| DifyBuilderReasoningEventResponse
| DifyBuilderProgressEventResponse
| DifyBuilderCommandFinishedEventResponse
| DifyBuilderErrorEventResponse,
Field(discriminator="event"),
]
]
):
"""One JSON object carried by an SSE ``data:`` frame."""
class DifyBuilderErrorResponse(ResponseModel):
code: BuilderErrorCode
message: str | None = None
recoverable: bool | None = None

View File

@@ -1,12 +1,19 @@
"""Installed-app audio admission, error translation, and response serialization."""
import logging
from collections.abc import Callable
from functools import wraps
from http import HTTPStatus
from flask import request
from werkzeug.exceptions import InternalServerError
from flask import Response, request
from flask_restx import Resource
from werkzeug.exceptions import HTTPException, InternalServerError
import services
from controllers.common.audio_response import audio_binary_response
from controllers.common.controller_schemas import TextToAudioPayload
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.console import console_ns
from controllers.console.app.error import (
AppUnavailableError,
AudioTooLargeError,
@@ -15,28 +22,31 @@ from controllers.console.app.error import (
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderNotSupportSpeechToTextError,
ProviderNotSupportTextToSpeechError,
ProviderQuotaExceededError,
SpeechToTextDisabledError,
UnsupportedAudioTypeError,
)
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import current_account_with_tenant
from models.model import InstalledApp
from services.app_ref_service import AppRefService
from services.audio_service import AudioService
from libs.helper import dump_response
from machinery.context import RequestContext
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.audio_types import AudioAppRef, AudioUpload
from services.errors.app_model_config import AppModelConfigBrokenError
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
ProviderNotSupportSpeechToTextServiceError,
ProviderNotSupportTextToSpeechServiceError,
SpeechToTextDisabledServiceError,
UnsupportedAudioTypeServiceError,
)
from .. import console_ns
from services.installed_app_access_service import InstalledAppRef
logger = logging.getLogger(__name__)
@@ -44,111 +54,92 @@ register_schema_model(console_ns, TextToAudioPayload)
register_response_schema_models(console_ns, AudioBinaryResponse, AudioTranscriptResponse)
def _audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return view(*args, **kwargs)
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AppModelConfigBrokenError as error:
logger.exception("App model config broken")
raise AppUnavailableError() from error
except NoAudioUploadedServiceError as error:
raise NoAudioUploadedError() from error
except AudioTooLargeServiceError as error:
raise AudioTooLargeError(str(error)) from error
except UnsupportedAudioTypeServiceError as error:
raise UnsupportedAudioTypeError() from error
except ProviderNotSupportSpeechToTextServiceError as error:
raise ProviderNotSupportSpeechToTextError() from error
except ProviderNotSupportTextToSpeechServiceError as error:
raise ProviderNotSupportTextToSpeechError() from error
except SpeechToTextDisabledServiceError as error:
raise SpeechToTextDisabledError() from error
except ProviderTokenNotInitError as error:
raise ProviderNotInitializeError(error.description) from error
except QuotaExceededError as error:
raise ProviderQuotaExceededError() from error
except ModelCurrentlyNotSupportError as error:
raise ProviderModelCurrentlyNotSupportError() from error
except InvokeError as error:
raise CompletionRequestError(error.description) from error
except (HTTPException, ValueError):
raise
except Exception as error:
logger.exception("Installed-app audio operation failed")
raise InternalServerError() from error
return decorated
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/audio-to-text",
endpoint="installed_app_audio",
)
class ChatAudioApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
def post(self, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
file = request.files["file"]
try:
response = AudioService.transcript_asr(
app_model=app_model,
file=file,
session=db.session(),
end_user=None,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except NoAudioUploadedServiceError:
raise NoAudioUploadedError()
except AudioTooLargeServiceError as e:
raise AudioTooLargeError(str(e))
except UnsupportedAudioTypeServiceError:
raise UnsupportedAudioTypeError()
except ProviderNotSupportSpeechToTextServiceError:
raise ProviderNotSupportSpeechToTextError()
except SpeechToTextDisabledServiceError:
raise SpeechToTextDisabledError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
class ChatAudioApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@console_account_admission()
@get_installed_app
@_audio_errors
def post(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
file = request.files.get("file")
audio = AudioUpload(stream=file.stream, mime_type=file.mimetype) if file is not None else None
transcript = application_services().app_audio.transcript_asr(
app=AudioAppRef(
app_id=installed_app.app_id,
tenant_id=installed_app.app_owner_tenant_id,
app_mode=installed_app.app_mode,
),
audio=audio,
)
return dump_response(AudioTranscriptResponse, transcript)
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/text-to-audio",
endpoint="installed_app_text",
)
class ChatTextApi(InstalledAppResource):
class ChatTextApi(Resource):
@console_ns.expect(console_ns.models[TextToAudioPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioBinaryResponse.__name__])
@console_account_admission()
@get_installed_app
@model_validate(TextToAudioPayload)
def post(self, req_data: TextToAudioPayload, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
try:
message_id = req_data.message_id
text = req_data.text
voice = req_data.voice
message_ref = None
if message_id:
current_user, _ = current_account_with_tenant()
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
message_id,
account_id=current_user.id,
)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session(),
text=text,
voice=voice,
message_ref=message_ref,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except NoAudioUploadedServiceError:
raise NoAudioUploadedError()
except AudioTooLargeServiceError as e:
raise AudioTooLargeError(str(e))
except UnsupportedAudioTypeServiceError:
raise UnsupportedAudioTypeError()
except ProviderNotSupportSpeechToTextServiceError:
raise ProviderNotSupportSpeechToTextError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
@_audio_errors
def post(
self, payload: TextToAudioPayload, request_context: RequestContext, installed_app: InstalledAppRef
) -> Response | None:
output = application_services().app_audio.transcript_tts(
app=AudioAppRef(
app_id=installed_app.app_id,
tenant_id=installed_app.app_owner_tenant_id,
app_mode=installed_app.app_mode,
),
account_id=request_context.account_id,
text=payload.text,
voice=payload.voice,
message_id=payload.message_id,
)
# response-contract:ignore audio_binary_response
return audio_binary_response(output)

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from http import HTTPStatus
from typing import cast
from flask_restx import Namespace, Resource
@@ -60,7 +61,7 @@ class BannerApi(Resource):
"""Resource for banner list."""
@api.doc(params=query_params_from_model(BannerListQuery))
@api.response(200, "Success", api.models[BannerListResponse.__name__])
@api.response(HTTPStatus.OK, "Success", api.models[BannerListResponse.__name__])
@model_validate(BannerListQuery)
def get(self, req_data: BannerListQuery):
"""Get banner list."""

View File

@@ -1,10 +1,12 @@
import logging
from http import HTTPStatus
from typing import Any, Literal
from uuid import UUID
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
from werkzeug.exceptions import InternalServerError, NotFound, Unauthorized
import services
from controllers.common.fields import SimpleResultResponse
@@ -17,10 +19,10 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import with_session
from controllers.console.explore.error import NotChatAppError, NotCompletionAppError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import model_validate, with_current_user, with_current_user_id
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
@@ -28,16 +30,16 @@ from core.errors.error import (
ProviderTokenNotInitError,
QuotaExceededError,
)
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.datetime_utils import naive_utc_now
from models import Account
from models.model import AppMode, InstalledApp
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.conversation_service import ConversationService
from machinery.context import RequestContext
from models.model import AppMode
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.errors.llm import InvokeRateLimitError
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_generation_service import InstalledAppNotChatError, InstalledAppNotCompletionError
from .. import console_ns
@@ -84,45 +86,35 @@ register_response_schema_models(console_ns, SimpleResultResponse)
"/installed-apps/<uuid:installed_app_id>/completion-messages",
endpoint="installed_app_completion",
)
class CompletionApi(InstalledAppResource):
class CompletionApi(Resource):
@console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@model_validate(CompletionMessageExplorePayload)
def post(
self,
req_data: CompletionMessageExplorePayload,
session: Session,
current_user: Account,
installed_app: InstalledApp,
):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
if app_model.mode != AppMode.COMPLETION:
raise NotCompletionAppError()
args = req_data.model_dump(exclude_none=True)
streaming = req_data.response_mode == "streaming"
args["auto_generate_name"] = False
installed_app.last_used_at = naive_utc_now()
db.session.commit()
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> Response:
try:
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=streaming,
response = application_services().installed_apps.generation.generate_completion(
installed_app=installed_app,
account_id=request_context.account_id,
args=req_data.model_dump(exclude_none=True),
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except InstalledAppNotCompletionError:
raise NotCompletionAppError() from None
except InstalledAppNotFoundError:
raise NotFound("Installed app not found") from None
except AccountNotFoundError:
raise Unauthorized("Account no longer exists.") from None
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
@@ -149,73 +141,60 @@ class CompletionApi(InstalledAppResource):
"/installed-apps/<uuid:installed_app_id>/completion-messages/<string:task_id>/stop",
endpoint="installed_app_stop_completion",
)
class CompletionStopApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user_id
@with_session(write=False)
def post(self, session: Session, current_user_id: str, installed_app: InstalledApp, task_id: str):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
if app_model.mode != AppMode.COMPLETION:
class CompletionStopApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_installed_app
def post(
self, request_context: RequestContext, installed_app: InstalledAppRef, task_id: str
) -> tuple[dict[str, object], int]:
app_mode = installed_app.app_mode
if app_mode != AppMode.COMPLETION:
raise NotCompletionAppError()
AppTaskService.stop_task(
application_services().app_tasks.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
user_id=request_context.account_id,
app_mode=AppMode.value_of(app_mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return SimpleResultResponse(result="success").model_dump(mode="json"), HTTPStatus.OK
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/chat-messages",
endpoint="installed_app_chat_completion",
)
class ChatApi(InstalledAppResource):
class ChatApi(Resource):
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@model_validate(ChatMessagePayload)
def post(self, req_data: ChatMessagePayload, session: Session, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
args = req_data.model_dump(exclude_none=True)
args["auto_generate_name"] = False
installed_app.last_used_at = naive_utc_now()
db.session.commit()
def post(
self,
req_data: ChatMessagePayload,
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> Response:
try:
# Eagerly validate conversation to avoid hanging on invalid conversation_id
if req_data.conversation_id:
ConversationService.get_conversation(
app_model=app_model,
conversation_id=req_data.conversation_id,
user=current_user,
session=session,
)
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
response = application_services().installed_apps.generation.generate_chat(
installed_app=installed_app,
account_id=request_context.account_id,
args=req_data.model_dump(exclude_none=True),
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except InstalledAppNotChatError:
raise NotChatAppError() from None
except InstalledAppNotFoundError:
raise NotFound("Installed app not found") from None
except AccountNotFoundError:
raise Unauthorized("Account no longer exists.") from None
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
@@ -244,23 +223,22 @@ class ChatApi(InstalledAppResource):
"/installed-apps/<uuid:installed_app_id>/chat-messages/<string:task_id>/stop",
endpoint="installed_app_stop_chat_completion",
)
class ChatStopApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user_id
@with_session(write=False)
def post(self, session: Session, current_user_id: str, installed_app: InstalledApp, task_id: str):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
class ChatStopApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_installed_app
def post(
self, request_context: RequestContext, installed_app: InstalledAppRef, task_id: str
) -> tuple[dict[str, object], int]:
app_mode = AppMode.value_of(installed_app.app_mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
AppTaskService.stop_task(
application_services().app_tasks.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=current_user_id,
user_id=request_context.account_id,
app_mode=app_mode,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return SimpleResultResponse(result="success").model_dump(mode="json"), HTTPStatus.OK

View File

@@ -1,33 +1,35 @@
from typing import Any
"""HTTP admission and response contracts for installed-app conversations."""
from collections.abc import Callable
from functools import wraps
from http import HTTPStatus
from uuid import UUID
from flask import request
from pydantic import BaseModel, Field, TypeAdapter
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from controllers.common.controller_schemas import ConversationRenamePayload
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.error import NotChatAppError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import model_validate, with_current_user
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.conversation_fields import (
ConversationInfiniteScrollPagination,
ConversationResponseSource,
ResultResponse,
SimpleConversation,
from controllers.console import console_ns
from controllers.console.explore.error import (
ConversationCursorNotFoundHTTPError,
ConversationFirstMessageNotFoundHTTPError,
ConversationNameRequiredHTTPError,
ConversationNotFoundHTTPError,
InstalledAppNotFoundHTTPError,
NotChatAppError,
)
from libs.helper import UUIDStrOrEmpty
from models import Account
from models.model import AppMode, InstalledApp
from services.conversation_service import ConversationService
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.conversation_fields import ConversationInfiniteScrollPagination, ResultResponse, SimpleConversation
from libs.helper import UUIDStrOrEmpty, dump_response
from machinery.context import RequestContext
from services.errors.conversation import ConversationNotExistsError, LastConversationNotExistsError
from services.web_conversation_service import WebConversationService
from .. import console_ns
from services.errors.message import MessageNotExistsError
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_conversation_service import ConversationNameRequiredError, ConversationNotChatAppError
class ConversationListQuery(BaseModel):
@@ -35,170 +37,149 @@ class ConversationListQuery(BaseModel):
limit: int = Field(default=20, ge=1, le=100)
pinned: bool | None = None
@field_validator("limit", mode="before")
@classmethod
def parse_limit(cls, value: object) -> object:
# Preserve request.args.get(..., default=20, type=int) coercion.
if isinstance(value, str):
try:
return int(value)
except ValueError:
return 20
return value
@field_validator("pinned", mode="before")
@classmethod
def parse_pinned(cls, value: object) -> object:
# Existing callers use the literal query string "true" for pinned items.
return value == "true" if isinstance(value, str) else value
register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayload)
register_response_schema_models(
console_ns,
ConversationInfiniteScrollPagination,
ResultResponse,
SimpleConversation,
)
register_response_schema_models(console_ns, ConversationInfiniteScrollPagination, ResultResponse, SimpleConversation)
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations",
endpoint="installed_app_conversations",
)
class ConversationListApi(InstalledAppResource):
def _conversation_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Translate the shared conversation use-case errors at the Console boundary."""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return view(*args, **kwargs)
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
except ConversationNotChatAppError as error:
raise NotChatAppError() from error
except ConversationNotExistsError as error:
raise ConversationNotFoundHTTPError() from error
except LastConversationNotExistsError as error:
raise ConversationCursorNotFoundHTTPError() from error
except MessageNotExistsError as error:
raise ConversationFirstMessageNotFoundHTTPError() from error
except ConversationNameRequiredError as error:
raise ConversationNameRequiredHTTPError() from error
return decorated
@console_ns.route("/installed-apps/<uuid:installed_app_id>/conversations", endpoint="installed_app_conversations")
class ConversationListApi(Resource):
@console_ns.doc(params=query_params_from_model(ConversationListQuery))
@console_ns.response(200, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
raw_args: dict[str, Any] = {
"last_id": request.args.get("last_id"),
"limit": request.args.get("limit", default=20, type=int),
"pinned": request.args.get("pinned"),
}
if raw_args["last_id"] is None:
raw_args["last_id"] = None
pinned_value = raw_args["pinned"]
if isinstance(pinned_value, str):
raw_args["pinned"] = pinned_value == "true"
args = ConversationListQuery.model_validate(raw_args)
try:
with sessionmaker(db.engine).begin() as session:
pagination = WebConversationService.pagination_by_last_id(
session=session,
app_model=app_model,
user=current_user,
last_id=args.last_id or None,
limit=args.limit,
invoke_from=InvokeFrom.EXPLORE,
pinned=args.pinned,
)
adapter = TypeAdapter(SimpleConversation)
conversations = [
adapter.validate_python(
ConversationResponseSource(item, session=session),
from_attributes=True,
)
for item in pagination.data
]
return ConversationInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=conversations,
).model_dump(mode="json")
except LastConversationNotExistsError:
raise NotFound("Last Conversation Not Exists.")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__])
@console_account_admission()
@get_installed_app
@model_validate(ConversationListQuery)
@_conversation_errors
def get(
self, query: ConversationListQuery, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, object]:
page = application_services().installed_apps.conversations.get_page(
installed_app=installed_app,
account_id=request_context.account_id,
last_id=query.last_id or None,
limit=query.limit,
pinned=query.pinned,
)
return dump_response(ConversationInfiniteScrollPagination, page)
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>",
endpoint="installed_app_conversation",
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>", endpoint="installed_app_conversation"
)
class ConversationApi(InstalledAppResource):
@console_ns.response(204, "Conversation deleted successfully")
@with_current_user
def delete(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
conversation_id = str(c_id)
try:
ConversationService.delete(app_model, conversation_id, current_user, session=db.session())
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return "", 204
class ConversationApi(Resource):
@console_ns.response(HTTPStatus.NO_CONTENT, "Conversation deleted successfully")
@console_account_admission()
@get_installed_app
@_conversation_errors
def delete(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> tuple[str, int]:
application_services().installed_apps.conversations.delete(
installed_app=installed_app, account_id=request_context.account_id, conversation_id=str(c_id)
)
return "", HTTPStatus.NO_CONTENT
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/name",
endpoint="installed_app_conversation_rename",
)
class ConversationRenameApi(InstalledAppResource):
class ConversationRenameApi(Resource):
@console_ns.expect(console_ns.models[ConversationRenamePayload.__name__])
@console_ns.response(200, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__])
@with_current_user
@console_ns.response(
HTTPStatus.OK, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__]
)
@console_account_admission()
@get_installed_app
@model_validate(ConversationRenamePayload)
def post(self, req_data: ConversationRenamePayload, current_user: Account, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
conversation_id = str(c_id)
try:
session = db.session()
conversation = ConversationService.rename(
app_model, conversation_id, current_user, req_data.name, req_data.auto_generate, session=session
)
return (
TypeAdapter(SimpleConversation)
.validate_python(ConversationResponseSource(conversation, session=session), from_attributes=True)
.model_dump(mode="json")
)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@_conversation_errors
def post(
self,
payload: ConversationRenamePayload,
request_context: RequestContext,
installed_app: InstalledAppRef,
c_id: UUID,
) -> dict[str, object]:
conversation = application_services().installed_apps.conversations.rename(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),
name=payload.name,
auto_generate=payload.auto_generate,
)
return dump_response(SimpleConversation, conversation)
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/pin",
endpoint="installed_app_conversation_pin",
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/pin", endpoint="installed_app_conversation_pin"
)
class ConversationPinApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@with_current_user
def patch(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
conversation_id = str(c_id)
try:
WebConversationService.pin(app_model, conversation_id, current_user, db.session())
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return ResultResponse(result="success").model_dump(mode="json")
class ConversationPinApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
@console_account_admission()
@get_installed_app
@_conversation_errors
def patch(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> dict[str, object]:
application_services().installed_apps.conversations.set_pinned(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),
is_pinned=True,
)
return dump_response(ResultResponse, {"result": "success"})
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/conversations/<uuid:c_id>/unpin",
endpoint="installed_app_conversation_unpin",
)
class ConversationUnPinApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@with_current_user
def patch(self, current_user: Account, installed_app: InstalledApp, c_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
conversation_id = str(c_id)
WebConversationService.unpin(app_model, conversation_id, current_user, db.session())
return ResultResponse(result="success").model_dump(mode="json")
class ConversationUnPinApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
@console_account_admission()
@get_installed_app
@_conversation_errors
def patch(self, request_context: RequestContext, installed_app: InstalledAppRef, c_id: UUID) -> dict[str, object]:
application_services().installed_apps.conversations.set_pinned(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=str(c_id),
is_pinned=False,
)
return dump_response(ResultResponse, {"result": "success"})

View File

@@ -1,40 +1,130 @@
from http import HTTPStatus
from core.logging.context import get_request_id
from libs.exception import BaseHTTPException
class InstalledAppHTTPError(BaseHTTPException):
"""A Console error with safe details and a request ID for troubleshooting."""
def __init__(self) -> None:
super().__init__()
details: dict[str, object] = {"request_id": get_request_id()}
self.data = {"code": self.error_code, "message": self.description, "status": self.code, "details": details}
class InstalledAppNotFoundHTTPError(InstalledAppHTTPError):
error_code = "installed_app_not_found"
description = "The app was not found in this workspace."
code = HTTPStatus.NOT_FOUND
class InstalledAppUnavailableHTTPError(InstalledAppHTTPError):
error_code = "installed_app_unavailable"
description = "The app is not available in this app library."
code = HTTPStatus.NOT_FOUND
class InstalledAppInvalidCursorError(InstalledAppHTTPError):
error_code = "invalid_cursor"
description = "The app list cursor is invalid. Refresh the list and try again."
code = HTTPStatus.BAD_REQUEST
class WebAppAccessUnavailableHTTPError(InstalledAppHTTPError):
error_code = "web_app_access_unavailable"
description = "The app access service is unavailable. Try again later."
code = HTTPStatus.SERVICE_UNAVAILABLE
class ConversationNotFoundHTTPError(InstalledAppHTTPError):
error_code = "conversation_not_found"
description = "The conversation was not found for this account and app."
code = HTTPStatus.NOT_FOUND
class ConversationCursorNotFoundHTTPError(InstalledAppHTTPError):
error_code = "conversation_cursor_not_found"
description = "The conversation cursor is not in the current list. Refresh the list and try again."
code = HTTPStatus.NOT_FOUND
class ConversationFirstMessageNotFoundHTTPError(InstalledAppHTTPError):
error_code = "conversation_first_message_not_found"
description = "The conversation has no message from which to generate a name."
code = HTTPStatus.NOT_FOUND
class ConversationNameRequiredHTTPError(InstalledAppHTTPError):
error_code = "conversation_name_required"
description = "A name is required when automatic naming is disabled."
code = HTTPStatus.BAD_REQUEST
class MessageNotFoundHTTPError(InstalledAppHTTPError):
error_code = "message_not_found"
description = "The message was not found for this account and app."
code = HTTPStatus.NOT_FOUND
class MessageCursorNotFoundHTTPError(InstalledAppHTTPError):
error_code = "message_cursor_not_found"
description = "The message cursor is not in this conversation. Refresh the list and try again."
code = HTTPStatus.NOT_FOUND
class MessageFeedbackRatingRequiredHTTPError(InstalledAppHTTPError):
error_code = "message_feedback_rating_required"
description = "A rating is required when there is no existing feedback to remove."
code = HTTPStatus.BAD_REQUEST
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Not Completion App"
code = 400
code = HTTPStatus.BAD_REQUEST
class NotChatAppError(BaseHTTPException):
error_code = "not_chat_app"
description = "App mode is invalid."
code = 400
code = HTTPStatus.BAD_REQUEST
class NotWorkflowAppError(BaseHTTPException):
error_code = "not_workflow_app"
description = "Only support workflow app."
code = 400
code = HTTPStatus.BAD_REQUEST
class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException):
error_code = "app_suggested_questions_after_answer_disabled"
description = "Function Suggested questions after answer disabled."
code = 403
code = HTTPStatus.FORBIDDEN
class AppAccessDeniedError(BaseHTTPException):
error_code = "access_denied"
description = "App access denied."
code = 403
code = HTTPStatus.FORBIDDEN
class RecommendedAppNotFoundError(BaseHTTPException):
error_code = "recommended_app_not_found"
description = "Recommended app not found."
code = 404
code = HTTPStatus.NOT_FOUND
class AppPreviewSiteUnavailableError(BaseHTTPException):
error_code = "app_site_unavailable"
description = "The app preview site is unavailable."
code = HTTPStatus.FORBIDDEN
class AppPreviewOwnerUnavailableError(BaseHTTPException):
error_code = "app_owner_unavailable"
description = "The app preview owner is unavailable."
code = HTTPStatus.FORBIDDEN
class TrialAppNotAllowed(BaseHTTPException):
@@ -44,7 +134,7 @@ class TrialAppNotAllowed(BaseHTTPException):
"""
error_code = "trial_app_not_allowed"
code = 403
code = HTTPStatus.FORBIDDEN
description = "the app is not allowed to be trial."
@@ -55,11 +145,11 @@ class TrialAppLimitExceeded(BaseHTTPException):
"""
error_code = "trial_app_limit_exceeded"
code = 403
code = HTTPStatus.FORBIDDEN
description = "The user has exceeded the trial app limit."
class TrialAppFeatureDisabledError(BaseHTTPException):
error_code = "trial_app_feature_disabled"
code = 403
code = HTTPStatus.FORBIDDEN
description = "Trial app feature is not enabled."

View File

@@ -1,38 +1,36 @@
import base64
import binascii
import logging
from http import HTTPStatus
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field
from sqlalchemy import and_, select
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.common.fields import SimpleMessageResponse, SimpleResultMessageResponse
from controllers.common.fields import SimpleResultMessageResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
model_validate,
with_current_tenant_id,
with_current_user,
from controllers.console.explore.error import (
InstalledAppInvalidCursorError,
InstalledAppNotFoundHTTPError,
InstalledAppUnavailableHTTPError,
WebAppAccessUnavailableHTTPError,
)
from extensions.ext_database import db
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account, App, InstalledApp, RecommendedApp
from machinery.context import RequestContext
from models.model import AppMode, IconType
from services.account_service import TenantService
from services.installed_app_service import InstalledAppCursor, InstalledAppService
class InstalledAppCreatePayload(BaseModel):
app_id: str
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_service import (
InstalledAppCursor,
InstalledAppRecord,
InstalledAppUnavailableError,
)
from services.webapp_access_query_service import WebAppAccessUnavailableError
class InstalledAppUpdatePayload(BaseModel):
@@ -75,8 +73,8 @@ def _decode_installed_app_cursor(cursor: str | None) -> InstalledAppCursor | Non
padded_cursor = cursor + "=" * (-len(cursor) % 4)
payload = base64.b64decode(padded_cursor, altchars=b"-_", validate=True)
return InstalledAppCursor.model_validate_json(payload)
except (binascii.Error, UnicodeDecodeError, ValueError):
raise BadRequest("Invalid cursor") from None
except (binascii.Error, UnicodeDecodeError, ValueError) as error:
raise InstalledAppInvalidCursorError() from error
class InstalledAppInfoResponse(ResponseModel):
@@ -102,7 +100,6 @@ class InstalledAppResponse(ResponseModel):
is_pinned: bool
last_used_at: int | None
editable: bool
uninstallable: bool
class InstalledAppListResponse(ResponseModel):
@@ -112,26 +109,22 @@ class InstalledAppListResponse(ResponseModel):
def _installed_app_response_data(
installed_app: InstalledApp,
app_model: App,
installed_app: InstalledAppRecord,
*,
current_tenant_id: str,
current_user: Account,
editable: bool,
) -> InstalledAppResponse:
return InstalledAppResponse(
id=installed_app.id,
app=InstalledAppInfoResponse.model_validate(app_model),
app=InstalledAppInfoResponse.model_validate(installed_app.app, from_attributes=True),
app_owner_tenant_id=installed_app.app_owner_tenant_id,
is_pinned=installed_app.is_pinned,
last_used_at=to_timestamp(installed_app.last_used_at),
editable=current_user.role in {"owner", "admin"},
uninstallable=current_tenant_id == installed_app.app_owner_tenant_id,
editable=editable,
)
register_schema_models(
console_ns,
InstalledAppCreatePayload,
InstalledAppUpdatePayload,
InstalledAppsListQuery,
)
@@ -140,155 +133,95 @@ register_response_schema_models(
InstalledAppInfoResponse,
InstalledAppResponse,
InstalledAppListResponse,
SimpleMessageResponse,
SimpleResultMessageResponse,
)
@console_ns.route("/installed-apps")
class InstalledAppsListApi(Resource):
@login_required
@account_initialization_required
@console_ns.doc(params=query_params_from_model(InstalledAppsListQuery))
@console_ns.response(200, "Success", console_ns.models[InstalledAppListResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[InstalledAppListResponse.__name__])
@console_account_admission()
@model_validate(InstalledAppsListQuery)
def get(self, query: InstalledAppsListQuery, request_context: RequestContext) -> dict[str, object]:
cursor = _decode_installed_app_cursor(query.cursor)
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
installed_apps, has_more, next_cursor = InstalledAppService.get_visible_page(
tenant_id=current_tenant_id,
user_id=str(current_user.id),
cursor=cursor,
limit=query.limit,
app_id=query.app_id,
name=query.name,
session=db.session,
)
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
try:
page = application_services().installed_apps.management.get_visible_page(
tenant_id=request_context.active_workspace_id,
user_id=request_context.account_id,
cursor=cursor,
limit=query.limit,
app_id=query.app_id,
name=query.name,
)
except WebAppAccessUnavailableError as error:
logger.exception(
"Installed app list access check failed: workspace_id=%s account_id=%s request_id=%s",
request_context.active_workspace_id,
request_context.account_id,
request_context.request_id,
)
raise WebAppAccessUnavailableHTTPError() from error
installed_app_list = [
_installed_app_response_data(
installed_app,
app_model,
current_tenant_id=current_tenant_id,
current_user=current_user,
editable=page.editable,
)
for installed_app, app_model in installed_apps
for installed_app in page.data
]
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, current_user.id)
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, request_context.account_id)
return dump_response(
InstalledAppListResponse,
{
"installed_apps": installed_app_list,
"has_more": has_more,
"next_cursor": _encode_installed_app_cursor(next_cursor) if next_cursor else None,
"has_more": page.has_more,
"next_cursor": _encode_installed_app_cursor(page.next_cursor) if page.next_cursor else None,
},
)
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("apps")
@console_ns.expect(console_ns.models[InstalledAppCreatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleMessageResponse.__name__])
@with_current_tenant_id
@model_validate(InstalledAppCreatePayload)
def post(self, req_data: InstalledAppCreatePayload, current_tenant_id: str):
recommended_app = db.session.scalar(
select(RecommendedApp).where(RecommendedApp.app_id == req_data.app_id).limit(1)
)
if recommended_app is None:
raise NotFound("Recommended app not found")
app = db.session.get(App, req_data.app_id)
if app is None:
raise NotFound("App entity not found")
if not app.is_public:
raise Forbidden("You can't install a non-public app")
installed_app = db.session.scalar(
select(InstalledApp)
.where(and_(InstalledApp.app_id == req_data.app_id, InstalledApp.tenant_id == current_tenant_id))
.limit(1)
)
if installed_app is None:
# todo: position
recommended_app.install_count += 1
new_installed_app = InstalledApp(
app_id=req_data.app_id,
tenant_id=current_tenant_id,
app_owner_tenant_id=app.tenant_id,
is_pinned=False,
last_used_at=naive_utc_now(),
)
db.session.add(new_installed_app)
db.session.commit()
return {"message": "App installed successfully"}
@console_ns.route("/installed-apps/<uuid:installed_app_id>")
class InstalledAppApi(InstalledAppResource):
"""
get, update, and delete an installed app
use InstalledAppResource to apply default decorators and get installed_app
"""
class InstalledAppApi(Resource):
"""Read or update an admitted workspace installation."""
@console_ns.response(200, "Success", console_ns.models[InstalledAppResponse.__name__])
@with_current_user
@with_current_tenant_id
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[InstalledAppResponse.__name__])
@console_account_admission()
@get_installed_app
def get(
self,
current_tenant_id: str,
current_user: Account,
installed_app: InstalledApp,
):
app_model = InstalledAppService.get_published_app(installed_app.app_id, session=db.session)
if app_model is None:
raise NotFound("Installed app not found")
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> dict[str, object]:
try:
detail = application_services().installed_apps.management.get_detail(
installed_app=installed_app, account_id=request_context.account_id
)
except InstalledAppUnavailableError as error:
raise InstalledAppUnavailableHTTPError() from error
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
return dump_response(
InstalledAppResponse,
_installed_app_response_data(
installed_app,
app_model,
current_tenant_id=current_tenant_id,
current_user=current_user,
detail.installation,
editable=detail.editable,
),
)
@console_ns.response(204, "App uninstalled successfully")
@with_current_tenant_id
def delete(self, current_tenant_id: str, installed_app: InstalledApp):
if installed_app.app_owner_tenant_id == current_tenant_id:
raise BadRequest("You can't uninstall an app owned by the current tenant")
db.session.delete(installed_app)
db.session.commit()
return "", 204
@console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultMessageResponse.__name__])
@console_ns.expect(console_ns.models[InstalledAppUpdatePayload.__name__])
@console_account_admission()
@get_installed_app
@model_validate(InstalledAppUpdatePayload)
def patch(self, req_data: InstalledAppUpdatePayload, installed_app: InstalledApp):
commit_args = False
if req_data.is_pinned is not None:
installed_app.is_pinned = req_data.is_pinned
commit_args = True
if commit_args:
db.session.commit()
def patch(
self, req_data: InstalledAppUpdatePayload, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, str]:
try:
application_services().installed_apps.management.set_pinned(
installed_app=installed_app, is_pinned=req_data.is_pinned
)
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
return {"result": "success", "message": "App info updated successfully"}

View File

@@ -0,0 +1,57 @@
"""Installed-app admission for handlers using a pure installation reference."""
import logging
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from controllers.console.explore.error import (
AppAccessDeniedError,
InstalledAppNotFoundHTTPError,
WebAppAccessUnavailableHTTPError,
)
from extensions.ext_application_services import application_services
from machinery.context import RequestContext
from services.installed_app_access_service import (
InstalledAppAccessDeniedError,
InstalledAppNotFoundError,
InstalledAppRef,
)
from services.webapp_access_query_service import WebAppAccessUnavailableError
logger = logging.getLogger(__name__)
def get_installed_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, InstalledAppRef, P], R],
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Resolve the route's installation after Console account admission."""
@wraps(view)
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
installed_app_id = kwargs.pop("installed_app_id", None)
if installed_app_id is None:
raise RuntimeError("The installed-app admission route must provide installed_app_id")
try:
installed_app = application_services().installed_apps.access.get_access(
installed_app_id=str(installed_app_id),
tenant_id=request_context.active_workspace_id,
account_id=request_context.account_id,
)
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
except InstalledAppAccessDeniedError as error:
raise AppAccessDeniedError() from error
except WebAppAccessUnavailableError as error:
logger.exception(
"Installed app admission access check failed: installed_app_id=%s workspace_id=%s "
"account_id=%s request_id=%s",
installed_app_id,
request_context.active_workspace_id,
request_context.account_id,
request_context.request_id,
)
raise WebAppAccessUnavailableHTTPError() from error
return view(self, request_context, installed_app, *args, **kwargs)
return decorated

View File

@@ -1,14 +1,20 @@
"""HTTP admission and response contracts for installed-app messages."""
import logging
from collections.abc import Callable
from functools import wraps
from http import HTTPStatus
from typing import Literal
from uuid import UUID
from flask import request
from pydantic import BaseModel, TypeAdapter
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel
from werkzeug.exceptions import HTTPException, InternalServerError, Unauthorized
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
AppMoreLikeThisDisabledError,
AppUnavailableError,
@@ -17,39 +23,40 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import with_session
from controllers.console.explore.error import (
AppSuggestedQuestionsAfterAnswerDisabledError,
ConversationNotFoundHTTPError,
InstalledAppNotFoundHTTPError,
MessageCursorNotFoundHTTPError,
MessageFeedbackRatingRequiredHTTPError,
MessageNotFoundHTTPError,
NotChatAppError,
NotCompletionAppError,
)
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import model_validate, with_current_user
from core.app.entities.app_invoke_entities import InvokeFrom
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from fields.conversation_fields import MessageResponseSource, ResultResponse
from fields.message_fields import (
ExploreMessageInfiniteScrollPagination,
ExploreMessageListItem,
SuggestedQuestionsResponse,
)
from extensions.ext_application_services import application_services
from fields.conversation_fields import ResultResponse
from fields.message_fields import ExploreMessageInfiniteScrollPagination, SuggestedQuestionsResponse
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from models import Account
from models.enums import FeedbackRating
from models.model import AppMode, InstalledApp
from services.app_generate_service import AppGenerateService
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.errors.app import MoreLikeThisDisabledError
from services.errors.base import BaseServiceError
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import (
FirstMessageNotExistsError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_service import MessageService
from .. import console_ns
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_generation_service import InstalledAppNotCompletionError
from services.installed_app_message_service import FeedbackRatingRequiredError, MessageNotChatAppError
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
logger = logging.getLogger(__name__)
@@ -67,176 +74,166 @@ register_response_schema_models(
)
def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return view(*args, **kwargs)
except InstalledAppNotFoundError as error:
raise InstalledAppNotFoundHTTPError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except (AccountNotFoundError, SuggestedQuestionsActorNotFoundError) as error:
raise Unauthorized("Account no longer exists.") from error
except MessageNotChatAppError as error:
raise NotChatAppError() from error
except InstalledAppNotCompletionError as error:
raise NotCompletionAppError() from error
except MessageNotExistsError as error:
raise MessageNotFoundHTTPError() from error
except FirstMessageNotExistsError as error:
raise MessageCursorNotFoundHTTPError() from error
except ConversationNotExistsError as error:
raise ConversationNotFoundHTTPError() from error
except FeedbackRatingRequiredError as error:
raise MessageFeedbackRatingRequiredHTTPError() from error
except MoreLikeThisDisabledError as error:
raise AppMoreLikeThisDisabledError() from error
except SuggestedQuestionsAfterAnswerDisabledError as error:
raise AppSuggestedQuestionsAfterAnswerDisabledError() from error
except ProviderTokenNotInitError as error:
raise ProviderNotInitializeError(error.description) from error
except QuotaExceededError as error:
raise ProviderQuotaExceededError() from error
except ModelCurrentlyNotSupportError as error:
raise ProviderModelCurrentlyNotSupportError() from error
except InvokeError as error:
raise CompletionRequestError(error.description) from error
except (HTTPException, ValueError):
raise
except Exception as error:
logger.exception("Installed-app message operation failed")
raise InternalServerError() from error
return decorated
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/messages",
endpoint="installed_app_messages",
)
class MessageListApi(InstalledAppResource):
class MessageListApi(Resource):
@console_ns.doc(params=query_params_from_model(MessageListQuery))
@console_ns.response(200, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp):
session = db.session()
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
args = MessageListQuery.model_validate(request.args.to_dict())
try:
pagination = MessageService.pagination_by_first_id(
app_model,
current_user,
args.conversation_id,
args.first_id or None,
args.limit,
session=session,
)
adapter = TypeAdapter(ExploreMessageListItem)
items = [
adapter.validate_python(MessageResponseSource(message, session=session), from_attributes=True)
for message in pagination.data
]
return ExploreMessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
).model_dump(mode="json")
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except FirstMessageNotExistsError:
raise NotFound("First Message Not Exists.")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
@console_account_admission()
@get_installed_app
@model_validate(MessageListQuery)
@_message_errors
def get(
self, query: MessageListQuery, request_context: RequestContext, installed_app: InstalledAppRef
) -> dict[str, object]:
page = application_services().installed_apps.messages.get_page(
installed_app=installed_app,
account_id=request_context.account_id,
conversation_id=query.conversation_id,
first_id=query.first_id or None,
limit=query.limit,
)
return helper.dump_response(ExploreMessageInfiniteScrollPagination, page)
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/messages/<uuid:message_id>/feedbacks",
endpoint="installed_app_message_feedback",
)
class MessageFeedbackApi(InstalledAppResource):
class MessageFeedbackApi(Resource):
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
@console_ns.response(200, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__])
@with_current_user
@console_ns.response(HTTPStatus.OK, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__])
@console_account_admission()
@get_installed_app
@model_validate(MessageFeedbackPayload)
@_message_errors
def post(
self, req_data: MessageFeedbackPayload, current_user: Account, installed_app: InstalledApp, message_id: UUID
):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
message_id_str = str(message_id)
try:
MessageService.create_feedback(
app_model=app_model,
message_id=message_id_str,
user=current_user,
rating=FeedbackRating(req_data.rating) if req_data.rating else None,
content=req_data.content,
session=db.session(),
)
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
return ResultResponse(result="success").model_dump(mode="json")
self,
payload: MessageFeedbackPayload,
request_context: RequestContext,
installed_app: InstalledAppRef,
message_id: UUID,
) -> dict[str, object]:
application_services().installed_apps.messages.set_feedback(
installed_app=installed_app,
account_id=request_context.account_id,
message_id=str(message_id),
rating=payload.rating,
content=payload.content,
)
return helper.dump_response(ResultResponse, {"result": "success"})
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/messages/<uuid:message_id>/more-like-this",
endpoint="installed_app_more_like_this",
)
class MessageMoreLikeThisApi(InstalledAppResource):
class MessageMoreLikeThisApi(Resource):
@console_ns.doc(params=query_params_from_model(MoreLikeThisQuery))
@console_ns.response(200, "Success")
@with_current_user
@with_session
def get(self, session: Session, current_user: Account, installed_app: InstalledApp, message_id: UUID):
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
if app_model.mode != "completion":
raise NotCompletionAppError()
message_id_str = str(message_id)
args = MoreLikeThisQuery.model_validate(request.args.to_dict())
streaming = args.response_mode == "streaming"
try:
response = AppGenerateService.generate_more_like_this(
session=session,
app_model=app_model,
user=current_user,
message_id=message_id_str,
invoke_from=InvokeFrom.EXPLORE,
streaming=streaming,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
except MoreLikeThisDisabledError:
raise AppMoreLikeThisDisabledError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@model_validate(MoreLikeThisQuery)
@_message_errors
def get(
self,
query: MoreLikeThisQuery,
request_context: RequestContext,
installed_app: InstalledAppRef,
message_id: UUID,
) -> Response:
response = application_services().installed_apps.generation.generate_more_like_this(
installed_app=installed_app,
account_id=request_context.account_id,
message_id=str(message_id),
streaming=query.response_mode == "streaming",
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="installed_app_suggested_question",
)
class MessageSuggestedQuestionApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp, message_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
class MessageSuggestedQuestionApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@console_account_admission()
@get_installed_app
@_message_errors
def get(
self, request_context: RequestContext, installed_app: InstalledAppRef, message_id: UUID
) -> dict[str, object]:
if installed_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
raise NotChatAppError()
message_id_str = str(message_id)
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model,
user=current_user,
message_id=message_id_str,
invoke_from=InvokeFrom.EXPLORE,
session=db.session(),
questions = application_services().message_suggested_questions.get_suggested_questions(
app_id=installed_app.app_id,
app_owner_tenant_id=installed_app.app_owner_tenant_id,
expected_app_mode=installed_app.app_mode,
actor=SuggestedQuestionsAccount(account_id=request_context.account_id, invoke_from="explore"),
message_id=str(message_id),
)
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
return SuggestedQuestionsResponse(data=questions).model_dump(mode="json")
except (
BaseServiceError,
AppDefinitionUnavailableError,
ProviderTokenNotInitError,
QuotaExceededError,
ModelCurrentlyNotSupportError,
InvokeError,
):
raise
except ValueError as error:
# Legacy model/history configuration failures are server failures,
# not invalid request parameters. Replace this when that runtime
# exposes typed configuration errors.
logger.exception("Suggested-question runtime failed for message %s", message_id)
raise InternalServerError() from error
return helper.dump_response(SuggestedQuestionsResponse, {"data": questions})

View File

@@ -1,16 +1,20 @@
from http import HTTPStatus
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from extensions.ext_application_services import application_services
from libs.helper import dump_response
from models.model import InstalledApp
from machinery.context import RequestContext
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.installed_app_access_service import InstalledAppRef
class ExploreAppMetaResponse(BaseModel):
@@ -26,11 +30,13 @@ register_response_schema_models(console_ns, Parameters, ExploreAppMetaResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/parameters", endpoint="installed_app_parameters")
class AppParameterApi(InstalledAppResource):
class AppParameterApi(Resource):
"""Resource for app variables."""
@console_ns.response(200, "Success", console_ns.models[Parameters.__name__])
def get(self, installed_app: InstalledApp):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[Parameters.__name__])
@console_account_admission()
@get_installed_app
def get(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
"""Retrieve app parameters."""
try:
parameters = application_services().app_definitions.get_parameters(installed_app.app_id)
@@ -41,9 +47,11 @@ class AppParameterApi(InstalledAppResource):
@console_ns.route("/installed-apps/<uuid:installed_app_id>/meta", endpoint="installed_app_meta")
class ExploreAppMetaApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
def get(self, installed_app: InstalledApp):
class ExploreAppMetaApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ExploreAppMetaResponse.__name__])
@console_account_admission()
@get_installed_app
def get(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
"""Get app meta"""
try:
tool_icons = application_services().app_definitions.get_tool_icons(installed_app.app_id)

View File

@@ -1,8 +1,10 @@
from typing import Any
from http import HTTPStatus
from typing import Any, Literal
from uuid import UUID
from flask import make_response, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
@@ -10,7 +12,9 @@ from controllers.console.explore.error import RecommendedAppNotFoundError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.app_export_fields import AppExportResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import build_icon_url, dump_response
from machinery.context import RequestContext
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
@@ -20,6 +24,13 @@ class RecommendedAppsQuery(BaseModel):
language: str = Field(default="en-US", description="Language code for recommended app localization")
class RecommendedAgentExportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID = Field(description="Current published template snapshot ID")
format: Literal["yaml", "ifpkg"] = Field(default="ifpkg", description="Export format; defaults to ifpkg")
class RecommendedAppInfoResponse(ResponseModel):
id: str
name: str | None = None
@@ -77,11 +88,14 @@ class RecommendedAppDetailResponse(ResponseModel):
mode: str
export_data: str
can_trial: bool
package_url: str | None = Field(default=None, description="Download URL for a New Agent .ifpkg template")
version_id: str | None = Field(default=None, description="Published version for direct local template creation")
register_schema_models(
console_ns,
RecommendedAppsQuery,
RecommendedAgentExportQuery,
)
register_response_schema_models(
console_ns,
@@ -90,13 +104,14 @@ register_response_schema_models(
RecommendedAppListResponse,
LearnDifyAppListResponse,
RecommendedAppDetailResponse,
AppExportResponse,
)
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[RecommendedAppListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[RecommendedAppListResponse.__name__])
@console_account_admission()
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, _request_context: RequestContext):
@@ -111,7 +126,7 @@ class RecommendedAppListApi(Resource):
@console_ns.route("/explore/apps/learn-dify")
class LearnDifyAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@console_account_admission()
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, _request_context: RequestContext):
@@ -125,8 +140,8 @@ class LearnDifyAppListApi(Resource):
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
@console_ns.response(404, "Recommended app not found")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
@console_ns.response(HTTPStatus.NOT_FOUND, "Recommended app not found")
@console_account_admission()
def get(self, _request_context: RequestContext, app_id: UUID):
try:
@@ -134,3 +149,37 @@ class RecommendedAppApi(Resource):
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
return dump_response(RecommendedAppDetailResponse, result)
@console_ns.route("/trial-apps/<uuid:app_id>/export")
class RecommendedAgentExportApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAgentExportQuery))
@console_ns.doc(
produces=["application/json", "application/zip"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]},
)
@console_ns.response(200, "Published Agent template export", console_ns.models[AppExportResponse.__name__])
@console_ns.response(404, "Published template unavailable")
@model_validate(RecommendedAgentExportQuery)
def get(self, query: RecommendedAgentExportQuery, app_id: UUID):
# Both formats require public catalog membership and the current published version.
try:
exported = application_services().recommended_app_packages.export(
app_id=str(app_id), version_id=query.version_id, format=query.format
)
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
if isinstance(exported, str):
response = make_response(dump_response(AppExportResponse, {"data": exported}))
response.headers["Cache-Control"] = "no-store"
return response
try:
response = send_file(
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
)
except Exception:
exported.close()
raise
response.headers["Cache-Control"] = "no-store"
response.call_on_close(exported.close)
return response

View File

@@ -1,72 +1,76 @@
from http import HTTPStatus
from uuid import UUID
from pydantic import TypeAdapter
from flask_restx import Resource
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.error import NotCompletionAppError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import model_validate, with_current_user
from extensions.ext_database import db
from fields.conversation_fields import MessageResponseSource, ResultResponse
from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem
from models import Account
from models.model import InstalledApp
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.conversation_fields import ResultResponse
from fields.message_fields import SavedMessageInfiniteScrollPagination
from libs.helper import dump_response
from machinery.context import RequestContext
from services.errors.message import MessageNotExistsError
from services.saved_message_service import SavedMessageService
from services.installed_app_access_service import InstalledAppRef
from services.saved_message_service import SavedMessageActor
register_schema_models(console_ns, SavedMessageListQuery, SavedMessageCreatePayload)
register_response_schema_models(console_ns, ResultResponse, SavedMessageInfiniteScrollPagination)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/saved-messages", endpoint="installed_app_saved_messages")
class SavedMessageListApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(SavedMessageListQuery))
@console_ns.response(200, "Success", console_ns.models[SavedMessageInfiniteScrollPagination.__name__])
@with_current_user
@model_validate(SavedMessageListQuery)
def get(self, req_data: SavedMessageListQuery, current_user: Account, installed_app: InstalledApp):
session = db.session()
app_model = installed_app.app_with_session(session=session)
if app_model is None:
raise AppUnavailableError()
if app_model.mode != "completion":
raise NotCompletionAppError()
def _require_completion_app(installed_app: InstalledAppRef) -> str:
if installed_app.app_mode != "completion":
raise NotCompletionAppError()
return installed_app.app_id
pagination = SavedMessageService.pagination_by_last_id(
app_model,
current_user,
str(req_data.last_id) if req_data.last_id else None,
req_data.limit,
session=session,
@console_ns.route("/installed-apps/<uuid:installed_app_id>/saved-messages", endpoint="installed_app_saved_messages")
class SavedMessageListApi(Resource):
@console_ns.doc(params=query_params_from_model(SavedMessageListQuery))
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SavedMessageInfiniteScrollPagination.__name__])
@console_account_admission()
@get_installed_app
@model_validate(SavedMessageListQuery)
def get(
self,
req_data: SavedMessageListQuery,
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> dict[str, object]:
app_id = _require_completion_app(installed_app)
pagination = application_services().saved_messages.pagination_by_last_id(
app_id=app_id,
actor=SavedMessageActor.account(request_context.account_id),
last_id=str(req_data.last_id) if req_data.last_id else None,
limit=req_data.limit,
)
adapter = TypeAdapter(SavedMessageItem)
items = [
adapter.validate_python(MessageResponseSource(message, session=session), from_attributes=True)
for message in pagination.data
]
return SavedMessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
).model_dump(mode="json")
return dump_response(SavedMessageInfiniteScrollPagination, pagination)
@console_ns.expect(console_ns.models[SavedMessageCreatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__])
@with_current_user
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ResultResponse.__name__])
@console_account_admission()
@get_installed_app
@model_validate(SavedMessageCreatePayload)
def post(self, req_data: SavedMessageCreatePayload, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
if app_model.mode != "completion":
raise NotCompletionAppError()
def post(
self,
req_data: SavedMessageCreatePayload,
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> dict[str, object]:
app_id = _require_completion_app(installed_app)
try:
SavedMessageService.save(app_model, current_user, str(req_data.message_id), session=db.session())
application_services().saved_messages.save(
app_id=app_id,
actor=SavedMessageActor.account(request_context.account_id),
message_id=str(req_data.message_id),
)
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
@@ -76,19 +80,21 @@ class SavedMessageListApi(InstalledAppResource):
@console_ns.route(
"/installed-apps/<uuid:installed_app_id>/saved-messages/<uuid:message_id>", endpoint="installed_app_saved_message"
)
class SavedMessageApi(InstalledAppResource):
@console_ns.response(204, "Saved message deleted successfully")
@with_current_user
def delete(self, current_user: Account, installed_app: InstalledApp, message_id: UUID):
app_model = installed_app.app_with_session(session=db.session())
if app_model is None:
raise AppUnavailableError()
class SavedMessageApi(Resource):
@console_ns.response(HTTPStatus.NO_CONTENT, "Saved message deleted successfully")
@console_account_admission()
@get_installed_app
def delete(
self,
request_context: RequestContext,
installed_app: InstalledAppRef,
message_id: UUID,
) -> tuple[str, int]:
app_id = _require_completion_app(installed_app)
application_services().saved_messages.delete(
app_id=app_id,
actor=SavedMessageActor.account(request_context.account_id),
message_id=str(message_id),
)
message_id_str = str(message_id)
if app_model.mode != "completion":
raise NotCompletionAppError()
SavedMessageService.delete(app_model, current_user, message_id_str, session=db.session())
return "", 204
return "", HTTPStatus.NO_CONTENT

View File

@@ -1,16 +1,20 @@
import logging
from dataclasses import dataclass
from collections.abc import Callable, Mapping
from dataclasses import asdict
from datetime import datetime
from functools import wraps
from http import HTTPStatus
from typing import Any, Literal
from uuid import UUID
from flask import request
from flask import Response, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
import services
from configs import dify_config
from controllers.common.audio_response import audio_binary_response
from controllers.common.fields import (
AudioBinaryResponse,
AudioTranscriptResponse,
@@ -19,12 +23,14 @@ from controllers.common.fields import (
from controllers.common.fields import Parameters as ParametersResponse
from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import (
JsonResponseWithStatus,
query_params_from_model,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.error import (
AppNotFoundError,
AppUnavailableError,
AudioTooLargeError,
CompletionRequestError,
@@ -38,21 +44,29 @@ from controllers.console.app.error import (
SpeechToTextDisabledError,
UnsupportedAudioTypeError,
)
from controllers.console.app.wraps import get_previewable_app_model, with_session
from controllers.console.app.preview_admission import get_preview_app
from controllers.console.explore.error import (
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
)
from controllers.console.explore.error import (
AppPreviewSiteUnavailableError as AppPreviewSiteUnavailableHttpError,
)
from controllers.console.explore.error import (
AppSuggestedQuestionsAfterAnswerDisabledError,
NotChatAppError,
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.wraps import TrialAppResource
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request
from controllers.console.explore.trial_app_admission import get_trial_app, get_trial_app_for_stop
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request_context
from controllers.console.flask_admission import console_account_admission
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate, with_current_user
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
AppInvokeQuotaExceededError,
ModelCurrentlyNotSupportError,
ProviderTokenNotInitError,
QuotaExceededError,
@@ -60,28 +74,29 @@ from core.errors.error import (
from core.helper import encrypter
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
from fields.file_fields import FileResponse, FileWithSignedUrl
from fields.message_fields import SuggestedQuestionsResponse
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.errors.invoke import InvokeError
from graphon.variables import SecretVariable, VariableBase
from libs import helper
from libs.helper import dump_response, to_timestamp, uuid_value
from models import Account, App
from models.account import TenantStatus
from models.model import AppMode, Site
from models.workflow import Workflow
from services.account_service import TenantService
from libs.stream import close_stream
from libs.url_utils import normalize_api_base_url
from machinery.context import RequestContext
from models.enums import CreatorUserRole
from models.model import AppMode
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_generate_service import AppGenerateService
from services.app_ref_service import AppRefService
from services.app_service import AppResponseView, AppService
from services.audio_service import AudioService
from services.dataset_service import DatasetService
from services.app_preview_query_service import (
AppPreviewOwnerUnavailableError,
AppPreviewRef,
AppPreviewSiteUnavailableError,
AppPreviewUnavailableError,
)
from services.audio_types import AudioAppRef, AudioUpload
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
@@ -95,7 +110,14 @@ from services.errors.message import (
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_service import MessageService
from services.file_service import FileUploadActor
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
from services.trial_app_access_service import TrialAppRef
from services.trial_app_generation_service import (
TrialAppNotChatError,
TrialAppNotCompletionError,
TrialAppNotWorkflowError,
)
logger = logging.getLogger(__name__)
@@ -135,7 +157,7 @@ class TrialDatasetListQuery(BaseModel):
ids: list[str] = Field(default_factory=list, description="Dataset IDs")
type TrialAppMode = Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
type TrialAppMode = Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
type TrialIconType = Literal["emoji", "image", "link"]
type JsonObject = dict[str, Any]
@@ -395,7 +417,12 @@ class TrialWorkflowResponse(ResponseModel):
result: list[Any] = []
for item in value:
if isinstance(item, SecretVariable):
if isinstance(item, Mapping):
serialized = dict(item)
if serialized.get("value_type") == "secret":
serialized["value"] = encrypter.full_mask_token()
result.append(serialized)
elif isinstance(item, SecretVariable):
serialized = item.model_dump(mode="json")
serialized["value"] = encrypter.full_mask_token()
result.append(serialized)
@@ -408,27 +435,6 @@ class TrialWorkflowResponse(ResponseModel):
return result
@dataclass(frozen=True)
class TrialWorkflowResponseSource:
workflow: Workflow
session: Session
@property
def created_by_account(self) -> Account | None:
return self.workflow.get_created_by_account(session=self.session)
@property
def updated_by_account(self) -> Account | None:
return self.workflow.get_updated_by_account(session=self.session)
@property
def tool_published(self) -> bool:
return self.workflow.get_tool_published(session=self.session)
def __getattr__(self, name: str) -> Any:
return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields
register_schema_models(
console_ns,
WorkflowRunRequest,
@@ -446,6 +452,7 @@ register_response_schema_models(
SiteResponse,
SuggestedQuestionsResponse,
TrialAppDetailResponse,
AgentAppComposerResponse,
TrialDatasetListResponse,
TrialWorkflowResponse,
)
@@ -453,68 +460,80 @@ register_response_schema_models(
simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
class TrialAppFileUploadApi(TrialAppResource):
@cloud_edition_billing_resource_check("documents")
@console_ns.route(
"/trial-apps/<uuid:app_id>/files/upload",
endpoint="trial_app_file_upload",
)
class TrialAppFileUploadApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Upload a file into the tenant that owns the trial app."""
upload_file = upload_file_from_request(
current_user=current_user,
resource_tenant_id=app_model.tenant_id,
)
return dump_response(FileResponse, upload_file), 201
class TrialAppRemoteFileUploadApi(TrialAppResource):
@console_ns.response(HTTPStatus.CREATED, "File uploaded successfully", console_ns.models[FileResponse.__name__])
@console_account_admission()
@get_trial_app
@cloud_edition_billing_resource_check("documents")
def post(self, request_context: RequestContext, trial_app: TrialAppRef) -> JsonResponseWithStatus:
"""Upload a file into the tenant that owns the trial app."""
upload_file = upload_file_from_request_context(
request_context=request_context,
resource_tenant_id=trial_app.tenant_id,
)
return dump_response(FileResponse, upload_file), HTTPStatus.CREATED
@console_ns.route(
"/trial-apps/<uuid:app_id>/remote-files/upload",
endpoint="trial_app_remote_file_upload",
)
class TrialAppRemoteFileUploadApi(Resource):
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
@with_current_user
@console_ns.response(
HTTPStatus.CREATED, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__]
)
@console_account_admission()
@get_trial_app
@cloud_edition_billing_resource_check("documents")
@model_validate(RemoteFileUploadPayload)
def post(self, payload: RemoteFileUploadPayload, current_user: Account, app_model: App):
def post(
self, payload: RemoteFileUploadPayload, request_context: RequestContext, trial_app: TrialAppRef
) -> JsonResponseWithStatus:
"""Upload a remote file into the tenant that owns the trial app."""
remote_file = upload_remote_file(
url=payload.url,
current_user=current_user,
resource_tenant_id=app_model.tenant_id,
current_user=FileUploadActor(id=request_context.account_id, creator_role=CreatorUserRole.ACCOUNT),
resource_tenant_id=trial_app.tenant_id,
)
return dump_response(FileWithSignedUrl, remote_file), 201
return dump_response(FileWithSignedUrl, remote_file), HTTPStatus.CREATED
class TrialAppWorkflowRunApi(TrialAppResource):
@console_ns.route(
"/trial-apps/<uuid:app_id>/workflows/run",
endpoint="trial_app_workflow_run",
)
class TrialAppWorkflowRunApi(Resource):
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_trial_app
@model_validate(WorkflowRunRequest)
def post(self, req_data: WorkflowRunRequest, session: Session, current_user: Account, trial_app):
def post(self, req_data: WorkflowRunRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
"""
Run workflow
"""
app_model = trial_app
if not app_model:
raise NotWorkflowAppError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
args = req_data.model_dump()
try:
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
response = application_services().trial_apps.generation.generate_workflow(
trial_app=trial_app,
account_id=request_context.account_id,
args=req_data.model_dump(),
)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except TrialAppNotWorkflowError as error:
raise NotWorkflowAppError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except services.errors.app_model_config.AppModelConfigBrokenError as error:
raise AppUnavailableError() from error
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -523,7 +542,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except InvokeRateLimitError as ex:
except (AppInvokeQuotaExceededError, InvokeRateLimitError) as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
@@ -532,41 +551,34 @@ class TrialAppWorkflowRunApi(TrialAppResource):
raise InternalServerError()
class TrialAppWorkflowTaskStopApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, trial_app, task_id: str):
@console_ns.route("/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
class TrialAppWorkflowTaskStopApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_trial_app
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str) -> dict[str, object]:
"""
Stop workflow task
"""
app_model = trial_app
if not app_model:
raise NotWorkflowAppError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
if trial_app.app_mode != "workflow":
raise NotWorkflowAppError()
# Stop using both mechanisms for backward compatibility
# Legacy stop flag mechanism (without user check)
AppQueueManager.set_stop_flag_no_user_check(task_id)
application_services().app_tasks.stop_workflow_task_no_user_check(task_id=task_id)
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
return {"result": "success"}
return dump_response(SimpleResultResponse, {"result": "success"})
class TrialChatApi(TrialAppResource):
@console_ns.route(
"/trial-apps/<uuid:app_id>/chat-messages",
endpoint="trial_app_chat_completion",
)
class TrialChatApi(Resource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_trial_app
@model_validate(ChatRequest)
def post(self, req_data: ChatRequest, session: Session, current_user: Account, trial_app):
app_model = trial_app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
def post(self, req_data: ChatRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
args = req_data.model_dump()
# Validate UUID values if provided
@@ -575,24 +587,20 @@ class TrialChatApi(TrialAppResource):
if args.get("parent_message_id"):
args["parent_message_id"] = uuid_value(args["parent_message_id"])
args["auto_generate_name"] = False
try:
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
response = application_services().trial_apps.generation.generate_chat(
trial_app=trial_app,
account_id=request_context.account_id,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except TrialAppNotChatError as error:
raise NotChatAppError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
@@ -600,6 +608,8 @@ class TrialChatApi(TrialAppResource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except (AgentAppGeneratorError, AgentAppNotPublishedError):
raise AppUnavailableError() from None
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -608,7 +618,7 @@ class TrialChatApi(TrialAppResource):
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except InvokeRateLimitError as ex:
except (AppInvokeQuotaExceededError, InvokeRateLimitError) as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
@@ -617,25 +627,51 @@ class TrialChatApi(TrialAppResource):
raise InternalServerError()
class TrialMessageSuggestedQuestionApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@with_current_user
def get(self, current_user: Account, trial_app, message_id):
app_model = trial_app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@console_ns.route(
"/trial-apps/<uuid:app_id>/chat-messages/<string:task_id>/stop",
endpoint="trial_app_stop_chat_completion",
)
class TrialChatTaskStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_trial_app_for_stop
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str):
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
message_id = str(message_id)
application_services().app_tasks.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=request_context.account_id,
app_mode=AppMode.value_of(trial_app.app_mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route(
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="trial_app_suggested_question",
)
class TrialMessageSuggestedQuestionApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SuggestedQuestionsResponse.__name__])
@console_account_admission()
@get_trial_app
def get(self, request_context: RequestContext, trial_app: TrialAppRef, message_id: UUID) -> dict[str, object]:
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model,
user=current_user,
message_id=message_id,
invoke_from=InvokeFrom.EXPLORE,
session=db.session(),
questions = application_services().message_suggested_questions.get_suggested_questions(
app_id=trial_app.app_id,
app_owner_tenant_id=trial_app.tenant_id,
expected_app_mode=trial_app.app_mode,
actor=SuggestedQuestionsAccount(account_id=request_context.account_id, invoke_from="explore"),
message_id=str(message_id),
)
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except SuggestedQuestionsActorNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
@@ -654,150 +690,124 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource):
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
return dump_response(SuggestedQuestionsResponse, {"data": questions})
class TrialChatAudioApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@with_current_user
def post(self, current_user: Account, trial_app):
app_model = trial_app
def _trial_audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return view(*args, **kwargs)
except (AppDefinitionUnavailableError, services.errors.app_model_config.AppModelConfigBrokenError) as error:
raise AppUnavailableError() from error
except NoAudioUploadedServiceError as error:
raise NoAudioUploadedError() from error
except AudioTooLargeServiceError as error:
raise AudioTooLargeError(str(error)) from error
except UnsupportedAudioTypeServiceError as error:
raise UnsupportedAudioTypeError() from error
except ProviderNotSupportSpeechToTextServiceError as error:
raise ProviderNotSupportSpeechToTextError() from error
except SpeechToTextDisabledServiceError as error:
raise SpeechToTextDisabledError() from error
except ProviderTokenNotInitError as error:
raise ProviderNotInitializeError(error.description) from error
except QuotaExceededError as error:
raise ProviderQuotaExceededError() from error
except ModelCurrentlyNotSupportError as error:
raise ProviderModelCurrentlyNotSupportError() from error
except InvokeError as error:
raise CompletionRequestError(error.description) from error
except (HTTPException, ValueError):
raise
except Exception as error:
logger.exception("Trial audio operation failed")
raise InternalServerError() from error
return decorated
@console_ns.route(
"/trial-apps/<uuid:app_id>/audio-to-text",
endpoint="trial_app_audio",
)
class TrialChatAudioApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@console_account_admission()
@get_trial_app
@_trial_audio_errors
def post(self, request_context: RequestContext, trial_app: TrialAppRef) -> dict[str, object]:
file = request.files.get("file")
try:
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AudioService.transcript_asr(
app_model=app_model,
file=file,
session=db.session(),
end_user=None,
)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except NoAudioUploadedServiceError:
raise NoAudioUploadedError()
except AudioTooLargeServiceError as e:
raise AudioTooLargeError(str(e))
except UnsupportedAudioTypeServiceError:
raise UnsupportedAudioTypeError()
except ProviderNotSupportSpeechToTextServiceError:
raise ProviderNotSupportSpeechToTextError()
except SpeechToTextDisabledServiceError:
raise SpeechToTextDisabledError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
audio = AudioUpload(stream=file.stream, mime_type=file.mimetype) if file is not None else None
transcript = application_services().app_audio.transcript_asr(
app=AudioAppRef(app_id=trial_app.app_id, tenant_id=trial_app.tenant_id, app_mode=trial_app.app_mode),
audio=audio,
)
application_services().trial_apps.usage.record(app_id=trial_app.app_id, account_id=request_context.account_id)
return dump_response(AudioTranscriptResponse, transcript)
class TrialChatTextApi(TrialAppResource):
@console_ns.route(
"/trial-apps/<uuid:app_id>/text-to-audio",
endpoint="trial_app_text",
)
class TrialChatTextApi(Resource):
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@with_current_user
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioBinaryResponse.__name__])
@console_account_admission()
@get_trial_app
@model_validate(TextToSpeechRequest)
def post(self, req_data: TextToSpeechRequest, current_user: Account, trial_app):
app_model = trial_app
@_trial_audio_errors
def post(
self, req_data: TextToSpeechRequest, request_context: RequestContext, trial_app: TrialAppRef
) -> Response | None:
output = application_services().app_audio.transcript_tts(
app=AudioAppRef(app_id=trial_app.app_id, tenant_id=trial_app.tenant_id, app_mode=trial_app.app_mode),
account_id=request_context.account_id,
text=req_data.text,
voice=req_data.voice,
message_id=req_data.message_id,
)
response = audio_binary_response(output)
try:
message_id = req_data.message_id
text = req_data.text
voice = req_data.voice
message_ref = None
if message_id:
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
message_id,
account_id=current_user.id,
)
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session(),
text=text,
voice=voice,
message_ref=message_ref,
# Preserve usage after MIME inspection, including a missing message's
# null response. Early provider/MIME failures do not consume a trial.
application_services().trial_apps.usage.record(
app_id=trial_app.app_id, account_id=request_context.account_id
)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except NoAudioUploadedServiceError:
raise NoAudioUploadedError()
except AudioTooLargeServiceError as e:
raise AudioTooLargeError(str(e))
except UnsupportedAudioTypeServiceError:
raise UnsupportedAudioTypeError()
except ProviderNotSupportSpeechToTextServiceError:
raise ProviderNotSupportSpeechToTextError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
except BaseException:
if response is not None:
close_stream(response)
raise
# response-contract:ignore audio_binary_response
return response
class TrialCompletionApi(TrialAppResource):
@console_ns.route(
"/trial-apps/<uuid:app_id>/completion-messages",
endpoint="trial_app_completion",
)
class TrialCompletionApi(Resource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_trial_app
@model_validate(CompletionRequest)
def post(self, req_data: CompletionRequest, session: Session, current_user: Account, trial_app):
app_model = trial_app
if app_model.mode != "completion":
raise NotCompletionAppError()
args = req_data.model_dump()
streaming = args["response_mode"] == "streaming"
args["auto_generate_name"] = False
def post(self, req_data: CompletionRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
try:
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=streaming,
response = application_services().trial_apps.generation.generate_completion(
trial_app=trial_app,
account_id=request_context.account_id,
args=req_data.model_dump(),
)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except TrialAppNotCompletionError as error:
raise NotCompletionAppError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
@@ -813,6 +823,8 @@ class TrialCompletionApi(TrialAppResource):
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except (AppInvokeQuotaExceededError, InvokeRateLimitError) as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
except Exception:
@@ -820,145 +832,134 @@ class TrialCompletionApi(TrialAppResource):
raise InternalServerError()
@console_ns.route("/trial-apps/<uuid:app_id>/site")
class TrialSitApi(Resource):
"""Resource for trial app sites."""
@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
@with_session(write=False)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SiteResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
"""Retrieve app site info.
Returns the site configuration for the application including theme, icons, and text.
"""
site = session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
if not site:
raise Forbidden()
tenant = TenantService.get_tenant_by_id(app_model.tenant_id, session=session)
assert tenant
if tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
return SiteResponse.model_validate(site).model_dump(mode="json")
try:
site = application_services().app_previews.get_site(app=app)
except AppPreviewSiteUnavailableError as error:
raise AppPreviewSiteUnavailableHttpError(str(error)) from error
except AppPreviewOwnerUnavailableError as error:
raise AppPreviewOwnerUnavailableHttpError(str(error)) from error
return dump_response(SiteResponse, site)
@console_ns.route(
"/trial-apps/<uuid:app_id>/parameters",
endpoint="trial_app_parameters",
)
class TrialAppParameterApi(Resource):
"""Resource for app variables."""
@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
@with_session(write=False)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ParametersResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
"""Retrieve app parameters."""
if app_model is None:
raise AppUnavailableError()
try:
parameters = application_services().app_definitions.get_parameters(app_model.id)
parameters = application_services().app_definitions.get_public_parameters(app.app_id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
return dump_response(ParametersResponse, parameters)
@console_ns.route("/trial-apps/<uuid:app_id>", endpoint="trial_app")
class AppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
@with_session(write=False)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Get app detail"""
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialAppDetailResponse.__name__])
@console_account_admission()
@get_preview_app
def get(self, app: AppPreviewRef, request_context: RequestContext) -> dict[str, object]:
"""Get app detail using the viewer's workspace for tool configuration."""
try:
detail = application_services().app_preview_details.get_detail(
app=app,
account_id=request_context.account_id,
active_workspace_id=request_context.active_workspace_id,
)
except AppPreviewUnavailableError as error:
raise AppNotFoundError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except AppPreviewSiteUnavailableError as error:
raise AppPreviewSiteUnavailableHttpError(str(error)) from error
app_service = AppService()
app_model = app_service.get_app(app_model, session=session)
return TrialAppDetailResponse.model_validate(
AppResponseView(app_model, session=session),
from_attributes=True,
).model_dump(mode="json")
source = asdict(detail)
source["api_base_url"] = normalize_api_base_url(dify_config.SERVICE_API_URL or request.host_url.rstrip("/"))
source["site"] = {
**asdict(detail.site),
"app_base_url": dify_config.APP_WEB_URL or request.url_root.rstrip("/"),
}
return dump_response(TrialAppDetailResponse, source)
@console_ns.route("/trial-apps/<uuid:app_id>/agent-composer")
class TrialAgentComposerApi(Resource):
@console_ns.response(200, "Published Agent configuration", console_ns.models[AgentAppComposerResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef):
try:
preview = application_services().app_previews.get_agent_composer(app=app)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
return dump_response(AgentAppComposerResponse, preview)
@console_ns.route(
"/trial-apps/<uuid:app_id>/workflows",
endpoint="trial_app_workflow",
)
class AppWorkflowApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
@with_session(write=False)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Get workflow detail"""
if not app_model.workflow_id:
raise AppUnavailableError()
workflow = app_model.workflow_with_session(session=session)
if workflow is None:
raise AppUnavailableError()
return TrialWorkflowResponse.model_validate(
TrialWorkflowResponseSource(workflow=workflow, session=session),
from_attributes=True,
).model_dump(mode="json")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialWorkflowResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
"""Get a detached workflow definition after catalog preview admission."""
try:
workflow = application_services().app_preview_details.get_workflow(app=app)
except AppPreviewUnavailableError as error:
raise AppNotFoundError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
return dump_response(TrialWorkflowResponse, workflow)
@console_ns.route(
"/trial-apps/<uuid:app_id>/datasets",
endpoint="trial_app_datasets",
)
class DatasetListApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
@console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__])
@with_session(write=False)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TrialDatasetListResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
# These legacy fields are response metadata: the query returns all
# requested IDs without pagination. Keep their integer fallback and echo behavior.
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
ids = request.args.getlist("ids")
tenant_id = app_model.tenant_id
if ids:
datasets, total = DatasetService.get_datasets_by_ids(ids, tenant_id, session=session)
else:
if not ids:
raise NeedAddIdsError()
try:
datasets = application_services().app_previews.get_datasets(app=app, ids=ids)
except AppPreviewUnavailableError as error:
raise AppNotFoundError() from error
# `get_datasets_by_ids` resolves the ids it was handed in a single page
# (`per_page=len(ids)`), so `limit` never bounded this result and there is
# never a next page to ask for.
response = {"data": datasets, "has_more": False, "limit": limit, "total": total, "page": page}
response = {
"data": datasets,
"has_more": False,
"limit": limit,
"total": len(datasets),
"page": page,
}
return dump_response(TrialDatasetListResponse, response)
console_ns.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
console_ns.add_resource(
TrialAppFileUploadApi,
"/trial-apps/<uuid:app_id>/files/upload",
endpoint="trial_app_file_upload",
)
console_ns.add_resource(
TrialAppRemoteFileUploadApi,
"/trial-apps/<uuid:app_id>/remote-files/upload",
endpoint="trial_app_remote_file_upload",
)
console_ns.add_resource(
TrialMessageSuggestedQuestionApi,
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="trial_app_suggested_question",
)
console_ns.add_resource(TrialChatAudioApi, "/trial-apps/<uuid:app_id>/audio-to-text", endpoint="trial_app_audio")
console_ns.add_resource(TrialChatTextApi, "/trial-apps/<uuid:app_id>/text-to-audio", endpoint="trial_app_text")
console_ns.add_resource(
TrialCompletionApi, "/trial-apps/<uuid:app_id>/completion-messages", endpoint="trial_app_completion"
)
console_ns.add_resource(TrialSitApi, "/trial-apps/<uuid:app_id>/site")
console_ns.add_resource(TrialAppParameterApi, "/trial-apps/<uuid:app_id>/parameters", endpoint="trial_app_parameters")
console_ns.add_resource(AppApi, "/trial-apps/<uuid:app_id>", endpoint="trial_app")
console_ns.add_resource(
TrialAppWorkflowRunApi, "/trial-apps/<uuid:app_id>/workflows/run", endpoint="trial_app_workflow_run"
)
console_ns.add_resource(TrialAppWorkflowTaskStopApi, "/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
console_ns.add_resource(AppWorkflowApi, "/trial-apps/<uuid:app_id>/workflows", endpoint="trial_app_workflow")
console_ns.add_resource(DatasetListApi, "/trial-apps/<uuid:app_id>/datasets", endpoint="trial_app_datasets")

View File

@@ -0,0 +1,58 @@
"""Trial execution admission for handlers using a detached app reference."""
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from controllers.console.explore.error import TrialAppFeatureDisabledError, TrialAppLimitExceeded, TrialAppNotAllowed
from extensions.ext_application_services import application_services
from machinery.context import RequestContext
from services.trial_app_access_service import (
TrialAppRef,
TrialAppUnavailableError,
TrialAppUsageLimitExceededError,
)
def trial_feature_enable[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Gate trial execution before resolving app membership or account usage."""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if not application_services().recommended_app_queries.is_trial_enabled():
raise TrialAppFeatureDisabledError()
return view(*args, **kwargs)
return decorated
def get_trial_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
*,
check_usage: bool = True,
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Admit the trial app, optionally skipping generation quota for task cancellation."""
@wraps(view)
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
app_id = kwargs.pop("app_id", None)
if app_id is None:
raise RuntimeError("The trial app admission route must provide app_id")
try:
trial_app = application_services().trial_apps.access.get_access(
app_id=str(app_id), account_id=request_context.account_id, check_usage=check_usage
)
except TrialAppUnavailableError as error:
raise TrialAppNotAllowed() from error
except TrialAppUsageLimitExceededError as error:
raise TrialAppLimitExceeded() from error
return view(self, request_context, trial_app, *args, **kwargs)
return trial_feature_enable(decorated)
def get_trial_app_for_stop[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Keep trial access checks without requiring remaining generation quota."""
return get_trial_app(view, check_usage=False)

View File

@@ -1,7 +1,9 @@
import logging
from http import HTTPStatus
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError
from flask import Response
from flask_restx import Resource
from werkzeug.exceptions import InternalServerError, Unauthorized
from controllers.common.controller_schemas import WorkflowRunPayload
from controllers.common.fields import SimpleResultResponse
@@ -12,26 +14,25 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import with_session
from controllers.console.explore.error import NotWorkflowAppError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import model_validate, with_current_user
from controllers.console.explore.installed_app_admission import get_installed_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
ProviderTokenNotInitError,
QuotaExceededError,
)
from extensions.ext_redis import redis_client
from graphon.graph_engine.manager import GraphEngineManager
from extensions.ext_application_services import application_services
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from models import Account
from models.model import AppMode, InstalledApp
from services.app_generate_service import AppGenerateService
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.errors.llm import InvokeRateLimitError
from services.installed_app_access_service import InstalledAppRef
from services.installed_app_generation_service import InstalledAppNotWorkflowError
from .. import console_ns
@@ -42,42 +43,34 @@ register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/run")
class InstalledAppWorkflowRunApi(InstalledAppResource):
class InstalledAppWorkflowRunApi(Resource):
@console_ns.expect(console_ns.models[WorkflowRunPayload.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_ns.response(HTTPStatus.OK, "Success")
@console_account_admission()
@get_installed_app
@model_validate(WorkflowRunPayload)
def post(
self,
req_data: WorkflowRunPayload,
session: Session,
current_user: Account,
installed_app: InstalledApp,
):
request_context: RequestContext,
installed_app: InstalledAppRef,
) -> Response:
"""
Run workflow
"""
app_model = installed_app.app_with_session(session=session)
if not app_model:
raise NotWorkflowAppError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
args = req_data.model_dump(exclude_none=True)
try:
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
response = application_services().installed_apps.generation.generate_workflow(
installed_app=installed_app,
account_id=request_context.account_id,
args=req_data.model_dump(exclude_none=True),
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except (AppDefinitionUnavailableError, InstalledAppNotWorkflowError):
raise NotWorkflowAppError() from None
except AccountNotFoundError:
raise Unauthorized("Account no longer exists.") from None
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -96,25 +89,17 @@ class InstalledAppWorkflowRunApi(InstalledAppResource):
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/tasks/<string:task_id>/stop")
class InstalledAppWorkflowTaskStopApi(InstalledAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_session(write=False)
def post(self, session: Session, installed_app: InstalledApp, task_id: str):
class InstalledAppWorkflowTaskStopApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_installed_app
def post(self, request_context: RequestContext, installed_app: InstalledAppRef, task_id: str) -> dict[str, object]:
"""
Stop workflow task
"""
app_model = installed_app.app_with_session(session=session)
if not app_model:
raise NotWorkflowAppError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
if installed_app.app_mode != "workflow":
raise NotWorkflowAppError()
# Stop using both mechanisms for backward compatibility
# Legacy stop flag mechanism (without user check)
AppQueueManager.set_stop_flag_no_user_check(task_id)
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
application_services().app_tasks.stop_workflow_task_no_user_check(task_id=task_id)
return SimpleResultResponse(result="success").model_dump(mode="json")

View File

@@ -1,139 +0,0 @@
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from flask_restx import Resource
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from controllers.console.explore.error import (
AppAccessDeniedError,
TrialAppFeatureDisabledError,
TrialAppLimitExceeded,
TrialAppNotAllowed,
)
from controllers.console.wraps import account_initialization_required
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
from services.enterprise.enterprise_service import EnterpriseService
from services.system_feature_service import SystemFeatureService
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
def decorator(view: Callable[Concatenate[InstalledApp, P], R]):
@wraps(view)
def decorated(installed_app_id: str, *args: P.args, **kwargs: P.kwargs):
_, current_tenant_id = current_account_with_tenant()
installed_app = db.session.scalar(
select(InstalledApp)
.where(InstalledApp.id == str(installed_app_id), InstalledApp.tenant_id == current_tenant_id)
.limit(1)
)
if installed_app is None:
raise NotFound("Installed app not found")
if not installed_app.app_with_session(session=db.session()):
db.session.delete(installed_app)
db.session.commit()
raise NotFound("Installed app not found")
return view(installed_app, *args, **kwargs)
return decorated
if view:
return decorator(view)
return decorator
def user_allowed_to_access_app[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
def decorator(view: Callable[Concatenate[InstalledApp, P], R]):
@wraps(view)
def decorated(installed_app: InstalledApp, *args: P.args, **kwargs: P.kwargs):
current_user, _ = current_account_with_tenant()
if SystemFeatureService.is_webapp_auth_enabled():
app_id = installed_app.app_id
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
user_id=str(current_user.id),
app_id=app_id,
)
if not res:
raise AppAccessDeniedError()
return view(installed_app, *args, **kwargs)
return decorated
if view:
return decorator(view)
return decorator
def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = None):
def decorator(view: Callable[Concatenate[App, P], R]):
@wraps(view)
def decorated(app_id: str, *args: P.args, **kwargs: P.kwargs):
current_user, _ = current_account_with_tenant()
session = db.session()
trial_app = session.scalar(select(TrialApp).where(TrialApp.app_id == str(app_id)).limit(1))
if trial_app is None:
raise TrialAppNotAllowed()
app = trial_app.app_with_session(session=session)
if app is None:
raise TrialAppNotAllowed()
account_trial_app_record = session.scalar(
select(AccountTrialAppRecord)
.where(AccountTrialAppRecord.account_id == current_user.id, AccountTrialAppRecord.app_id == app_id)
.limit(1)
)
if account_trial_app_record:
if account_trial_app_record.count >= trial_app.trial_limit:
raise TrialAppLimitExceeded()
return view(app, *args, **kwargs)
return decorated
if view:
return decorator(view)
return decorator
def trial_feature_enable[**P, R](view: Callable[P, R]):
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if not application_services().recommended_app_queries.is_trial_enabled():
raise TrialAppFeatureDisabledError()
return view(*args, **kwargs)
return decorated
class InstalledAppResource(Resource):
# must be reversed if there are multiple decorators
method_decorators = [
user_allowed_to_access_app,
installed_app_required,
account_initialization_required,
login_required,
]
class TrialAppResource(Resource):
# must be reversed if there are multiple decorators
method_decorators = [
trial_app_required,
trial_feature_enable,
account_initialization_required,
login_required,
]

View File

@@ -1,8 +1,11 @@
from collections.abc import Generator
from contextlib import contextmanager
from typing import Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
import services
@@ -29,10 +32,12 @@ from controllers.console.wraps import (
from extensions.ext_application_services import application_services
from fields.file_fields import FileResponse, UploadConfig
from libs.helper import dump_response
from libs.login import login_required
from libs.login import current_account_with_tenant, login_required
from machinery.context import RequestContext
from models import Account, UploadFile
from models.enums import CreatorUserRole
from services.feature_service import FeatureService
from services.file_service import FileUploadActor, FileUploadResult
register_response_schema_models(
console_ns,
@@ -61,8 +66,8 @@ FILE_UPLOAD_PARAMS = {
}
def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | None = None) -> UploadFile:
"""Validate the multipart request and persist the file under the requested resource tenant."""
def _parse_file_upload() -> tuple[FileStorage, str, Literal["datasets"] | None]:
"""Validate multipart shape before checking source-specific permissions."""
source_str = request.args.get("source") or request.form.get("source")
source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None
@@ -75,21 +80,37 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str |
if not file.filename:
raise FilenameNotExistsError()
return file, file.filename, source
@contextmanager
def _file_upload_errors() -> Generator[None, None, None]:
try:
yield
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description or "File size exceeded.") from file_too_large_error
except services.errors.file.UnsupportedFileTypeError as unsupported_file_type_error:
raise UnsupportedFileTypeError() from unsupported_file_type_error
except services.errors.file.BlockedFileExtensionError as blocked_extension_error:
raise BlockedFileExtensionError(blocked_extension_error.description) from blocked_extension_error
def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | None = None) -> UploadFile:
"""Validate the multipart request and persist the file under the requested resource tenant."""
file, filename, source = _parse_file_upload()
if source == "datasets" and not current_user.is_dataset_editor:
raise Forbidden()
if source not in ("datasets", None):
source = None
default_file_size_limit = (
FeatureService.get_knowledge_file_size_limit(resource_tenant_id or current_user.current_tenant_id)
if source == "datasets"
else None
)
try:
with _file_upload_errors():
return application_services().files.upload_file(
filename=file.filename,
filename=filename,
content=file.stream.read(),
mimetype=file.mimetype,
user=current_user,
@@ -97,12 +118,28 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str |
source=source,
default_file_size_limit=default_file_size_limit,
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description) from file_too_large_error
except services.errors.file.UnsupportedFileTypeError as unsupported_file_type_error:
raise UnsupportedFileTypeError() from unsupported_file_type_error
except services.errors.file.BlockedFileExtensionError as blocked_extension_error:
raise BlockedFileExtensionError(blocked_extension_error.description) from blocked_extension_error
def upload_file_from_request_context(*, request_context: RequestContext, resource_tenant_id: str) -> FileUploadResult:
"""Admit a Console upload and pass only its creator and resource owner to storage."""
file, filename, source = _parse_file_upload()
if source == "datasets" and not current_account_with_tenant().account.is_dataset_editor:
raise Forbidden()
default_file_size_limit = (
FeatureService.get_knowledge_file_size_limit(resource_tenant_id) if source == "datasets" else None
)
actor = FileUploadActor(id=request_context.account_id, creator_role=CreatorUserRole.ACCOUNT)
with _file_upload_errors():
return application_services().files.upload_file_for_actor(
actor=actor,
resource_tenant_id=resource_tenant_id,
filename=filename,
content=file.stream.read(),
mimetype=file.mimetype,
source=source,
default_file_size_limit=default_file_size_limit,
)
@console_ns.route("/files/upload")

View File

@@ -2,6 +2,7 @@
from collections.abc import Callable, Sequence
from functools import wraps
from http import HTTPStatus
from typing import Concatenate
from flask import Response, abort, request
@@ -11,6 +12,7 @@ from configs import dify_config
from controllers.common.rbac import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
enable_change_email,
enterprise_license_required,
setup_required,
@@ -18,6 +20,7 @@ from controllers.console.wraps import (
from core.logging.context import get_request_id, get_trace_id
from enums import DeploymentEdition
from libs.login import current_account_with_tenant, login_required
from libs.oauth_bearer import bearer_feature_required
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.system_feature_service import SystemFeatureService
@@ -46,8 +49,10 @@ def console_account_admission[T, **P, R](
require_change_email_enabled: bool = False,
require_initialized: bool = True,
require_valid_enterprise_license: bool = False,
require_oauth_bearer_enabled: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac_checks: Sequence[RBACCheck] | None = None,
billing_resource: str | None = None,
) -> Callable[
[Callable[Concatenate[T, RequestContext, P], R]],
Callable[Concatenate[T, P], R | Response],
@@ -56,13 +61,16 @@ def console_account_admission[T, **P, R](
All combinations use this decorator factory. Requirements are data, while
the execution order stays fixed: edition, setup, login/CSRF, optional
account initialization, optional enterprise license, role/RBAC checks, then
context construction.
account initialization, optional enterprise license and feature checks,
role/RBAC checks, then context construction and optional billing-resource
admission.
"""
def decorator(
view: Callable[Concatenate[T, RequestContext, P], R],
) -> Callable[Concatenate[T, P], R | Response]:
admitted_view = cloud_edition_billing_resource_check(billing_resource)(view) if billing_resource else view
@wraps(view, updated=())
def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
account_with_tenant = current_account_with_tenant()
@@ -83,7 +91,7 @@ def console_account_admission[T, **P, R](
request_id=get_request_id(),
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
)
return view(self, request_context, *args, **kwargs)
return admitted_view(self, request_context, *args, **kwargs)
if rbac_checks is not None:
setattr(inject_request_context, RBAC_CHECKS_ATTR, rbac_checks)
@@ -91,6 +99,8 @@ def console_account_admission[T, **P, R](
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
if require_change_email_enabled:
admitted = enable_change_email(admitted)
if require_oauth_bearer_enabled:
admitted = bearer_feature_required(admitted)
if require_valid_enterprise_license:
admitted = enterprise_license_required(admitted)
if require_initialized:
@@ -104,7 +114,7 @@ def console_account_admission[T, **P, R](
@wraps(view)
def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
if dify_config.DEPLOYMENT_EDITION not in editions:
abort(404)
abort(HTTPStatus.NOT_FOUND)
return admitted(self, *args, **kwargs)
return enforce_edition

View File

@@ -1,3 +1,5 @@
from typing import overload
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
@@ -25,6 +27,7 @@ from libs.helper import dump_response
from libs.login import login_required
from machinery.context import RequestContext
from models import Account
from services.file_service import FileUploadActor
from services.remote_file_service import (
RemoteFileAccessDeniedError as RemoteFileAccessDeniedServiceError,
)
@@ -84,13 +87,37 @@ class GetRemoteFileInfo(Resource):
)
@overload
def upload_remote_file(
*,
url: str,
current_user: Account,
resource_tenant_id: str | None = None,
) -> RemoteFileUploadResult: ...
@overload
def upload_remote_file(
*,
url: str,
current_user: FileUploadActor,
resource_tenant_id: str,
) -> RemoteFileUploadResult: ...
def upload_remote_file(
*,
url: str,
current_user: Account | FileUploadActor,
resource_tenant_id: str | None = None,
) -> RemoteFileUploadResult:
"""Fetch a remote file and persist it under the requested tenant."""
"""Fetch a remote file and persist it under the requested tenant.
For Account, resource_tenant_id=None (including when omitted) uses the
current workspace; an explicit value takes precedence. FileUploadActor
carries no tenant, so resource_tenant_id must be provided explicitly.
RemoteFileService enforces this before fetching the remote file.
"""
try:
return application_services().remote_files.upload_from_url(
url=url,

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