Compare commits

...

1039 Commits

Author SHA1 Message Date
Jyong
5ab1d0a4a6 fix(knowledge): respect TiDB collection deletion constraints 2026-09-24 01:30:19 -04:00
Jyong
b69e2555cc fix(knowledge): support TiDB authorized vector filtering 2026-09-24 01:11:54 -04:00
Jyong
29158db347 fix(knowledge): retain Weaviate collection cleanup helper 2026-09-24 00:36:38 -04:00
Jyong
638008507b fix(knowledge): include vector kind in collection names 2026-09-24 00:31:59 -04:00
Stephen Zhou
5ab31b78e7 Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-24 08:59:21 +08:00
Jyong
189cc209dd fix(knowledge): remove independent vector storage switch (#42827) 2026-09-23 04:51:26 -04:00
Jyong
039694b1d3 fix(knowledge): provision TiDB vector clusters for new tenants (#42810) 2026-09-23 03:24:34 -04:00
Jyong
59830a7282 test(ci): include native vector results in gate regression matrix (#42797) 2026-09-23 02:23:35 -04:00
Jyong
34f3955154 fix(ci): require native vector tests in final KnowledgeFS gate (#42795) 2026-09-23 02:14:36 -04:00
Jyong
f09af0c148 feat(knowledge): support Elasticsearch and Weaviate vector storage (#42792) 2026-09-23 02:10:38 -04:00
Stephen Zhou
104bbe199e fix(cli): adapt requests to the OpenAPI operation catalog 2026-09-23 10:06:04 +08:00
Stephen Zhou
aca6580790 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/constants/__init__.py
#	api/controllers/openapi/_errors.py
#	api/controllers/openapi/_version_gate.py
#	api/extensions/ext_blueprints.py
#	api/tests/unit_tests/controllers/openapi/test_auth_matrix.py
#	api/tests/unit_tests/controllers/openapi/test_error_contract.py
#	api/tests/unit_tests/controllers/service_api/test_wraps.py
#	api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py
#	packages/contracts/console.ts
#	packages/contracts/generated/api/console/orpc.gen.ts
#	packages/contracts/generated/api/console/router.gen.ts
#	packages/contracts/generated/api/openapi/orpc.gen.ts
#	packages/contracts/generated/api/openapi/zod.gen.ts
#	packages/contracts/generated/api/service/orpc.gen.ts
#	packages/contracts/generated/knowledge-fs/metadata.gen.ts
#	packages/contracts/generated/knowledge-fs/orpc.gen.ts
#	packages/contracts/openapi-ts.knowledge-fs.config.ts
#	web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx
2026-09-23 09:20:42 +08:00
Stephen Zhou
1dc6a447c4 fix(knowledge): allowlist sandbox usage merge migration in coverage gate 2026-09-22 17:08:14 +08:00
Stephen Zhou
8c3e7383b4 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-22 16:56:51 +08:00
Stephen Zhou
686e760d8c fix: reconcile knowledge branch with main migrations and queries 2026-09-22 16:56:20 +08:00
Jyong
e781e78124 feat(knowledge): reuse Dify vector storage for KnowledgeFS (#42731) 2026-09-22 04:52:24 -04:00
Stephen Zhou
74cb7ba6ca Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-22 16:40:00 +08:00
Stephen Zhou
7b5f6a2e80 fix(web): preserve knowledge UI behavior after main merge 2026-09-21 09:58:49 +08:00
Stephen Zhou
fc50e67142 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py
#	web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx
#	web/app/components/datasets/list/dataset-card/components/dataset-card-footer.tsx
#	web/app/components/datasets/list/dataset-card/components/dataset-card-header.tsx
#	web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx
#	web/app/components/datasets/list/dataset-card/index.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx
#	web/features/new-rag/__tests__/add-source-page.spec.tsx
#	web/features/new-rag/add-source-page.tsx
#	web/features/new-rag/document-chunk-detail.tsx
#	web/features/new-rag/document-chunk-tree.tsx
#	web/features/new-rag/documents-page.tsx
#	web/features/new-rag/documents/__tests__/page.spec.tsx
#	web/features/new-rag/documents/tasks/auxiliary-read-guard.ts
#	web/features/new-rag/knowledge-space-shell.tsx
#	web/features/new-rag/processing-tasks-drawer.tsx
2026-09-21 09:49:29 +08:00
hjlarry
f094e3f415 feat(knowledge): support plan-based document queue routing 2026-09-20 11:15:35 +08:00
Stephen Zhou
7b482e4dab fix(knowledge): use explicit column for authorization timestamp update 2026-09-18 15:26:40 +08:00
Stephen Zhou
898373ec53 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts
#	web/app/layout.tsx
#	web/features/new-rag/__tests__/add-source-page.spec.tsx
#	web/features/new-rag/__tests__/document-detail-page.spec.tsx
#	web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
#	web/features/new-rag/__tests__/sources-page.spec.tsx
#	web/features/new-rag/create-knowledge-page.tsx
#	web/features/new-rag/document-chunk-detail.tsx
#	web/features/new-rag/document-list.tsx
#	web/features/new-rag/documents-page.tsx
#	web/features/new-rag/documents/__tests__/page.spec.tsx
#	web/features/new-rag/knowledge-space-shell.tsx
#	web/features/new-rag/new-knowledge-list.tsx
#	web/features/new-rag/sources-page.tsx
#	web/features/new-rag/use-document-reindex.ts
#	web/service/fetch.spec.ts
2026-09-18 15:18:33 +08:00
Stephen Zhou
0323ba2ca3 chore(i18n): remove unused knowledge translation keys 2026-09-18 15:14:32 +08:00
Stephen Zhou
9583c98a06 Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-18 15:02:17 +08:00
Stephen Zhou
775133d54d fix(knowledge): fix overview tips in dark mode and hide neutral changes 2026-09-18 14:51:17 +08:00
Stephen Zhou
49fba5c18c Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/controllers/console/datasets/datasets.py
#	web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx
#	web/app/components/workflow/run/node.tsx
2026-09-18 09:29:42 +08:00
Jyong
06e473015a fix(knowledge): save all settings from one form 2026-09-17 06:36:06 -04:00
Jyong
47cb671979 fix(knowledge): support selected members during creation 2026-09-17 05:58:24 -04:00
Stephen Zhou
8061a155e8 fix(web): correct knowledge advanced settings chevron direction 2026-09-17 16:44:55 +08:00
Stephen Zhou
ba1c6433ab fix(web): align knowledge card actions with design 2026-09-17 16:42:54 +08:00
Stephen Zhou
7daf6066d9 fix(web): remove redundant knowledge card labels 2026-09-17 16:37:20 +08:00
Stephen Zhou
f454da6f32 fix(web): unify knowledge overlay close button sizing 2026-09-17 16:34:15 +08:00
Stephen Zhou
259350203c fix(web): use accent ghost button for all activity 2026-09-17 16:26:24 +08:00
Stephen Zhou
675d5ca19a fix(web): unify knowledge overview heading typography 2026-09-17 16:21:39 +08:00
Stephen Zhou
33bfa4f658 fix(web): align knowledge source field label typography 2026-09-17 16:17:02 +08:00
Jyong
07ea2b9e88 fix(knowledge): expose recoverable service APIs and pin engine contracts 2026-09-17 04:15:05 -04:00
Stephen Zhou
372e8bce75 fix(web): localize knowledge overview time range labels 2026-09-17 16:05:11 +08:00
Stephen Zhou
762736fc7c fix(web): show pointer cursor for knowledge source options 2026-09-17 15:56:41 +08:00
Stephen Zhou
437d7501de fix(web): use primary button for API key creation 2026-09-17 15:53:21 +08:00
Stephen Zhou
dae30608e0 fix(web): keep API key table actions visible 2026-09-17 15:47:59 +08:00
Stephen Zhou
1dcb4658ca fix(web): clarify knowledge base availability after upgrade 2026-09-17 15:33:22 +08:00
Stephen Zhou
ee07ec84ea chore(knowledge-fs): remove stale configuration and test artifacts 2026-09-17 15:00:04 +08:00
Stephen Zhou
10826508b5 fix(web): restore knowledge collapsible trigger styles 2026-09-17 14:40:50 +08:00
Stephen Zhou
e96c652dd2 Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-17 14:35:48 +08:00
Jyong
86b31946b9 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-17 00:01:22 -04:00
Jyong
3a0b54216b delete knowledge-fs 2026-09-16 23:55:03 -04:00
Stephen Zhou
d23c4dfcfa fix(knowledge-fs): use document task snapshots for status and recovery 2026-09-11 21:04:17 +08:00
FFXN
e4cc2fcd63 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-11 19:04:00 +08:00
Stephen Zhou
3a0344afbd fix(knowledge-fs): handle per-document reindex failures 2026-09-11 17:02:37 +08:00
Stephen Zhou
46d0290122 fix(web): remove misleading overview upload failure banner 2026-09-11 16:09:43 +08:00
FFXN
d3670a1880 feat(online-drive): preserve metadata through datasource bridge
Keep the optional remote metadata contract bounded across browse and download response models.

Use a dedicated online-drive download message so Base64 blob chunks and metadata survive the Dify inner API event stream unchanged, with regression coverage for legacy and non-online-drive messages.
2026-09-11 15:51:00 +08:00
Stephen Zhou
91db65ce1e fix(web): extend settings panel background to viewport edge 2026-09-10 15:25:36 +08:00
Stephen Zhou
72eec1d31c fix(web): center settings dialog across viewport sizes 2026-09-10 15:19:55 +08:00
Jyong
d847a3a91e test(knowledge-fs): cover agent investigation delivery failures 2026-09-10 01:29:45 -04:00
Jyong
ed67c470ab feat(knowledge-fs): report agent investigation failures 2026-09-10 01:18:27 -04:00
Stephen Zhou
4df4e13130 chore: merge main into deploy/konwledge 2026-09-10 11:47:39 +08:00
Stephen Zhou
e0171b47eb fix(navigation): prefetch workspace features for initial render 2026-09-10 01:26:10 +08:00
Stephen Zhou
3b677ec43f fix(navigation): detect shortcut platform on the server 2026-09-10 01:22:47 +08:00
Stephen Zhou
5e3072c60d fix(install): keep footer position stable while loading 2026-09-10 00:50:13 +08:00
Stephen Zhou
56db98fe73 chore: merge main into deploy/konwledge 2026-09-10 00:26:43 +08:00
Stephen Zhou
95f8d7f44d refactor(knowledge): integrate nuqs-owned query groups 2026-09-09 23:58:15 +08:00
Stephen Zhou
d7cd57b5b6 fix(knowledge): integrate reviewed nuqs-jotai runtime changes 2026-09-09 22:51:23 +08:00
Stephen Zhou
df37966ed5 refactor(knowledge): simplify URL atom APIs and call sites 2026-09-09 21:57:13 +08:00
Stephen Zhou
cbd488c1d3 fix(knowledge): centralize URL atoms and synchronize navigation 2026-09-09 21:43:01 +08:00
Stephen Zhou
ca0d7362a8 fix(knowledge): derive New RAG state without effect synchronization 2026-09-09 20:42:58 +08:00
Jyong
740005bb2f test(knowledge-fs): include graph projections in migration runner fixtures 2026-09-09 04:49:19 -04:00
Jyong
d322fd8b03 feat(knowledge-fs): add semantic graph matching and constrained path retrieval 2026-09-09 04:40:59 -04:00
Stephen Zhou
775501b2f8 fix(knowledge): align dependencies and lint after main merge 2026-09-09 16:31:26 +08:00
Stephen Zhou
f4a4895c68 Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-09 16:18:07 +08:00
Stephen Zhou
577d409615 fix(knowledge): contain API key scope picker scrolling 2026-09-09 15:40:14 +08:00
Jyong
17d937678d feat(knowledge-fs): prepare optional drive metadata verification
Accept bounded optional file metadata and cache publication-verified download receipts for conservative unchanged-file checks. Preserve the existing download and hash comparison path for legacy plugins.

This is platform-side groundwork; metadata-aware SDK, daemon and provider support is still required for the standard plugin chain. Update the KnowledgeFS contract lock and cover metadata parsing and verification.
2026-09-09 02:08:07 -04:00
Stephen Zhou
b063723def fix(knowledge): resolve main CI checks 2026-09-09 12:07:08 +08:00
Stephen Zhou
f7168bbd7d fix(knowledge-fs): restore document and quality page scrolling 2026-09-09 11:16:52 +08:00
Stephen Zhou
fe2627bfd8 fix(knowledge): refresh contract subtree lock 2026-09-09 11:10:09 +08:00
Stephen Zhou
413eda0b10 fix(knowledge-fs): align agent card grid with classic 2026-09-09 10:47:46 +08:00
Stephen Zhou
df0039b629 test(knowledge-fs): update sharp security lock 2026-09-09 10:43:53 +08:00
Stephen Zhou
595375ddad fix(knowledge-fs): bump sharp to 0.35.4 2026-09-09 10:36:56 +08:00
Stephen Zhou
d4870060c2 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	.agents/skills/how-to-write-component/references/data.md
#	web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts
#	web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts
#	web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx
#	web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/index.tsx
#	web/features/new-rag/__tests__/add-source-page.spec.tsx
#	web/features/new-rag/__tests__/crawl-selection-form.spec.tsx
#	web/features/new-rag/__tests__/create-knowledge-page.spec.tsx
#	web/features/new-rag/__tests__/document-detail-page.spec.tsx
#	web/features/new-rag/__tests__/knowledge-space-shell.spec.tsx
#	web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
#	web/features/new-rag/__tests__/processing-task-events.spec.ts
#	web/features/new-rag/__tests__/sources-page.spec.tsx
#	web/features/new-rag/add-source-page.tsx
#	web/features/new-rag/crawl-selection-form.tsx
#	web/features/new-rag/create-knowledge-page.tsx
#	web/features/new-rag/create-knowledge-workflow.ts
#	web/features/new-rag/document-detail-page.tsx
#	web/features/new-rag/document-detail-queries.ts
#	web/features/new-rag/documents-page.tsx
#	web/features/new-rag/knowledge-space-shell.tsx
#	web/features/new-rag/new-knowledge-list.tsx
#	web/features/new-rag/processing-tasks-drawer.tsx
#	web/features/new-rag/services/processing-task-events.ts
#	web/features/new-rag/sources-page.tsx
#	web/features/new-rag/sources/create/website-crawl-preview.tsx
#	web/features/new-rag/use-document-reindex.ts
#	web/features/new-rag/use-document-task-status.ts
#	web/service/console-openapi-url.ts
#	web/service/console-router-loader.ts
#	web/service/console/client.spec.ts
#	web/service/console/query-policies.ts
2026-09-09 10:22:55 +08:00
Jyong
6c87959b2e fix(knowledge-fs): use model-aware budgets and bounded recovery 2026-09-08 08:02:05 -04:00
Jyong
082815ef9f fix(knowledge-fs): refresh contract pin for Celery execution 2026-09-08 05:11:24 -04:00
Jyong
f9eb819cc7 feat(knowledge-fs): execute background work in isolated Celery workers 2026-09-08 05:02:45 -04:00
Stephen Zhou
6e842cfde5 fix(knowledge): resolve source provider icons and handle loading failures 2026-09-08 15:40:03 +08:00
Stephen Zhou
294dcc9ac1 fix(knowledge): use canonical retry policy field 2026-09-08 14:41:23 +08:00
Stephen Zhou
1e458e3e3e fix(knowledge): refine Japanese translation accuracy 2026-09-08 14:35:46 +08:00
Stephen Zhou
ad15cee84a fix(knowledge): correct Japanese translations and terminology 2026-09-08 14:31:45 +08:00
Jyong
d7b5d3a010 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
Preserve import recovery and model-settings fixes alongside upstream knowledge naming changes. Regenerate the KnowledgeFS contract lock and verify the merged API contracts.
2026-09-08 02:23:21 -04:00
Jyong
9e6b9d1ceb fix(knowledge-fs): recover failed imports and model changes safely
Safely normalize semantic chunk boundaries with bounded retries and accurate error classification. Synchronize failed processing tasks and overview attention states.

Allow safe retrieval model changes before publication, create fresh compilation attempts when profiles change, and preserve explicit concurrency and revision-conflict safeguards.
2026-09-08 02:19:51 -04:00
Stephen Zhou
10260d108c fix(knowledge): align knowledge view URL naming 2026-09-08 14:03:26 +08:00
Stephen Zhou
19de0a4943 fix(knowledge): shorten knowledge view switcher labels 2026-09-08 13:42:46 +08:00
Stephen Zhou
3e3698c4c7 fix(knowledge): refresh contract subtree lock 2026-09-08 13:26:02 +08:00
Stephen Zhou
f02f0eca8a fix(knowledge): align agent runtime error naming 2026-09-08 13:25:29 +08:00
Stephen Zhou
bd74150f6a fix(knowledge): complete agent knowledge base naming 2026-09-08 13:16:16 +08:00
Stephen Zhou
e6ac86e8d5 refactor(knowledge): adopt agent knowledge base naming 2026-09-08 13:05:17 +08:00
Stephen Zhou
bd810e96eb revert(i18n): restore original and agentic knowledge naming 2026-09-08 12:52:49 +08:00
Stephen Zhou
b2c4fadba8 fix(i18n): localize original and agentic knowledge naming 2026-09-08 12:47:05 +08:00
Stephen Zhou
4b3948d3e2 refactor(knowledge): distinguish original and agentic naming 2026-09-08 12:38:41 +08:00
Stephen Zhou
bbded02729 fix(ci): scope deploy coverage after main sync 2026-09-08 10:12:06 +08:00
Stephen Zhou
882573e477 fix(agent): preserve upload references for knowledge image search 2026-09-08 09:49:08 +08:00
Stephen Zhou
c2390e0698 fix(api): hide workflow-only agent app API keys 2026-09-08 09:49:03 +08:00
Stephen Zhou
12a5189576 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/core/rbac/entities.py
#	api/extensions/ext_application_services.py
#	api/extensions/ext_commands.py
#	api/services/enterprise/rbac_service.py
#	api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py
#	web/app/components/datasets/list/__tests__/index.spec.tsx
2026-09-08 09:26:30 +08:00
Jyong
f91c4da56a fix(agent): remove redundant knowledge picker help icons 2026-09-07 06:34:19 -04:00
FFXN
833565de03 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-07 18:12:27 +08:00
FFXN
e058a6c14d fix(knowledge-import): handle temporary policy-write fence for website source imports 2026-09-07 18:10:10 +08:00
FFXN
9006e110ed fix(knowledge-import): handle temporary policy-write fence for website source imports 2026-09-07 18:07:34 +08:00
Jyong
83975c677d fix(agent): refine knowledge picker UX and permission filtering 2026-09-07 06:06:44 -04:00
FFXN
d0364e6963 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-07 16:50:34 +08:00
FFXN
d60245efb9 fix(website-import): add replaceExistingSelection flag to crawl import payload 2026-09-07 16:50:04 +08:00
Jyong
d3d77bb2c8 fix(agent): expose KnowledgeFS configuration entry points 2026-09-07 04:30:12 -04:00
Jyong
1e7017772a fix(ci): cover extracted KnowledgeFS core modules 2026-09-07 03:48:42 -04:00
Jyong
a4bea4e7d3 fix(agent): align KnowledgeFS integration type contracts 2026-09-07 03:44:37 -04:00
Jyong
b68cc9f1ff fix(ci): refresh exact KnowledgeFS test-fixture scan locations 2026-09-07 03:41:34 -04:00
FFXN
5913a0e2e7 fix(website-import): ensure proper handling of replacement selections and reconcile inventory 2026-09-07 15:39:08 +08:00
FFXN
fe09388e21 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-07 15:38:45 +08:00
FFXN
f7f74c8c35 fix(website-import): ensure proper handling of replacement selections and reconcile inventory 2026-09-07 15:37:43 +08:00
Stephen Zhou
6417b55f50 fix(knowledge-fs): resolve CI failures and defer workflow runtime initialization 2026-09-07 15:36:28 +08:00
Jyong
4e17182fc2 feat(agent): enable autonomous KnowledgeFS CLI access 2026-09-07 03:32:33 -04:00
Stephen Zhou
f1f5394370 fix(knowledge-fs): allow clearing connected source names 2026-09-07 14:23:49 +08:00
Stephen Zhou
98d1c04c4f fix(knowledge-fs): save website source settings without previewing 2026-09-07 14:20:42 +08:00
FFXN
20d9fff987 fix(knowledge-fs): sync latest website selection without self-fencing deletions 2026-09-07 11:40:53 +08:00
FFXN
30ad3801d2 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-09-07 11:40:38 +08:00
FFXN
33f5d6a065 fix(knowledge-fs): sync latest website selection without self-fencing deletions 2026-09-07 11:35:56 +08:00
Stephen Zhou
35ac87bf37 docs(frontend): reconcile merged ownership guidance 2026-09-07 10:50:26 +08:00
Stephen Zhou
31e946a11d Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-07 10:44:31 +08:00
Jyong
24db4f5af9 fix(knowledge-fs): bound document parsing and isolate resource-heavy work 2026-09-05 23:42:16 -04:00
Stephen Zhou
ad43bf7eda fix(web): align knowledge space translations with English source 2026-09-05 10:12:58 +08:00
Stephen Zhou
6ea10937d2 Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-05 09:09:27 +08:00
FFXN
484adb3cfa fix(knowledge-fs): recover website import workflows and log scheduler failures` 2026-09-04 23:36:45 +08:00
FFXN
87df76637b fix(knowledge-fs): recover website import workflows and log scheduler failures` 2026-09-04 23:35:24 +08:00
FFXN
74c702a5ab fix(api): fix CI 2026-09-04 22:30:00 +08:00
FFXN
9b59c68737 fix(api): fix CI 2026-09-04 22:29:27 +08:00
FFXN
3e5b4640d6 fix(api): fix CI 2026-09-04 22:17:16 +08:00
FFXN
9f52da1a4d fix(knowledge-fs): retry transient embeddings and recover website source state 2026-09-04 22:07:30 +08:00
FFXN
2f2aa585bd Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-04 22:07:17 +08:00
FFXN
b3ae5b8da2 fix(knowledge-fs): retry transient embeddings and recover website source state 2026-09-04 22:06:49 +08:00
Stephen Zhou
57a283d88c Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	web/features/new-rag/__tests__/add-source-page.spec.tsx
#	web/features/new-rag/__tests__/document-detail-page.spec.tsx
#	web/features/new-rag/add-source-page.tsx
#	web/features/new-rag/components/add-source-exit-dialog.tsx
#	web/features/new-rag/crawl-selection-form.tsx
#	web/features/new-rag/document-chunk-tree.tsx
#	web/features/new-rag/document-detail-header.tsx
#	web/features/new-rag/document-detail-page.tsx
#	web/features/new-rag/document-detail-status.tsx
#	web/features/new-rag/document-list.tsx
#	web/features/new-rag/documents-page.tsx
#	web/features/new-rag/documents/__tests__/page.spec.tsx
#	web/features/new-rag/knowledge-space-shell.tsx
#	web/features/new-rag/new-knowledge-list.tsx
#	web/features/new-rag/processing-tasks-drawer.tsx
#	web/features/new-rag/sources-page.tsx
#	web/features/new-rag/sources/create/__tests__/website-crawl-preview.spec.tsx
#	web/features/new-rag/sources/create/website-crawl-preview.tsx
2026-09-04 21:22:37 +08:00
Stephen Zhou
a2740f12a8 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-04 21:11:18 +08:00
FFXN
90dd7fa894 fix(knowledge-fs): propagate preview capability to crawl import 2026-09-04 21:09:44 +08:00
FFXN
8be760ac12 fix(knowledge-fs): preserve website preview bindings and surface import failures 2026-09-04 20:38:05 +08:00
Stephen Zhou
0a36f2a9f9 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-04 20:02:48 +08:00
FFXN
244226b4da fix(knowledge-fs): harden website preview lifecycle
- prevent preview cancellation during knowledge base creation handoff
- serialize preview scheduler ticks and handle timer failures
- prevent cleanup jobs from starving queued previews
- pass workflow page content through object storage references
2026-09-04 19:56:04 +08:00
FFXN
a94adda138 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-09-04 19:55:44 +08:00
FFXN
70ec2c5954 fix(knowledge-fs): harden website preview lifecycle
- prevent preview cancellation during knowledge base creation handoff
- serialize preview scheduler ticks and handle timer failures
- prevent cleanup jobs from starving queued previews
- pass workflow page content through object storage references
2026-09-04 19:49:31 +08:00
FFXN
587efac57d fix(knowledge-fs): harden website preview lifecycle
- prevent preview cancellation during knowledge base creation handoff
- serialize preview scheduler ticks and handle timer failures
- prevent cleanup jobs from starving queued previews
- pass workflow page content through object storage references
2026-09-04 19:49:19 +08:00
Stephen Zhou
38457f27fb Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	.agents/skills/how-to-write-component/references/data.md
#	.agents/skills/how-to-write-component/references/ownership.md
#	api/controllers/inner_api/__init__.py
#	api/tests/test_containers_integration_tests/services/test_account_service.py
#	api/tests/unit_tests/services/test_account_service.py
#	packages/iconify-collections/custom-public/icons.json
#	packages/iconify-collections/custom-public/info.json
#	web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
#	web/features/new-rag/components/knowledge-space-card.tsx
#	web/features/new-rag/document-chunk-tree.tsx
#	web/features/tag-management/components/tag-search-content.tsx
#	web/features/tag-management/components/tag-selector.tsx
#	web/service/client.spec.ts
2026-09-04 19:42:31 +08:00
Stephen Zhou
6aba69326e fix(knowledge-fs): show v2 workflow citations 2026-09-04 19:21:59 +08:00
FFXN
0f38780115 fix(knowledge-fs): authenticate namespace source preview routes 2026-09-04 18:41:06 +08:00
FFXN
9d4c9b7c63 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-04 18:11:04 +08:00
FFXN
db44dbe561 fix(knowledge-fs): support namespace preview persistence and capability audits
- declare namespace preview tables and indexes in the runtime schema
- allow capability audit records before a control space exists
- preserve namespace audit details in the sanitized claims summary
- add schema, migration, and audit regression coverage
2026-09-04 17:56:14 +08:00
Jyong
b79b8a879b feat(knowledge-fs): record and count every knowledge space query
Workflow KnowledgeFS retrieval nodes ran through `retrieval-tests`, which
never wrote an AnswerTrace or an overview event, so their queries were
missing from the retrieval history and from the space overview; the history
also only ever showed each member their own traces.

- AnswerTrace gains a `source` (retrieval_test | workflow | service_api |
  agent | mcp; migration 0051_answer_trace_source) derived from the
  Capability v2 caller kind. The retrieval-tests route records one trace per
  run (stages, evidence bundle, profile metadata), returns its id as
  `answerTraceId`, and the workflow node's failed-retrieval capture attaches
  to that trace instead of creating a second record.
- The quality trace list exposes and filters by `source`; traces from other
  caller kinds are visible to any current reader of the space, and counts and
  scores fall back to the evidence embedded in the trace when no bundle row
  exists.
- Overview accounting: retrieval-tests and Research tasks now emit
  `query.requested`, and the Research job state machine emits
  `query.completed` / `query.failed` on terminal stages (wired for both the
  in-process gateway and the durable runtime), so query volume, answer rate
  and outcomes include every caller. Activity details keep `source` and
  `taskKind`.
- Console and service trace routes accept a `source` filter; the retrieval
  test page shows a source badge and an all / retrieval tests / workflow
  filter, with translations for every locale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-04 05:48:17 -04:00
FFXN
397cc2d7cc fix(knowledge-fs): add namespace source preview jobs and pages schema 2026-09-04 17:23:56 +08:00
FFXN
327e12ad23 fix(knowledge-fs): add namespace source preview jobs and pages schema 2026-09-04 17:23:29 +08:00
FFXN
3171b69984 feat(knowledge-fs): fix CI. 2026-09-04 16:56:21 +08:00
FFXN
c6b2978f6e Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-09-04 16:56:11 +08:00
FFXN
40e30d9fa6 feat(knowledge-fs): fix CI. 2026-09-04 16:55:47 +08:00
Stephen Zhou
876aab708e fix(knowledge-fs): track workflow retrieval outcomes 2026-09-04 16:50:09 +08:00
FFXN
d4d51e0e20 feat(knowledge-fs): fix CI. 2026-09-04 16:18:02 +08:00
FFXN
32ed28355f Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-04 16:16:05 +08:00
FFXN
0eb5bb700a feat(knowledge-fs): fix CI. 2026-09-04 16:13:13 +08:00
Stephen Zhou
8e4f687c50 test(knowledge-fs): track namespace preview repository 2026-09-04 16:06:34 +08:00
FFXN
e318a2f1ca feat(knowledge-fs): add namespace preview for initial website sources
- create website preview jobs before a knowledge space exists
- persist crawled page content in KnowledgeFS object storage
- import selected pages using preview job and page IDs
- validate preview ownership, expiry, and configuration fingerprint
- clean up preview content after cancellation, expiry, or consumption
- add namespace preview routes, capabilities, migrations, and contracts
- keep online document and drive flows unchanged
2026-09-04 15:53:01 +08:00
FFXN
ad059f55d4 feat(knowledge-fs): add namespace preview for initial website sources
- create website preview jobs before a knowledge space exists
- persist crawled page content in KnowledgeFS object storage
- import selected pages using preview job and page IDs
- validate preview ownership, expiry, and configuration fingerprint
- clean up preview content after cancellation, expiry, or consumption
- add namespace preview routes, capabilities, migrations, and contracts
- keep online document and drive flows unchanged
2026-09-04 15:52:36 +08:00
Stephen Zhou
7fba0afc91 fix(knowledge-fs): refresh contract lock 2026-09-04 12:50:27 +08:00
Stephen Zhou
6570e907a4 style(knowledge-fs): format migration assertion 2026-09-04 12:33:02 +08:00
Stephen Zhou
60a9bddbf1 test(knowledge-fs): track API migration operations 2026-09-04 12:22:03 +08:00
Stephen Zhou
9d3afb37ca fix(knowledge-fs): classify threshold-filtered queries 2026-09-04 12:12:58 +08:00
Stephen Zhou
0b80f0556c test(knowledge-fs): track failed query outcome migration 2026-09-04 12:08:28 +08:00
FFXN
0361ee6a8b fix: expose active import workflows as source sync status 2026-09-04 11:53:35 +08:00
FFXN
7262dc51be Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-04 11:52:57 +08:00
FFXN
448fd92f6d fix: expose active import workflows as source sync status 2026-09-04 11:52:41 +08:00
Stephen Zhou
98fdfb5cfb fix(knowledge-fs): refresh and link flagged bad cases 2026-09-04 11:51:11 +08:00
Stephen Zhou
b8d39bec5f fix(knowledge-fs): persist durable query outcomes 2026-09-04 11:43:04 +08:00
Stephen Zhou
836308074e fix(knowledge-fs): align new RAG frontend RBAC permissions 2026-09-04 10:07:22 +08:00
Jyong
8150a8fca7 feat(knowledge-fs): rate limit console writes and every service API route
The legacy dataset console rate limits every mutating, download, and
retrieval route and the dataset service API limits every request, while
KnowledgeFS only covered uploads and query admission. Apply the knowledge
rate limit to all space-scoped POST/PUT/PATCH/DELETE console routes, the
document downloads, and research/query entrypoints (workspace-level source
previews, staged-upload discards, the deprecated buffered query route, and
the internal stream transport stay exempt). Extract the service API check
into `check_knowledge_rate_limit` and call it from the KnowledgeFS profile
helper so all service routes share the legacy per-workspace limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 11:10:04 -04:00
Jyong
cce0d818cf feat(knowledge-fs): authorize spaces with the legacy dataset RBAC points
KnowledgeFS spaces used their own `knowledge_space_*` RBAC vocabulary, so
workspace roles configured for knowledge bases did not apply to them. Reuse
the legacy `dataset_*` permission points instead, following the mapping the
dataset console already applies:

- read      -> dataset_readonly
- create    -> dataset_create_and_management
- edit / document write -> dataset_edit
- delete    -> dataset_delete
- access config -> dataset_access_config
- query / research / trace detail -> dataset_retrieval_recall

The enterprise `/knowledge-fs/permission-keys/batch` lookup now answers in
that vocabulary and is translated back into the product capabilities the
console exposes as `permission_keys`, so the web stays unchanged; the
historical `knowledge_space_*` keys remain accepted during the transition.
Workspace-level checks send the mapped dataset scene with
`resource_type=dataset`, and the RBAC-disabled fallback grants every dataset
point KnowledgeFS consults.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 11:10:04 -04:00
FFXN
7ca5eaab97 fix(llm-semantic-chunker): revert default max output tokens from 8000 to 6000 2026-09-03 22:57:44 +08:00
FFXN
f12c230689 fix(llm-semantic-chunker): revert default max output tokens from 8000 to 6000 2026-09-03 22:57:14 +08:00
FFXN
8c0f8e7113 fix(llm-semantic-chunker): increase default max output tokens from 6000 to 8000 2026-09-03 22:18:16 +08:00
FFXN
cc8a89cdf7 fix(llm-semantic-chunker): increase default max output tokens from 6000 to 8000 2026-09-03 22:18:06 +08:00
FFXN
6c79f4baeb Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-03 22:12:41 +08:00
FFXN
85ac5a79c7 fix(knowledge-fs): handle unknown unit ID errors with LlmSemanticChunkingOutputError 2026-09-03 22:11:26 +08:00
FFXN
b904fbe066 fix(knowledge-fs): handle unknown unit ID errors with LlmSemanticChunkingOutputError 2026-09-03 22:11:17 +08:00
Stephen Zhou
0d7d3f4221 fix(web): preserve golden question dialog close lifecycle 2026-09-03 21:39:26 +08:00
FFXN
f291a76135 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-03 21:31:23 +08:00
FFXN
bbde5fab52 fix(knowledge-fs): update content handling to use KnowledgeFSCrawlImportPagePayload and add cleanup tests 2026-09-03 21:31:00 +08:00
Stephen Zhou
7101589f8d fix(web): prevent golden question dialog overflow 2026-09-03 21:18:37 +08:00
FFXN
31b2b4d500 feat(knowledge-fs): add content handling for initial source preview and cleanup tasks 2026-09-03 21:16:54 +08:00
FFXN
b7b8367234 feat(knowledge-fs): add content handling for initial source preview and cleanup tasks 2026-09-03 21:09:44 +08:00
FFXN
94a170b0de Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-03 21:01:45 +08:00
FFXN
06cd47807c feat(knowledge-fs): add content handling for initial source preview and cleanup tasks 2026-09-03 21:01:21 +08:00
Stephen Zhou
23b02cbc74 fix(web): localize new RAG formatting 2026-09-03 19:38:52 +08:00
FFXN
069be90768 fix (web): show syncing status for initial knowledge sources 2026-09-03 19:03:58 +08:00
FFXN
43d50ce021 feat(knowledge-fs): enhance capability grant admission middleware with deletion handling options 2026-09-03 18:55:37 +08:00
FFXN
51085c6480 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-03 18:55:23 +08:00
FFXN
ce5d702c46 feat(knowledge-fs): enhance capability grant admission middleware with deletion handling options 2026-09-03 18:54:43 +08:00
Jyong
8569c6be53 test(api): track the typed key binding migration as the alembic head
Migration 9a4e7d1c2b60 (dataset API key binding resource type) is the new
single head after c3f1a9b2e6d4; update the merge-head guard accordingly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 06:07:22 -04:00
Jyong
79116d8c4b feat(knowledge-fs): scope dataset API keys by knowledge base type
Legacy knowledge bases (`datasets`) and KnowledgeFS spaces
(`knowledge_fs_control_spaces`) live in different tables, so a dataset API
key binding could only ever name a legacy dataset and the KnowledgeFS service
API never consulted bindings at all: a key scoped to specific knowledge bases
still reached every KnowledgeFS space in the workspace.

- Bindings carry a `resource_type` (`dataset` | `knowledge_fs_space`) and a
  nullable `control_space_id` (migration 9a4e7d1c2b60, existing rows stay
  `dataset`), with a CHECK that exactly one id column matches the type.
- `dataset_api_key_service` exposes the key scope by kind, validates and
  binds KnowledgeFS spaces (tenant-owned, not deleting/deleted), and cleans up
  keys scoped only to a space when its deletion is requested. The orphan-key
  cleanup is now NULL-safe so a space binding keeps a key alive.
- Legacy dataset routes only honour `dataset` bindings; the KnowledgeFS
  authorization service only honours `knowledge_fs_space` bindings and raises
  a scope error that the service API maps to 403 (unknown keys stay 401).
- Console key creation accepts `knowledge_space_ids`; list/create responses
  return them next to `dataset_ids`.
- The web scope picker lists KnowledgeFS spaces alongside legacy datasets
  when KnowledgeFS is enabled and submits the ids by kind; the scope column
  counts both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 05:59:39 -04:00
Jyong
180ac5ad4d refactor(knowledge-fs): remove deprecated buffered service API routes
Drop the deprecated `POST /knowledge-fs/spaces/<id>/queries` route and the
deprecated buffered `POST .../documents` upload from the service API. Both
only ever failed closed; queries go through admission + the query stream,
documents through the durable source import flow. Regenerate the service
contracts and markdown docs accordingly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 05:59:39 -04:00
Jyong
06a363509e fix(knowledge-fs): show only the shared prefix as the service API endpoint
The API access dialog appended `/queries/admission` to the endpoint it
displays, which is one operation rather than the base every KnowledgeFS
service route shares. Show `<service api>/knowledge-fs/spaces/<id>` instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 05:59:39 -04:00
Stephen Zhou
cbfd1f69ee refactor(knowledge-fs): split translations into dedicated namespace 2026-09-03 17:37:59 +08:00
FFXN
5b7f15f2b1 test(knowledge-fs): add tests for dataset upgrade API with disabled knowledge FS 2026-09-03 17:20:56 +08:00
Stephen Zhou
45d89701c3 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-03 17:03:32 +08:00
FFXN
25277f66d6 fix(knowledge-fs): harden semantic chunking and deletion cleanup
- retry semantic chunking when the LLM returns an invalid schema
- add corrective schema instructions to retry prompts
- remove capability grant foreign keys from durable deletion records
- preserve capability grant IDs as immutable audit provenance
- add migration and regression coverage
2026-09-03 17:00:17 +08:00
FFXN
de8e91fec2 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-03 17:00:08 +08:00
FFXN
644a9b96a6 fix(knowledge-fs): harden semantic chunking and deletion cleanup
- retry semantic chunking when the LLM returns an invalid schema
- add corrective schema instructions to retry prompts
- remove capability grant foreign keys from durable deletion records
- preserve capability grant IDs as immutable audit provenance
- add migration and regression coverage
2026-09-03 16:59:57 +08:00
Stephen Zhou
31e52a7eeb Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-03 16:57:39 +08:00
Stephen Zhou
3611a93a3a Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/services/test_knowledge_fs_initial_source_reliability.py
2026-09-03 16:57:14 +08:00
Stephen Zhou
ade154b878 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-03 16:50:05 +08:00
Stephen Zhou
611e7f6d17 test(knowledge-fs): allow canonical crawl duplicates 2026-09-03 16:49:44 +08:00
Stephen Zhou
e691e0bf1d Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-03 16:33:04 +08:00
Stephen Zhou
42cb6519ea Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-03 16:32:30 +08:00
FFXN
ad66d2c08a fix(knowledge-fs): update crawl function to accept WebsiteCrawlInput type 2026-09-03 16:31:53 +08:00
FFXN
239dedba48 fix(knowledge-fs): enhance website crawl handling with support for exact page fetching 2026-09-03 16:20:42 +08:00
FFXN
f51a759e0c Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-09-03 16:20:24 +08:00
FFXN
54335db161 fix(knowledge-fs): enhance website crawl handling with support for exact page fetching 2026-09-03 16:20:01 +08:00
FFXN
6d9d388c73 fix(knowledge-fs): enhance website crawl handling with support for exact page fetching 2026-09-03 16:19:47 +08:00
Jyong
c8aa2d4f59 feat(knowledge-fs): report specific, actionable background task failures
Every document-processing failure surfaced as "the document could not be
processed", and every data-source failure as "the source could not be read",
because the root cause was erased at three points: the compilation classifier
collapsed any coded-but-non-retryable error into DOCUMENT_COMPILATION_FAILED,
datasource runtime errors were reduced to per-operation constants, and the
console mapped dozens of codes onto a handful of generic sentences without the
stage or a support reference.

- knowledge-fs: keep the specific public code for any coded error in the
  compilation classifier (retry follows the error or the catalog policy), tag
  previously uncoded failures (semantic chunking output, embedding dimension,
  PDF rendering, lease loss, generation model, unsupported file type) with
  public codes, give embedding provider errors their own codes instead of the
  parser's, alias datasource runtime codes, and register 17 catalog entries
  (model response invalid, parser unsupported type, PDF render, crawl page
  not found, provider timeout/unavailable/rejected, content too large, ...).
- api: extend the public error-code enum (including the missing
  DOCUMENT_PARSER_TIMEOUT) and regenerate the contracts.
- web: per-code copy that says what happened and what to do, in all 24
  locales, plus a "failed while <stage> · reference <id>" line under each
  failed task in the background-task drawer and the document task list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 03:09:29 -04:00
Stephen Zhou
947b654d14 fix(knowledge-fs): allow dismissing finished tasks 2026-09-03 14:37:23 +08:00
Jyong
57d03faa98 fix(workflow): let the KnowledgeFS retrieval node run on text or image alone
The KnowledgeFS retrieval node treated the query text as mandatory even though
a query image is a complete input on its own. Text and image are now
alternatives: the node needs at least one of them, never both.

- web: drop the required mark from the query text field; the checklist fails
  only when neither variable is bound (new i18n message in every locale).
- api: make `query_variable_selector` optional with a model-level rule that
  one of the two selectors must be set, and only map the query variable when
  it is bound. At runtime an empty text without images is a configuration
  error; an image-only query retrieves with an empty text, merges spaces on
  their own scores instead of a text reranker, skips automatic metadata
  extraction, and skips the text-based failed-query capture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 01:52:19 -04:00
Jyong
4d71c4a83a fix(knowledge-fs): allow one unresolved bad case per retrieval trace
A retrieval test record could be flagged as a bad case any number of times,
piling duplicate open cases onto the quality page. Refuse to file another bad
case while the trace already has one that is open or replaying, and let the
retrieval test page know about it.

- knowledge-fs: `createBadCase` looks up an unresolved case for the trace
  inside its transaction and throws `QualityBadCaseAlreadyOpenError`; the
  create route answers 409 with `QUALITY_BAD_CASE_ALREADY_OPEN`. Quality trace
  list items expose `openBadCaseId`.
- api: pass `open_bad_case_id` through the console trace response;
  regenerate the contracts.
- web: show the saved-as-bad-case state instead of the flag action when the
  selected trace already has an unresolved case, treat a 409 on flagging the
  same way, and refresh the history so the state follows the server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 01:40:11 -04:00
Stephen Zhou
af7f2b75b3 fix(knowledge-fs): refresh contract subtree lock 2026-09-03 12:03:14 +08:00
Stephen Zhou
8901daa517 test(knowledge-fs): track patched fast-uri version 2026-09-03 11:48:20 +08:00
Stephen Zhou
1279b4008f fix(knowledge-fs): upgrade vulnerable fast-uri 2026-09-03 11:33:45 +08:00
Stephen Zhou
69fa1e1018 lint tailwind 2026-09-03 11:00:41 +08:00
Stephen Zhou
914aadcc4b fix(knowledge-fs): unify retrieval run action label 2026-09-03 10:58:32 +08:00
Stephen Zhou
299cf4f45a fix(knowledge-fs): refresh contract subtree lock 2026-09-02 22:28:11 +08:00
Stephen Zhou
abe15df3c3 test(knowledge-fs): track latest database migration 2026-09-02 22:15:18 +08:00
Stephen Zhou
45bee40955 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/controllers/console/datasets/datasets.py
#	api/tests/unit_tests/controllers/console/datasets/test_datasets.py
2026-09-02 21:56:53 +08:00
Stephen Zhou
2ad99bf5cd fix(knowledge-fs): show installed datasource providers 2026-09-02 21:52:33 +08:00
Stephen Zhou
523844a774 test(knowledge-fs): align migration entrypoint baseline 2026-09-02 21:48:58 +08:00
Stephen Zhou
9dbb9deff0 test(knowledge-fs): track query image migration 2026-09-02 21:32:29 +08:00
Jyong
afa771dcac feat(knowledge-fs): keep query images with retrieval test history
Query images attached to a retrieval test vanished as soon as the run's
history record was selected, and were never shown again after a reload,
because nothing persisted them: the gateway dropped `queryImages` when writing
`answer_traces`, the console trace/research responses had no image field, and
the composer cleared its images on every record selection.

- knowledge-fs: add a nullable JSON `query_images` column to `answer_traces`
  (migration 0049 for postgres and tidb), persist and read it in the answer
  trace repository, and expose `queryImages` on quality trace list items.
- api: enrich trace and research task responses with
  `KnowledgeFSQueryImageResponse`, resolving file names and short-lived signed
  preview URLs only for files the acting account still owns; regenerate the
  console and service contracts.
- web: keep composer images scoped to the selected record like the query
  text, remember images per run in this session, fall back to persisted
  previews (with a labelled placeholder when the file is gone), show them in
  the result panel and as a count on history records, and carry them into
  retests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-02 09:17:36 -04:00
Jyong
bf2e9f0781 fix(knowledge-fs): scope research checkpoints by the expanded retrieval query
A Research run with query images retrieves with the user's text joined to the
vision expansion, while the evidence bundle keeps the raw text as `query` and
records the expanded text as `retrievalQuery`. The durable checkpoint validator
compared the bundle's raw `query` against the search state's expanded query, so
every text-plus-image (and image-only) Research task failed with
"Research retrieval durable checkpoint scope mismatch" on its first boundary
and again on every retry. Compare the search state against the query that was
actually retrieved instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-02 09:17:14 -04:00
Stephen Zhou
5df6fc4ca0 style(workflow): format available node filter 2026-09-02 20:51:23 +08:00
Stephen Zhou
894003c663 test(knowledge-fs): align connected source workflow props 2026-09-02 20:50:40 +08:00
Stephen Zhou
76e7ca8ac0 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-02 20:48:35 +08:00
Stephen Zhou
bc6ff536df fix(knowledge-fs): refresh quality evaluation progress 2026-09-02 20:48:26 +08:00
Stephen Zhou
2b16c5baf1 fix(knowledge-fs): refresh contract subtree lock 2026-09-02 20:33:15 +08:00
Stephen Zhou
2f8dd177d3 fix(knowledge-fs): stabilize research result streaming 2026-09-02 20:10:24 +08:00
FFXN
46c345401f fix(knowledge-fs): cap object-list page size and enforce entity name length limits 2026-09-02 19:18:18 +08:00
FFXN
567f4f5ab4 fix(knowledge-fs): enhance source import handling and error recovery for remote updates 2026-09-02 18:38:38 +08:00
FFXN
1e8814838f Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-02 18:08:26 +08:00
FFXN
9dd7e3f5f8 fix(knowledge-fs): repair sync policy version handling to accommodate concurrent updates 2026-09-02 18:07:57 +08:00
Stephen Zhou
158180d32c fix(knowledge-fs): remove graph metrics from overview 2026-09-02 17:13:06 +08:00
Stephen Zhou
d55beba866 tweaks 2026-09-02 16:59:53 +08:00
Stephen Zhou
2da03248de fix(knowledge-fs): allow reopening golden question CSV import 2026-09-02 15:49:30 +08:00
Jyong
0340784d57 feat(knowledge-fs): add automatic metadata filtering to retrieval node
Bring the LLM-driven "automatic" metadata filtering mode from the legacy
Knowledge Retrieval node to the KnowledgeFS retrieval node
(knowledge-retrieval-v2).

Backend:
- Accept `automatic` in `metadata_filtering_mode` and add
  `metadata_model_config` to the node entity.
- Add `automatic_metadata_filter.py`, which reuses the legacy metadata
  filter prompt templates, invokes the configured model, parses the JSON
  answer and normalises operators/values against the KnowledgeFS
  metadata catalog before building custom metadata conditions.
- Resolve the shared metadata catalog across the selected spaces through
  the new `list_metadata_fields` app execution capability (paginated),
  fail closed on missing/unavailable models and catalog admission errors,
  fail open on extraction errors, and expose the outcome via
  `metrics.metadata_filtering` and `llm_usage`.
- Inject the default extractor from the node factory.
- Fix an unescaped brace in the legacy completion prompt template that
  broke `.format()`.

Frontend:
- Offer disabled/automatic/manual modes, normalise unknown persisted
  modes to disabled, store the automatic model and completion params,
  render the model selector through the shared MetadataFilter, and
  require a model when saving in automatic mode.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnUM1CdV5LpcxZEQ2vu9Xc
2026-09-02 02:55:12 -04:00
Stephen Zhou
d3162a67b4 test(knowledge-fs): cover query image validation 2026-09-02 14:22:56 +08:00
Stephen Zhou
df17fc4f2a fix(knowledge-fs): align query image types 2026-09-02 14:06:14 +08:00
Jyong
049a4c514e fix(knowledge-fs): show task object titles 2026-09-02 01:43:16 -04:00
Jyong
222d09f474 feat(knowledge-fs): add profile-driven multimodal retrieval 2026-09-02 01:01:57 -04:00
FFXN
b55ec59d37 fix(knowledge-fs): handle source selection mismatch and improve error handling, make source selection replacement durable. 2026-09-02 12:56:37 +08:00
Stephen Zhou
12ad3c6ca6 fix(knowledge-fs): align Vinext query dependencies 2026-09-02 11:22:16 +08:00
Stephen Zhou
bd3addf6f3 fix(knowledge-fs): reserve document scrollbar gutter (WTA-2317)
Linear: https://linear.app/dify/issue/WTA-2317/uiux-%E6%96%87%E6%A1%A3%E9%98%85%E8%AF%BB%E5%8C%BA%E6%BB%9A%E5%8A%A8%E6%9D%A1%E4%B8%8D%E9%81%AE%E6%8C%A1%E5%86%85%E5%AE%B9
2026-09-02 11:01:07 +08:00
Stephen Zhou
2f9908eda8 fix(knowledge-fs): hug recent activity height (WTA-2315)
Linear: https://linear.app/dify/issue/WTA-2315/uiux-recent-activity-%E5%9C%A8-7-%E6%9D%A1%E4%BB%A5%E5%86%85%E4%BD%BF%E7%94%A8%E5%86%85%E5%AE%B9-hug-%E9%AB%98%E5%BA%A6
2026-09-02 10:55:12 +08:00
FFXN
052bdb4978 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-02 10:50:52 +08:00
FFXN
e39a40d817 fix(knowledge-fs): use control space ID when editing sources 2026-09-02 10:49:02 +08:00
Stephen Zhou
1b4bc1ca2e test(auth): align password reset policy mock 2026-09-02 10:39:43 +08:00
Stephen Zhou
cd7e1be7cb Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/services/feature_service.py
#	api/tests/unit_tests/controllers/console/workspace/test_members.py
#	api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py
#	api/tests/unit_tests/services/test_system_feature_service_knowledge_fs.py
#	pnpm-lock.yaml
2026-09-02 10:31:10 +08:00
Stephen Zhou
f85b9ddf8b refactor(knowledge-fs): bridge URL state into Jotai 2026-09-02 10:10:11 +08:00
Jyong
a15ca672a1 fix(knowledge-fs): align retrieval test timeout 2026-09-01 13:51:15 -04:00
Jyong
2ad192fce7 fix(knowledge-fs): harden research retrieval execution 2026-09-01 13:26:43 -04:00
Stephen Zhou
930de8196a refactor(knowledge-fs): decompose sources page 2026-09-02 00:31:11 +08:00
Stephen Zhou
d3f59d8458 refactor(knowledge-fs): decompose overview page 2026-09-01 23:21:34 +08:00
Stephen Zhou
8780803f2d refactor(knowledge-fs): decompose quality page 2026-09-01 22:59:24 +08:00
Stephen Zhou
8665083d51 refactor(knowledge-fs): decompose retrieval test page 2026-09-01 22:31:22 +08:00
Stephen Zhou
094f2712b9 fix(knowledge-fs): scope overview failure alert to onboarding 2026-09-01 20:54:49 +08:00
Stephen Zhou
73e449c3ef fix(workflow): allow KnowledgeFS top n up to 100 2026-09-01 20:22:56 +08:00
FFXN
66a93d5dfb fix(knowledge-fs): update subtreeTree hash in lock file 2026-09-01 20:17:51 +08:00
Stephen Zhou
3c7e197afe fix(workflow): reset KnowledgeFS rerank override 2026-09-01 20:07:55 +08:00
FFXN
98fb48de18 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-01 20:03:01 +08:00
FFXN
2e6af36f77 fix(knowledge-fs): ensure balanced parentheses in SQL query for document retrieval 2026-09-01 20:02:36 +08:00
FFXN
3d5f8b6d2b fix(knowledge-fs): ensure balanced parentheses in SQL query for document retrieval 2026-09-01 20:02:17 +08:00
Stephen Zhou
9fa8838b0d docs(frontend): clarify scoped route bridges 2026-09-01 19:56:30 +08:00
Stephen Zhou
1dd3775dde fix(knowledge-fs): use low effort for OpenRouter reasoning 2026-09-01 19:52:20 +08:00
Stephen Zhou
bbfea75857 fix(knowledge-fs): balance lifecycle fence sql 2026-09-01 19:44:10 +08:00
FFXN
78a9b3b18f Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-09-01 19:34:45 +08:00
FFXN
aff51ae6f3 fix(knowledge-fs): retry source selection CAS conflicts and log workflow errors 2026-09-01 19:32:54 +08:00
FFXN
9b85d7bead fix(knowledge-fs): retry source selection CAS conflicts and log workflow errors 2026-09-01 19:32:39 +08:00
Stephen Zhou
4dba1bc22e fix(knowledge-fs): isolate document detail route state 2026-09-01 19:32:24 +08:00
Stephen Zhou
a7a16c1335 fix(web): resolve marketplace plugin ids before install 2026-09-01 18:25:08 +08:00
Stephen Zhou
e9180d38e5 refactor(knowledge-fs): remove new rag edit guards 2026-09-01 18:02:18 +08:00
Jyong
dbcc3a11e5 fix(knowledge-fs): type retrieval lease clock 2026-09-01 05:37:51 -04:00
Stephen Zhou
368a7c91aa fix(knowledge-fs): keep score threshold visible 2026-09-01 17:29:25 +08:00
Stephen Zhou
75a7f4e5ba refactor(knowledge-fs): reuse dataset chunk image list 2026-09-01 17:22:44 +08:00
Jyong
7876f6e670 fix(knowledge-fs): scope child deletion conflicts 2026-09-01 05:18:38 -04:00
Stephen Zhou
3bc51c70d5 fix(knowledge-fs): keep document images compact 2026-09-01 17:07:03 +08:00
Stephen Zhou
6ce859d955 fix(knowledge-fs): center document detail loading state 2026-09-01 16:56:20 +08:00
Stephen Zhou
f5de0995d6 docs(frontend): clarify component state boundaries 2026-09-01 16:46:16 +08:00
Stephen Zhou
fa3e52fa9e refactor(knowledge-fs): reorganize settings ownership 2026-09-01 16:46:05 +08:00
Stephen Zhou
a1f6a28a1c docs(frontend): reorganize component architecture skill 2026-09-01 15:31:26 +08:00
Stephen Zhou
47aaadf587 docs(frontend): clarify forced hydration bridges 2026-09-01 14:56:12 +08:00
Stephen Zhou
c5d7ab5b48 refactor(knowledge-fs): hydrate document detail bridges synchronously 2026-09-01 14:56:04 +08:00
Stephen Zhou
9e8b0a6b8a refactor(knowledge-fs): reset document task pagination on close 2026-09-01 14:43:38 +08:00
Stephen Zhou
62e07b1cfd refactor(knowledge-fs): model document reindex preflight 2026-09-01 14:43:38 +08:00
Stephen Zhou
439dcfe21e refactor(knowledge-fs): query document multimodal assets 2026-09-01 14:37:22 +08:00
Stephen Zhou
dec1c302fa refactor(knowledge-fs): separate document task row ownership 2026-09-01 14:34:36 +08:00
Stephen Zhou
163cdce23d refactor(knowledge-fs): expose document detail field atoms 2026-09-01 14:28:38 +08:00
Stephen Zhou
480f708934 refactor(knowledge-fs): establish document metadata workflow 2026-09-01 14:27:28 +08:00
Stephen Zhou
3bea683c78 refactor(knowledge-fs): clarify document workflow dependencies 2026-09-01 14:20:40 +08:00
Stephen Zhou
281c6548c0 refactor(knowledge-fs): localize document chunk navigation 2026-09-01 14:17:36 +08:00
Stephen Zhou
d81aa3758e refactor(knowledge-fs): localize document title focus 2026-09-01 14:10:05 +08:00
Stephen Zhou
a5166c6834 fix(knowledge-fs): synchronize document detail bridges after render 2026-09-01 13:28:16 +08:00
Stephen Zhou
90f9ff3961 refactor(knowledge-fs): localize document task drawer 2026-09-01 13:22:34 +08:00
Stephen Zhou
611193a139 refactor(knowledge-fs): model document workflow state 2026-09-01 13:19:27 +08:00
Stephen Zhou
e1aed49d40 refactor(knowledge-fs): model document revision state 2026-09-01 13:09:24 +08:00
Stephen Zhou
0ece06dde5 refactor(knowledge-fs): establish document detail state boundary 2026-09-01 13:04:23 +08:00
Stephen Zhou
663afab2c2 docs(frontend): align component skill with state graphs 2026-09-01 12:41:24 +08:00
Stephen Zhou
f131b5135a refactor(knowledge-fs): isolate document title synchronization 2026-09-01 12:19:12 +08:00
Stephen Zhou
8c7b9fbbdd refactor(knowledge-fs): split document workflow notices 2026-09-01 12:17:51 +08:00
Stephen Zhou
ac3a1d1b77 refactor(knowledge-fs): establish document workflow ownership 2026-09-01 12:16:41 +08:00
Stephen Zhou
97c62bafc6 refactor(knowledge-fs): own document reindex action 2026-09-01 12:13:38 +08:00
Stephen Zhou
7806639749 refactor(knowledge-fs): localize document task surface 2026-09-01 12:12:14 +08:00
Stephen Zhou
539433f977 refactor(knowledge-fs): localize document revision ownership 2026-09-01 12:08:46 +08:00
Stephen Zhou
682c76ffbd docs(frontend): require component boundary redesigns 2026-09-01 11:48:27 +08:00
Stephen Zhou
9188db85aa docs(frontend): route component architecture audits 2026-09-01 11:33:28 +08:00
Stephen Zhou
31c7b13278 docs(frontend): audit component state and prop paths 2026-09-01 11:03:36 +08:00
Stephen Zhou
c99fcf0420 docs(frontend): tighten state and prop ownership 2026-09-01 10:58:27 +08:00
Stephen Zhou
39d631bbb7 docs(frontend): add component architecture anti-patterns 2026-09-01 10:46:36 +08:00
Stephen Zhou
c1b034b55d docs(frontend): clarify analyzable state graphs 2026-09-01 10:35:51 +08:00
Stephen Zhou
484d4c3e68 test(knowledge-fs): cover rbac role tag resolution 2026-09-01 10:29:57 +08:00
Stephen Zhou
5770ebdba8 fix(knowledge-fs): reconcile main merge regressions 2026-09-01 10:10:52 +08:00
Stephen Zhou
ecd41aed3f Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-09-01 09:45:49 +08:00
Stephen Zhou
061f12df59 refactor(knowledge-fs): localize document page state ownership 2026-08-31 23:34:37 +08:00
Stephen Zhou
f45964ec1e test(knowledge-fs): colocate document owner coverage 2026-08-31 20:27:12 +08:00
Stephen Zhou
1eab64ace3 refactor(knowledge-fs): encapsulate task runtime observers 2026-08-31 20:23:49 +08:00
Stephen Zhou
264e054707 refactor(knowledge-fs): isolate document results surface 2026-08-31 20:21:59 +08:00
Stephen Zhou
b82c426285 refactor(knowledge-fs): move document bulk selection and actions 2026-08-31 20:17:28 +08:00
Stephen Zhou
da6fb87ebe refactor(knowledge-fs): move document row action ownership 2026-08-31 20:12:18 +08:00
Stephen Zhou
d18325cab7 refactor(knowledge-fs): move document upload surface ownership 2026-08-31 20:06:32 +08:00
Stephen Zhou
ed8cfe3400 refactor(knowledge-fs): isolate document permission recovery 2026-08-31 19:59:55 +08:00
Jyong
4f3a2952d6 perf(knowledge-fs): harden cross-format document ingestion 2026-08-31 07:48:35 -04:00
Stephen Zhou
b45b00fb8e refactor(knowledge-fs): extract document task runtime 2026-08-31 19:39:53 +08:00
Stephen Zhou
c5a2b8c66d refactor(knowledge-fs): unify source setup ownership 2026-08-31 19:23:04 +08:00
Stephen Zhou
385df8c7ed refactor(knowledge-fs): organize new rag product surfaces 2026-08-31 19:00:34 +08:00
Stephen Zhou
cc44f54637 refactor(knowledge-fs): isolate document runtime coordination 2026-08-31 18:53:00 +08:00
Stephen Zhou
79d1844020 refactor(knowledge-fs): organize new rag feature boundaries 2026-08-31 18:45:40 +08:00
FFXN
237d1b8d04 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-31 18:19:41 +08:00
FFXN
2bcfc139c3 fix(knowledge-fs): add SOURCE_DOCUMENT_COMPILATION_FAILED error code and update DTOs 2026-08-31 18:19:23 +08:00
Stephen Zhou
75a5513f9c fix(knowledge-fs): align remaining page headers 2026-08-31 18:17:12 +08:00
FFXN
f80f30e964 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-08-31 18:13:27 +08:00
FFXN
8c472c217b fix(knowledge-fs): add SOURCE_DOCUMENT_COMPILATION_FAILED error code and update DTOs 2026-08-31 18:12:42 +08:00
Stephen Zhou
14f6511b60 refactor(knowledge-fs): structure report navigation row 2026-08-31 18:09:22 +08:00
Stephen Zhou
ecf37a52a7 fix(knowledge-fs): optically align report navigation 2026-08-31 18:07:08 +08:00
Stephen Zhou
babd036f2a fix(knowledge-fs): align evaluation report spacing 2026-08-31 18:05:35 +08:00
Stephen Zhou
37b36997ad fix(knowledge-fs): align quality page spacing 2026-08-31 18:03:24 +08:00
Stephen Zhou
b6361fc3f0 fix(knowledge-fs): sync evaluation copy translations 2026-08-31 17:50:53 +08:00
Stephen Zhou
4afe80b368 fix(knowledge-fs): align evaluation dialog copy 2026-08-31 17:46:40 +08:00
Stephen Zhou
43cf1087cb fix(knowledge-fs): align evaluation date formatting 2026-08-31 17:41:31 +08:00
Stephen Zhou
11d184ddae refactor(knowledge-fs): split quality page state ownership 2026-08-31 17:32:07 +08:00
Stephen Zhou
3983f979b7 fix(knowledge-fs): align quality evaluation layout 2026-08-31 17:10:12 +08:00
Stephen Zhou
76191eb3a1 feat(knowledge-fs): align evaluation UI with design 2026-08-31 15:12:02 +08:00
Stephen Zhou
a5c4578986 feat(knowledge-fs): align source editing with update API 2026-08-31 13:48:22 +08:00
Stephen Zhou
9abfd110cc fix(knowledge-fs): refresh contract subtree lock 2026-08-31 13:08:59 +08:00
Stephen Zhou
d0b33b2c06 fix(knowledge-fs): extend unstructured document timeout 2026-08-31 12:49:34 +08:00
Jyong
b2aff3432c fix(knowledge): preserve traces with deleted evidence 2026-08-30 23:16:37 -04:00
FFXN
d23b9229db feat(knowledge-fs): enhance source update payload with selection and sync policy validation 2026-08-31 00:00:37 +08:00
FFXN
e7086d5b1a Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-08-31 00:00:26 +08:00
FFXN
949549b9b1 feat(knowledge-fs): enhance source update payload with selection and sync policy validation 2026-08-30 23:58:27 +08:00
Stephen Zhou
0fd0122400 fix(knowledge-fs): refresh contract subtree lock 2026-08-30 23:53:18 +08:00
Stephen Zhou
e578e55e7b fix(knowledge-fs): harden long document ingestion 2026-08-30 23:45:22 +08:00
FFXN
72461af522 feat(knowledge-fs): support source config updates and deferred sync
- allow updating mutable source configuration fields
- enqueue sync after active business configuration changes
- pause scheduled sync while a source is disabled
- preserve and rebind sync policies across source version updates
- support website, online document, and online drive sources
2026-08-30 22:12:21 +08:00
FFXN
ecba1aa779 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-08-30 22:12:01 +08:00
FFXN
52df787444 feat(knowledge-fs): support source config updates and deferred sync
- allow updating mutable source configuration fields
- enqueue sync after active business configuration changes
- pause scheduled sync while a source is disabled
- preserve and rebind sync policies across source version updates
- support website, online document, and online drive sources
2026-08-30 22:10:12 +08:00
Stephen Zhou
adf650cc83 fix(knowledge-fs): refresh contract subtree lock 2026-08-30 22:08:44 +08:00
Stephen Zhou
138e810f9c fix(knowledge-fs): improve Chinese PDF ingestion and retrieval 2026-08-30 22:00:34 +08:00
Stephen Zhou
84c53bdfde fix(knowledge-fs): remove start mode hover background 2026-08-28 16:30:28 +08:00
Stephen Zhou
f51552508d fix(knowledge-fs): use workspace upload size limit in new rag 2026-08-28 16:16:24 +08:00
FFXN
16f29bcba0 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-28 15:57:00 +08:00
FFXN
7a16aaf0a1 fix(knowledge-fs): add rebase functionality for publication head revision and enhance error handling 2026-08-28 15:56:09 +08:00
Stephen Zhou
83e827e0b4 fix(knowledge-fs): remove provider sync policy from frontend 2026-08-28 15:55:49 +08:00
FFXN
d76bdb80c6 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-28 15:53:01 +08:00
FFXN
c81cf7e17b fix(knowledge-fs): add rebase functionality for publication head revision and enhance error handling 2026-08-28 15:52:43 +08:00
Stephen Zhou
54f74f8bdb fix(knowledge-fs): align API access with dataset keys 2026-08-28 15:22:05 +08:00
Stephen Zhou
200b71dca5 test(knowledge-fs): align sync policy expectations 2026-08-28 15:09:44 +08:00
FFXN
3df41373be Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-28 15:09:01 +08:00
FFXN
1212b92bf1 fix(knowledge-fs): remove provider mode from sync policies and update related constraints 2026-08-28 15:08:11 +08:00
Stephen Zhou
d762ff329a Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-28 14:55:27 +08:00
Stephen Zhou
9f31d49b7f test(knowledge-fs): track latest database migration 2026-08-28 14:52:46 +08:00
Stephen Zhou
9098955a77 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/controllers/console/test_knowledge_fs_proxy.py
#	api/tests/unit_tests/services/test_knowledge_fs_proxy.py
#	web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts
2026-08-28 14:50:39 +08:00
FFXN
65dda2d1fb fix(knowledge-fs): remove provider mode from sync policies and update related constraints 2026-08-28 14:43:44 +08:00
FFXN
c1b5d09196 fix(knowledge-fs): remove provider mode from sync policies and update related constraints 2026-08-28 14:43:22 +08:00
FFXN
be3934ee17 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
#	knowledge-fs/packages/database/migrations/0046_remove_provider_sync_policy.tidb.sql
#	knowledge-fs/packages/database/src/migration-artifacts.generated.ts
2026-08-28 14:30:03 +08:00
FFXN
90b6c32549 fix(knowledge-fs): remove provider mode from sync policies and update related constraints 2026-08-28 14:24:38 +08:00
FFXN
4db73de307 fix(knowledge-fs): remove provider mode from sync policies and update related constraints 2026-08-28 14:24:20 +08:00
Stephen Zhou
8c85ecdb56 fix(knowledge-fs): avoid credential-like migration identifier 2026-08-28 14:20:26 +08:00
FFXN
ae495d3f97 fix(knowledge-fs): remove provider mode from sync policies and update related constraints 2026-08-28 14:06:49 +08:00
Stephen Zhou
20fc6c130d fix(knowledge-fs): prevent duplicate overview scrollbar 2026-08-28 12:24:55 +08:00
Stephen Zhou
3938d91c75 fix(knowledge-fs): refresh contract lock 2026-08-28 00:23:10 +08:00
Stephen Zhou
b155a0e1b9 fix(knowledge-fs): format source update payload 2026-08-28 00:08:18 +08:00
Stephen Zhou
7e34a2ed07 fix(knowledge-fs): refine sync policy options 2026-08-28 00:01:26 +08:00
Stephen Zhou
e5dc72812a fix(knowledge-fs): support source configuration editing 2026-08-27 23:52:18 +08:00
Jyong
7d1b7f97f0 fix(knowledge-fs): unblock upload completion 2026-08-27 07:24:56 -04:00
Jyong
6702314998 fix(knowledge-fs): scope deletion admission to target 2026-08-27 06:29:09 -04:00
Stephen Zhou
90c8ea48e2 fix(knowledge-fs): refresh contract lock 2026-08-27 18:07:47 +08:00
Stephen Zhou
7c3b4aa3c6 fix(knowledge-fs): preserve DOCX image placement 2026-08-27 17:55:02 +08:00
Jyong
42b1be58f3 fix(knowledge-fs): repair document deletion task progress 2026-08-27 04:35:05 -04:00
Jyong
e902bfc502 chore(knowledge-fs): refresh contract lock 2026-08-27 03:02:14 -04:00
Jyong
12adc39383 fix(knowledge-fs): preserve spreadsheet image placement 2026-08-27 03:01:57 -04:00
FFXN
6feed72a06 fix(knowledge-fs): enhance embedding profile migration handling and retry logic 2026-08-27 11:27:51 +08:00
FFXN
9f1b0a445e fix(knowledge-fs): enhance embedding profile migration handling and retry logic 2026-08-27 11:27:12 +08:00
FFXN
31523c240b fix(knowledge-fs): implement profile migration projection promotion logic 2026-08-27 10:25:37 +08:00
FFXN
6bbf1a5c47 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-27 10:24:08 +08:00
FFXN
3e5febec04 fix(knowledge-fs): implement profile migration projection promotion logic 2026-08-27 10:23:43 +08:00
Stephen Zhou
d6e02174d8 test(migrations): track latest alembic head 2026-08-27 10:22:22 +08:00
Stephen Zhou
a91801dcbe Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-27 10:12:07 +08:00
Stephen Zhou
30ed0907f3 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/controllers/console/app/site.py
#	api/controllers/console/datasets/datasets.py
#	api/core/plugin/backwards_invocation/model.py
#	api/core/rag/rerank/rerank_model.py
#	api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth_server.py
#	api/tests/unit_tests/core/plugin/test_backwards_invocation_model.py
#	api/tests/unit_tests/tasks/test_batch_clean_document_task.py
#	packages/contracts/generated/api/service/orpc.gen.ts
#	web/app/components/datasets/rename-modal/index.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx
#	web/features/tag-management/components/tag-search-content.tsx
2026-08-27 10:06:48 +08:00
FFXN
9ec1e4e8c3 fix(knowledge-fs): update projection status to 'building' during profile migration 2026-08-27 10:00:04 +08:00
FFXN
304a06f61d Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-27 09:59:29 +08:00
FFXN
756a3ab4bf fix(knowledge-fs): update projection status to 'building' during profile migration 2026-08-27 09:59:08 +08:00
Stephen Zhou
98caad8a6a fix(knowledge-fs): refresh contract lock 2026-08-26 19:36:05 +08:00
Stephen Zhou
b6c55ab68d feat(knowledge-fs): allow community development access 2026-08-26 19:26:58 +08:00
Jyong
499abe4fca fix(knowledge-fs): segment structured tables by record 2026-08-26 06:38:30 -04:00
Stephen Zhou
ca8e0bc716 fix(web): restore model selector search spacing 2026-08-26 18:20:01 +08:00
Stephen Zhou
d7cd2f482e fix(knowledge-fs): gate availability by deployment edition 2026-08-26 17:52:12 +08:00
Jyong
c195854260 fix(knowledge-fs): bound document compilation memory 2026-08-26 04:51:07 -04:00
FFXN
e0fe418b3a Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-26 16:06:09 +08:00
FFXN
06c9ec2d73 test(knowledge_fs): add tests for service profile and issuance authorization errors 2026-08-26 16:05:27 +08:00
Jyong
77a5de2fc9 fix(knowledge-fs): persist document images across formats 2026-08-26 03:58:04 -04:00
FFXN
207640bcd1 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-26 15:42:54 +08:00
FFXN
18155535cf refactor(knowledge_fs): replace credential service with dataset API key authorization. Remove the API token feature at the space level for knowledgeFs. 2026-08-26 15:40:18 +08:00
Stephen Zhou
3cc31cf9b5 fix(knowledge_fs): indent document contents by nesting level (WTA-2230) 2026-08-26 12:23:48 +08:00
Stephen Zhou
30435e8123 fix(knowledge_fs): align page headers with sidebar (WTA-2229) 2026-08-26 12:15:16 +08:00
Stephen Zhou
677eeaf5a2 fix(knowledge_fs): use standard toasts for settings saves (WTA-2227) 2026-08-26 12:03:03 +08:00
Stephen Zhou
faf7c29900 fix(knowledge_fs): wrap long card descriptions (WTA-2226) 2026-08-26 11:53:54 +08:00
Stephen Zhou
eeade9c19f fix(knowledge_fs): allow updating external access before profile activation 2026-08-26 11:38:50 +08:00
Stephen Zhou
d002f60272 fix(knowledge_fs): prevent settings page overflow (WTA-2224) 2026-08-26 11:25:55 +08:00
FFXN
b64952b71f fix(knowledge_fs): add support for indexing object-backed images with no text 2026-08-26 11:22:42 +08:00
FFXN
98beb7d742 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-26 11:21:42 +08:00
FFXN
8193b2ffc7 fix(knowledge_fs): add support for indexing object-backed images with no text 2026-08-26 11:20:59 +08:00
Stephen Zhou
115873d287 fix(knowledge_fs): reuse number fields in knowledge settings (WTA-2224) 2026-08-26 11:14:21 +08:00
Stephen Zhou
44a0d84c24 fix(knowledge_fs): reuse legacy permission member picker (WTA-2225) 2026-08-26 10:53:49 +08:00
Stephen Zhou
63efda734c test(knowledge_fs): restore parser branch coverage 2026-08-25 20:17:19 +08:00
Stephen Zhou
979f09b30d fix(web): simplify document upload format guidance 2026-08-25 20:00:30 +08:00
Stephen Zhou
d5fd45bb41 feat(knowledge_fs): expand supported document upload formats 2026-08-25 19:58:15 +08:00
samzong
46ba3f8b60 fix(knowledge_fs): pin contract lock to the current knowledge-fs tree
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-08-25 06:03:16 -04:00
Stephen Zhou
22de6d29a4 fix(knowledge_fs): refresh contract subtree lock 2026-08-25 17:57:05 +08:00
Stephen Zhou
644e8f99bc fix(knowledge_fs): format research threshold metrics 2026-08-25 17:48:28 +08:00
Stephen Zhou
e8a8a8fda2 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-25 17:42:53 +08:00
Stephen Zhou
a562d9517b fix(knowledge_fs): use supported research intent in threshold test 2026-08-25 17:42:23 +08:00
samzong
19ab9027be fix(knowledge_fs): use a valid ResearchQueryIntent in the retrieval test
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-08-25 05:41:35 -04:00
samzong
1a7a90a307 fix(knowledge_fs): let Research evaluation use the production Evidence V3 contract
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-08-25 05:33:28 -04:00
FFXN
4768283500 fix(knowledge_fs): accept direct unpublished profile activation responses and validate settings 2026-08-25 16:31:48 +08:00
FFXN
583fea65b6 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-25 16:31:12 +08:00
FFXN
013ddcf034 fix(knowledge_fs): accept direct unpublished profile activation responses and validate settings 2026-08-25 16:30:46 +08:00
Stephen Zhou
beb599c163 fix(web): clarify knowledge settings permission errors
Keep rejected Score Threshold drafts intact, show the localized permission message for 403 responses, and avoid duplicate error toasts.\n\nLinear: https://linear.app/dify/issue/WTA-2201/修复-score-threshold-保存时的权限错误提示
2026-08-25 15:15:25 +08:00
FFXN
fbc7c85af1 fix(knowledge_fs): integrate Capability v2 grant for unpublished profile updates 2026-08-25 15:14:22 +08:00
FFXN
4a51c50e91 fix(knowledge_fs): integrate Capability v2 grant for unpublished profile updates 2026-08-25 15:14:12 +08:00
Stephen Zhou
edaa45cb6b fix(web): improve retrieval evidence navigation 2026-08-24 16:48:36 +08:00
FFXN
aea973e11e fix(knowledge_fs): skip projections for nodes without searchable text and optimize FTS generation 2026-08-24 16:17:39 +08:00
FFXN
b9f859a3d2 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-24 16:17:28 +08:00
FFXN
84c715aa05 fix(knowledge_fs): skip projections for nodes without searchable text and optimize FTS generation 2026-08-24 16:16:37 +08:00
Stephen Zhou
7c90d80bab fix(knowledge-fs): allow downloading failed documents
Keep frontend download eligibility aligned with the backend and fetch only the latest failed revision asset.\n\nLinear: https://linear.app/dify/issue/WTA-2162/allow-downloading-documents-when-indexing-fails
2026-08-24 15:35:25 +08:00
FFXN
dc2ed1c61a fix(knowledge_fs): skip projections for nodes without searchable text and optimize FTS generation 2026-08-24 15:16:52 +08:00
FFXN
6844f8b258 fix(knowledge_fs): skip projections for nodes without searchable text and optimize FTS generation 2026-08-24 15:16:07 +08:00
Stephen Zhou
4df871499b fix(migration): update expected alembic head 2026-08-24 11:25:46 +08:00
Stephen Zhou
480ec56f94 fix(web): restore quality dialog close positioning 2026-08-24 11:07:23 +08:00
Stephen Zhou
0564b75187 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/commands/test_generate_swagger_specs.py
#	api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py
#	api/tests/unit_tests/extensions/test_ext_login.py
#	packages/dify-ui/src/alert-dialog/index.tsx
#	web/app/components/datasets/external-api/external-api-modal/index.tsx
#	web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx
#	web/features/new-rag/document-list.tsx
#	web/features/new-rag/sources-page.tsx
2026-08-24 11:03:38 +08:00
Jyong
3cfb1d43ba fix(knowledge-fs): show initial import sync time 2026-08-21 05:32:15 -04:00
Stephen Zhou
3f36ff11f7 fix(web): localize new RAG view state 2026-08-21 16:41:50 +08:00
Stephen Zhou
3c13cc2565 refactor(web): split new RAG page responsibilities 2026-08-21 16:30:59 +08:00
Stephen Zhou
62ca5184a3 fix(web): stabilize new RAG frontend flows 2026-08-21 16:20:16 +08:00
Stephen Zhou
bba3e8008f fix(web): harden new RAG frontend behavior 2026-08-21 16:05:13 +08:00
Jyong
79b034d115 fix(knowledge-fs): reconcile completed source retries 2026-08-21 04:02:43 -04:00
Stephen Zhou
da650b63e5 fix(web): correct new RAG frontend behavior 2026-08-21 15:31:59 +08:00
Jyong
2ef7719b02 fix(knowledge-fs): allow replay evidence detail query 2026-08-21 03:10:14 -04:00
Stephen Zhou
0864965d2c fix(migration): merge agent soul cleanup head 2026-08-21 11:01:39 +08:00
Stephen Zhou
1790ff085b test: restore coverage after main merge 2026-08-21 10:50:43 +08:00
Stephen Zhou
e6fe2cd5d7 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py
#	api/tests/unit_tests/services/test_async_workflow_service.py
#	web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
2026-08-21 10:39:39 +08:00
Stephen Zhou
e2993ee371 fix(knowledge-fs): unify provider credential cards 2026-08-20 15:01:14 +08:00
Stephen Zhou
7422627d8c fix(knowledge-fs): ignore connections with removed credentials 2026-08-20 14:51:36 +08:00
Stephen Zhou
34ab95212c fix(knowledge-fs): move sync policy fields to form bottom 2026-08-20 14:37:05 +08:00
Jyong
63cfebbef3 fix(knowledge-fs): constrain research graph planning 2026-08-20 02:30:59 -04:00
Jyong
8096e61133 fix(knowledge-fs): accept structured research plan booleans 2026-08-20 01:56:19 -04:00
Stephen Zhou
d061d5ed3e fix(knowledge-fs): finalize async source imports 2026-08-20 12:01:24 +08:00
Stephen Zhou
4880c5c771 feat(knowledge-fs): migrate add source to async imports 2026-08-20 11:30:43 +08:00
FFXN
eda5791c28 feat(knowledge-fs): add migration to merge agent drive and KnowledgeFS heads 2026-08-20 11:04:02 +08:00
FFXN
b1ebfd3a56 feat(knowledge-fs): implement asynchronous source import and related payloads 2026-08-20 10:36:41 +08:00
FFXN
845ab7b000 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-20 10:35:35 +08:00
FFXN
b4219e544d feat(knowledge-fs): implement asynchronous source import and related payloads 2026-08-20 10:35:08 +08:00
FFXN
96346219b3 feat(knowledge-fs): implement asynchronous source import and related payloads 2026-08-20 10:34:53 +08:00
Stephen Zhou
8888fcb038 test(account): cover email code login challenge 2026-08-20 10:27:31 +08:00
Stephen Zhou
a50ea7d56d Merge branch 'deploy/konwledge' of https://github.com/langgenius/dify into deploy/konwledge 2026-08-20 10:18:08 +08:00
Stephen Zhou
db8c40b23e Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-08-20 10:11:26 +08:00
Jyong
47f26b001b fix(knowledge): score research evidence by query intent 2026-08-19 15:09:13 -04:00
Jyong
573637b89e fix(knowledge): bound research judge to one 8k call 2026-08-19 13:46:11 -04:00
Jyong
0aa7a2ac84 fix(knowledge): simplify query activity feed 2026-08-19 10:22:39 -04:00
Jyong
0391141936 feat(quality): show replay evidence details 2026-08-19 09:45:41 -04:00
Jyong
53df42c890 fix(web): center quality evaluation dialog 2026-08-19 07:39:27 -04:00
Jyong
90d4bdec2e fix(ci): apply knowledge migrations after deploy 2026-08-19 07:19:29 -04:00
Jyong
a98008aff0 fix(knowledge): keep quality replay history visible 2026-08-19 07:01:14 -04:00
Stephen Zhou
20887e06c2 fix(web): interpolate provider connection messages 2026-08-19 18:57:41 +08:00
Jyong
ddee85e6ae fix(knowledge-fs): refresh contract lock 2026-08-19 06:37:06 -04:00
Stephen Zhou
beddf6ef3e fix(knowledge-fs): type replay query parameters 2026-08-19 18:23:57 +08:00
Stephen Zhou
4008b451c6 fix(web): align source list with Figma (WTA-2108) 2026-08-19 18:09:38 +08:00
Stephen Zhou
165c0f0665 fix(web): update Confluence icon on source tab
Refs WTA-2090
2026-08-19 18:09:38 +08:00
Stephen Zhou
a8ff128ca4 test(knowledge-fs): account for quality replay migration 2026-08-19 18:09:21 +08:00
Jyong
66e3ae951f feat(knowledge): add golden question evaluations 2026-08-19 05:54:05 -04:00
Stephen Zhou
ac5ba51ec0 fix(web): align document drop state (WTA-2106)
Linear: https://linear.app/dify/issue/WTA-2106
2026-08-19 17:13:29 +08:00
Jyong
91006dfd2e feat(workflow): unify KnowledgeFS recall settings 2026-08-19 05:12:12 -04:00
Jyong
9553e7710c fix(knowledge-fs): allow optional notes and ignore bad cases 2026-08-19 04:16:42 -04:00
Stephen Zhou
c11f31f5d3 fix(knowledge-fs): recover upgrade jobs in new knowledge list 2026-08-19 16:15:46 +08:00
FFXN
71c1e872ed feat(knowledge-fs): add upgrade job listing API 2026-08-19 15:25:58 +08:00
FFXN
80df2009ac Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-19 15:17:45 +08:00
FFXN
0175a17908 feat(knowledge-fs): add upgrade job listing API 2026-08-19 15:12:21 +08:00
Jyong
02151d0afa fix(knowledge-fs): correct quality retest workflow 2026-08-19 03:03:31 -04:00
Stephen Zhou
b20b6f3c18 fix(knowledge-fs): correct inline alert styling 2026-08-19 14:45:52 +08:00
Jyong
39c31ef46c fix(knowledge-fs): clarify golden question evidence editing 2026-08-19 02:40:20 -04:00
Jyong
40cfea870b fix(knowledge-fs): correct research timing and judge budget 2026-08-19 02:08:52 -04:00
FFXN
69c073a378 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-19 13:23:32 +08:00
FFXN
76eeebcece feat(knowledge-fs): Legacy knowledge base allows multiple migrations 2026-08-19 13:21:52 +08:00
Jyong
d2741725f7 default KnowledgeFS external access on creation 2026-08-19 00:33:43 -04:00
Jyong
0d39b59875 fix(knowledge-fs): recover truncated research reasoning 2026-08-19 00:11:16 -04:00
Stephen Zhou
b3ddc3cdf2 fix: align merged KnowledgeFS checks 2026-08-19 09:55:36 +08:00
Stephen Zhou
df1a738a97 test: align merged RBAC and dataset coverage 2026-08-19 09:34:17 +08:00
Stephen Zhou
ac8d216aa9 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/core/plugin/test_backwards_invocation_model.py
#	api/tests/unit_tests/services/enterprise/test_rbac_service.py
#	api/tests/unit_tests/services/test_dataset_service_dataset.py
2026-08-19 09:27:13 +08:00
Stephen Zhou
5a14a69cc7 fix(knowledge-fs): update contract lock 2026-08-19 03:36:47 +08:00
Jyong
719579e330 fix(knowledge-fs): speed up semantic import and research 2026-08-18 15:15:42 -04:00
Jyong
aa87073bb5 refactor(knowledge-fs): redesign research evidence retrieval 2026-08-18 14:20:03 -04:00
Jyong
8560c2257d fix(knowledge-fs): ground semantic section paths 2026-08-18 10:46:56 -04:00
Jyong
8f58e2ddcf fix(knowledge-fs): bound semantic window output 2026-08-18 10:26:41 -04:00
Stephen Zhou
a2b1075eea fix(web): align add source sync policy layout 2026-08-18 22:05:24 +08:00
Jyong
1e0b5671eb fix(knowledge-fs): delete uploaded document objects 2026-08-18 09:50:06 -04:00
Jyong
0ca46ae273 fix(ci): fail closed on knowledge deployment 2026-08-18 09:26:37 -04:00
Stephen Zhou
4fc0d8bf2a fix(web): stabilize knowledge upgrade transitions 2026-08-18 19:34:03 +08:00
Jyong
b821021bd0 feat(knowledge-fs): optimize semantic chunk planning 2026-08-18 07:27:08 -04:00
Stephen Zhou
e4a2553ff1 fix(web): align document outline and content 2026-08-18 18:41:22 +08:00
Stephen Zhou
3d762c322d chore(web): remove stale markdown lint suppression 2026-08-18 18:21:56 +08:00
Stephen Zhou
f3ea262d68 fix(web): avoid forced line breaks in document chunks 2026-08-18 18:21:29 +08:00
Stephen Zhou
0c9b9e68d0 tweaks 2026-08-18 18:07:03 +08:00
Stephen Zhou
c9e4197f58 fix(web): model document headings separately from chunks 2026-08-18 18:05:04 +08:00
Stephen Zhou
74d09e78ab fix(web): use download icon for CSV import (WTA-2093)
Linear: https://linear.app/dify/issue/WTA-2093/uiux-%E5%AF%BC%E5%85%A5-csv-%E6%8C%89%E9%92%AE%E4%BD%BF%E7%94%A8-download-line-%E5%9B%BE%E6%A0%87
2026-08-18 17:24:42 +08:00
Stephen Zhou
efce54515e test(knowledge-fs): cover custom metadata validation 2026-08-18 17:01:25 +08:00
Stephen Zhou
791664f4d1 fix(knowledge-fs): format custom metadata DTO 2026-08-18 16:43:55 +08:00
Jyong
5d196aa6fa fix(workflow): use KnowledgeFS custom metadata filters 2026-08-18 03:53:54 -04:00
Stephen Zhou
18910412d5 fix(knowledge-fs): update contract lock 2026-08-18 15:33:02 +08:00
Stephen Zhou
b95d0f4743 fix(web): remove knowledge sidebar retrieval mode 2026-08-18 15:20:43 +08:00
Stephen Zhou
78800993e2 fix(knowledge-fs): materialize compose env in config test 2026-08-18 15:17:50 +08:00
Stephen Zhou
bd81ab9e92 fix(web): align legacy knowledge upgrade flow with backend 2026-08-18 15:13:03 +08:00
Jyong
e5dc0eeb3a merge: harden KnowledgeFS PDF image materialization
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-08-18 03:06:01 -04:00
Jyong
f8a2d530d6 feat(knowledge-fs): harden PDF image materialization 2026-08-18 03:03:18 -04:00
Stephen Zhou
595d60fb54 fix(web): use revision badges in quality table (WTA-2088)
Linear: https://linear.app/dify/issue/WTA-2088/uiux-校准-tag-暗黑模式-token
2026-08-18 14:49:55 +08:00
FFXN
fd0074a32f Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-18 14:41:14 +08:00
FFXN
1ef3379d5a feat(knowledge-fs): implement KnowledgeFS upgrade discovery and idempotency handling 2026-08-18 14:41:04 +08:00
Stephen Zhou
bb7985f333 fix(web): update Confluence onboarding icon
Refs WTA-2090
2026-08-18 14:26:51 +08:00
FFXN
850566bbbc fix(workflow-runtime): increase external operation timeout to 10 minutes 2026-08-18 14:02:51 +08:00
FFXN
f5000ecb88 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-18 14:02:10 +08:00
FFXN
82587cd4b5 fix(workflow-runtime): increase external operation timeout to 10 minutes 2026-08-18 14:01:53 +08:00
Stephen Zhou
c31ed67608 feat(web): add legacy knowledge upgrade experience 2026-08-18 14:00:04 +08:00
Stephen Zhou
600afe84d9 fix(web): preserve new RAG merge contracts 2026-08-18 11:13:20 +08:00
Stephen Zhou
52cfbea82b Merge branch 'deploy/konwledge' of https://github.com/langgenius/dify into deploy/konwledge 2026-08-18 10:55:50 +08:00
Stephen Zhou
d9b51a409d Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-08-18 10:54:19 +08:00
FFXN
dcc05a61ae feat(knowledge-fs): add KnowledgeFS upgrade functionality 2026-08-18 10:51:01 +08:00
FFXN
d6e03874ff Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-18 10:42:21 +08:00
FFXN
5236b71cb6 refactor(knowledge_fs): rename services and update imports for dataset knowledge file lease 2026-08-18 10:42:07 +08:00
Stephen Zhou
1cccac11e6 test(knowledge-fs): exclude upgrade adapters from product boundary 2026-08-18 10:21:16 +08:00
Stephen Zhou
4a6f5c8fba fix(knowledge-fs): use payload field aliases 2026-08-18 10:13:31 +08:00
Stephen Zhou
6638ae6c9d fix(knowledge-fs): format upgrade file lease 2026-08-18 10:06:51 +08:00
FFXN
b2924909e0 fix(secret-scan): update line numbers for database schema identifiers in allowlist 2026-08-18 09:51:50 +08:00
FFXN
8c196e268e Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-18 09:40:24 +08:00
Stephen Zhou
cd605abd5e fix(web): remove document revision selector 2026-08-17 23:20:46 +08:00
Stephen Zhou
608d5bbd92 fix(web): reuse Dify Tag in quality table (WTA-2076)
Linear: https://linear.app/dify/issue/WTA-2076/uiux-%E8%B4%A8%E9%87%8F%E9%A1%B5%E6%A0%87%E7%AD%BE%E5%88%97%E5%A4%8D%E7%94%A8-dify-tag-%E6%8E%A7%E4%BB%B6
2026-08-17 19:46:21 +08:00
Stephen Zhou
25a66fdd20 fix(web): align more providers link control (WTA-2070) 2026-08-17 19:36:50 +08:00
Stephen Zhou
a4c8c2a456 fix(web): align quality annotation tooltip (WTA-2075) 2026-08-17 19:17:09 +08:00
Stephen Zhou
7012533fec fix(web): collapse completed research process (WTA-2078) 2026-08-17 18:45:05 +08:00
Stephen Zhou
87652f2b2f fix(web): stabilize source provider card layout (WTA-2080) 2026-08-17 18:32:43 +08:00
Stephen Zhou
abc52964d2 fix(web): match source provider selector design (WTA-2072) 2026-08-17 18:16:58 +08:00
FFXN
79eb04067d Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-17 18:11:51 +08:00
Stephen Zhou
b4f9450a7a fix(web): align add source provider layout (WTA-2072)
Issue: https://linear.app/dify/issue/WTA-2072/bug-添加数据源切换类型-tab-时内容区域视觉抖动
2026-08-17 17:58:03 +08:00
Stephen Zhou
d543085ac5 chore(knowledge-fs): refresh contract lock (WTA-2068) 2026-08-17 17:56:07 +08:00
Stephen Zhou
3ea340077d test(knowledge-fs): classify icon migration coverage (WTA-2068) 2026-08-17 17:49:56 +08:00
FFXN
b022413a1f Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/controllers/console/datasets/datasets.py
#	api/extensions/ext_celery.py
2026-08-17 17:46:18 +08:00
Stephen Zhou
97fbfe52ce test(knowledge-fs): cover icon background migration (WTA-2068) 2026-08-17 17:42:26 +08:00
FFXN
c7cf9db996 feat(knowledge_fs): Upgrade the old version of the knowledge base to the new version of the knowledge base 2026-08-17 17:36:43 +08:00
Stephen Zhou
bc839185e4 fix(knowledge-fs): refresh secret scan allowlist (WTA-2068) 2026-08-17 17:35:30 +08:00
Stephen Zhou
1606ebbe55 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-17 17:31:46 +08:00
Stephen Zhou
1918c45b96 fix(knowledge-fs): persist icon style selection (WTA-2068) 2026-08-17 17:31:25 +08:00
Jyong
a60865a8b3 fix(knowledge-fs): restore metadata field writes 2026-08-17 05:12:51 -04:00
Stephen Zhou
7e55a51587 fix(web): expand New RAG overview content (WTA-2073) 2026-08-17 17:01:55 +08:00
Jyong
3cdf6e3a46 chore(knowledge-fs): refresh contract lock 2026-08-17 04:38:17 -04:00
Stephen Zhou
dc46951462 fix(web): isolate knowledge settings saves (WTA-2069)
Keep unrelated controls interactive while independent settings saves run, and serialize access mutations to preserve the latest values.

Linear: https://linear.app/dify/issue/WTA-2069/bug-knowledge-settings-switches-cause-other-inputs-to-flicker
2026-08-17 16:30:27 +08:00
Jyong
fab404705a Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-17 04:26:31 -04:00
Jyong
1d3da80c64 fix(knowledge-fs): tolerate ungrounded semantic entities 2026-08-17 04:25:38 -04:00
Stephen Zhou
b767fdac38 fix(web): remove quality tab counts (WTA-2067) 2026-08-17 16:10:44 +08:00
Stephen Zhou
233db4dd56 fix(web): unify New RAG content padding (WTA-2065) 2026-08-17 16:00:24 +08:00
Jyong
28f715f800 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-17 03:57:28 -04:00
Jyong
01a88f3532 Guard document actions by processing status 2026-08-17 03:55:13 -04:00
Stephen Zhou
6a5ad523a8 fix(web): show task failures in overview banner (WTA-2059) 2026-08-17 15:44:48 +08:00
Jyong
57da8fad9b fix KnowledgeFS upload bootstrap admission 2026-08-17 03:01:06 -04:00
Stephen Zhou
8422bc7386 fix(web): use brand token for knowledge create button (WTA-2055) 2026-08-17 14:49:40 +08:00
Jyong
627c80b09a feat(knowledge-fs): optimize document ingestion pipeline 2026-08-17 02:40:21 -04:00
Stephen Zhou
9227e4174d fix(web): center add source loading state (WTA-2060) 2026-08-17 13:13:31 +08:00
Jyong
f0743d993a fix(knowledge-fs): package sharp native runtime 2026-08-17 00:55:58 -04:00
Stephen Zhou
18230ecb9a fix(web): align New RAG page headers (WTA-2058) 2026-08-17 12:54:32 +08:00
Stephen Zhou
b5e74202aa fix(web): use filled icons for active New RAG navigation (WTA-2057) 2026-08-17 12:33:24 +08:00
Stephen Zhou
b25d20b665 fix(web): align legacy knowledge filters (WTA-2054) 2026-08-17 12:18:43 +08:00
Stephen Zhou
b97ed96dcd fix(web): polish document detail summary and scrollbar 2026-08-17 10:31:49 +08:00
Stephen Zhou
4f8cc0598d test(knowledge-fs): cover conversation cleanup schedule 2026-08-17 10:15:32 +08:00
Stephen Zhou
45c2dd52c8 fix: preserve OAuth account ID and New RAG titles after merge 2026-08-17 10:03:42 +08:00
Stephen Zhou
cc3f901598 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/extensions/ext_celery.py
#	api/tests/unit_tests/controllers/console/auth/test_oauth_server.py
#	lint.config.ts
#	packages/contracts/knowledge-fs-contract.test.mjs
#	vite.config.ts
#	web/features/new-rag/__tests__/document-detail-page.spec.tsx
#	web/features/new-rag/__tests__/knowledge-space-shell.spec.tsx
#	web/features/new-rag/__tests__/processing-task-events.spec.ts
#	web/features/new-rag/create-knowledge-page.tsx
#	web/features/new-rag/document-detail-page.tsx
#	web/features/new-rag/knowledge-space-shell.tsx
2026-08-17 09:50:10 +08:00
Stephen Zhou
ebc7322e00 fix(web): improve document detail layout (WTA-2026) 2026-08-14 23:47:18 +08:00
Stephen Zhou
426b5e9fee fix(web): align knowledge model readiness banner 2026-08-14 16:01:41 +08:00
yyh
7f60474aa9 fix(web): improve golden question selection semantics 2026-08-14 15:10:16 +08:00
Jyong
20f8cdb588 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-14 02:29:11 -04:00
Jyong
5323b6e4fc Improve application workflows and user experience 2026-08-14 02:27:03 -04:00
Stephen Zhou
54aa1852bc fix(knowledge-fs): restore retrieval mode selected styling 2026-08-14 11:21:50 +08:00
Stephen Zhou
fb7510c7b6 fix(api): restore deadlock-safe identity logging 2026-08-14 11:21:46 +08:00
Stephen Zhou
68417f40b4 fix(knowledge-fs): restore segmented control interactions 2026-08-14 11:11:50 +08:00
Stephen Zhou
01254dcdb6 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/controllers/web/test_wraps.py
#	api/tests/unit_tests/extensions/otel/test_runtime.py
#	web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx
2026-08-14 10:59:24 +08:00
Jyong
0f672e1599 Page semantic graph node listing within repository limits 2026-08-13 14:30:31 -04:00
Jyong
ffd2a7094d restore semantic document compilation pipeline 2026-08-13 13:10:39 -04:00
Jyong
0bc3a849ce fix(knowledge-fs): clarify background task failures 2026-08-13 06:01:01 -04:00
Stephen Zhou
3aa2915ab6 fix(knowledge-fs): center quality loading state 2026-08-13 17:42:11 +08:00
Jyong
24c1fe7138 fix(knowledge-fs): skip retrieval model probes on settings save 2026-08-13 05:02:45 -04:00
Jyong
6ee5f59da8 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-13 04:46:10 -04:00
Jyong
2b0fcce85c Treat configured reasoning and rerank models as active 2026-08-13 04:45:26 -04:00
FFXN
08910f8d79 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-13 16:29:19 +08:00
FFXN
b9b6498125 fix(knowledge_fs): enhance active profile handling and add migration test 2026-08-13 16:27:47 +08:00
Stephen Zhou
e903772ee0 fix(knowledge-fs): hide idle model validation status 2026-08-13 16:23:29 +08:00
Jyong
126394d157 Show document task failure reasons in status popovers 2026-08-13 04:16:41 -04:00
Jyong
2a77d24d44 Supersede pending retrieval candidates on settings updates 2026-08-13 03:58:34 -04:00
Stephen Zhou
30c84ce58c feat(knowledge-fs): support tag management and filtering (WTA-1980) 2026-08-13 15:22:57 +08:00
FFXN
1fe9b401e9 fix(knowledge_fs): add capability grant ID to durable deletion handlers 2026-08-13 15:19:43 +08:00
FFXN
f938be656b fix(knowledge_fs): add capability grant ID to durable deletion handlers 2026-08-13 15:18:51 +08:00
FFXN
8d263af94a fix: refresh KnowledgeFS contract lock 2026-08-13 15:06:21 +08:00
FFXN
1f9e0211f3 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-13 14:57:56 +08:00
FFXN
5c1201fa88 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/services/knowledge_fs/product_dto.py
#	api/tests/unit_tests/services/test_knowledge_fs_data_facade.py
2026-08-13 14:57:14 +08:00
Jyong
a9356e65e2 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-08-13 02:54:43 -04:00
Jyong
e91706c485 Retry failed profile migrations and avoid revision conflicts 2026-08-13 02:54:22 -04:00
FFXN
6470d18a8f fix(knowledge-fs): add active profile availability to data facade and DTO 2026-08-13 14:50:41 +08:00
Stephen Zhou
61e1a9d568 fix(knowledge-fs): refresh contract lock 2026-08-13 13:09:35 +08:00
Stephen Zhou
9c5a38abbc fix(knowledge-fs): unify incomplete model guidance (WTA-1987) 2026-08-13 12:59:27 +08:00
FFXN
3c87ae6461 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-13 11:15:18 +08:00
FFXN
13ba200d9f feat(knowledge-fs): update space tag management and improve type annotations 2026-08-13 11:14:52 +08:00
Stephen Zhou
7111a2817d fix(knowledge-fs): sync secret scan allowlist lines 2026-08-13 11:09:35 +08:00
FFXN
5783527457 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-13 11:01:40 +08:00
FFXN
f746f180aa feat(knowledge-fs): implement KnowledgeFS space tag management API 2026-08-13 10:55:33 +08:00
Stephen Zhou
f7a0036183 test: cover invalid successful polling result 2026-08-13 10:48:26 +08:00
Stephen Zhou
c259666e69 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/core/plugin/backwards_invocation/model.py
#	web/features/deployments/shared/components/unsupported-dsl-nodes-alert.tsx
2026-08-13 10:22:32 +08:00
Jyong
e25bf1776c test(knowledge-fs): sync migration runner expectations 2026-08-12 13:41:30 -04:00
Stephen Zhou
7b336cee65 test(knowledge-fs): include failed retrieval migration 2026-08-13 01:38:36 +08:00
Jyong
d064bf036c feat: capture workflow retrieval misses 2026-08-12 13:31:02 -04:00
Stephen Zhou
dced2e3129 fix(knowledge-fs): correct breadcrumb navigation (DC-191) 2026-08-13 01:02:32 +08:00
Stephen Zhou
623664b430 fix(knowledge-fs): serialize rerank plugin alias (WTA-1984) 2026-08-13 00:51:36 +08:00
Stephen Zhou
c7507e3dbb fix(knowledge-fs): show initial source provisioning (WTA-1983) 2026-08-13 00:30:30 +08:00
Jyong
9c9a9044cc fix(knowledge-fs): link retrieval evidence to golden questions 2026-08-12 12:26:13 -04:00
Jyong
3b3b26e991 fix(knowledge-fs): correct bad case reasons 2026-08-12 11:47:49 -04:00
Stephen Zhou
f83afe4a9d chore(knowledge-fs): update contract subtree lock 2026-08-12 23:21:14 +08:00
Stephen Zhou
4780328409 test(knowledge-fs): avoid non-null overview assertion 2026-08-12 23:06:49 +08:00
Jyong
0d67bd281b fix(knowledge-fs): correct overview attention signals 2026-08-12 10:56:33 -04:00
Stephen Zhou
0f6652d313 fix(knowledge-fs): keep sync policy available when adding sources (WTA-1978) 2026-08-12 21:40:00 +08:00
Stephen Zhou
2459f71c8b fix(knowledge-fs): locate retrieved source chunks (WTA-1981) 2026-08-12 20:08:37 +08:00
Stephen Zhou
a9cb3177cc chore(knowledge-fs): update contract subtree lock 2026-08-12 19:41:44 +08:00
Stephen Zhou
0af7268577 ci(knowledge-fs): restore coverage diff ancestry 2026-08-12 19:35:17 +08:00
Stephen Zhou
fd508b84b8 fix(knowledge-fs): preserve initial source failure details 2026-08-12 19:29:13 +08:00
Jyong
2de9456047 test(knowledge-fs): complete mandatory rerank coverage 2026-08-12 07:13:12 -04:00
Stephen Zhou
9bea201d81 fix(knowledge-fs): align API tests with rerank gate 2026-08-12 19:09:09 +08:00
Jyong
b3a7c7a3e0 feat(knowledge-fs): unify public error handling 2026-08-12 07:00:59 -04:00
Stephen Zhou
9926a4a1fc fix(knowledge-fs): unify sync policy editing (WTA-1978) 2026-08-12 18:21:19 +08:00
Stephen Zhou
cad3a3a238 fix(knowledge-fs): edit source sync settings (WTA-1978) 2026-08-12 17:47:55 +08:00
Stephen Zhou
53bbe94d3b fix(knowledge-fs): align knowledge creation form 2026-08-12 17:22:28 +08:00
Jyong
804cbf0fe4 fix(knowledge-fs): resolve published document outlines 2026-08-12 05:17:00 -04:00
Jyong
ef3c321002 fix(knowledge-fs): preserve unstructured document hierarchy 2026-08-12 04:33:31 -04:00
Stephen Zhou
077cdf5a54 fix(knowledge-fs): correct source provider branding 2026-08-12 16:28:01 +08:00
Stephen Zhou
d8dde7fb5e feat(knowledge-fs): preview local files in app (WTA-1802) 2026-08-12 15:49:15 +08:00
Jyong
9554add7ab fix(knowledge-fs): make upload completion grant replayable 2026-08-12 03:31:25 -04:00
Stephen Zhou
3e31ef1c27 fix(knowledge-fs): decode text previews as UTF-8 (WTA-1802) 2026-08-12 15:28:59 +08:00
Jyong
79e66cb7f1 fix(knowledge-fs): avoid self-fencing direct uploads 2026-08-12 03:16:56 -04:00
Jyong
b36cd36fd2 chore(knowledge-fs): refresh contract subtree lock 2026-08-12 03:01:54 -04:00
Jyong
3f09caa366 fix(knowledge-fs): parse upload session bigint values 2026-08-12 02:54:47 -04:00
Stephen Zhou
feccb4786a fix(knowledge-fs): reconcile main merge artifacts 2026-08-12 14:19:24 +08:00
Stephen Zhou
876814e92e Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	web/features/system-features/state.ts
2026-08-12 14:10:20 +08:00
Stephen Zhou
c1780e08ec fix(knowledge-fs): align frontend list queries 2026-08-12 13:59:53 +08:00
Stephen Zhou
793bafac1d fix(knowledge-fs): align document availability controls 2026-08-12 12:11:26 +08:00
Stephen Zhou
ba4f178e4b fix(knowledge-fs): align document download contracts 2026-08-12 11:43:32 +08:00
Stephen Zhou
c3bc4a4f80 fix(knowledge-fs): use bulk document deletion endpoint 2026-08-12 11:25:13 +08:00
Stephen Zhou
b9edb1382d test(knowledge-fs): drop removed billing config patch 2026-08-12 11:11:05 +08:00
Stephen Zhou
7adbaca9ab fix: reconcile main merge regressions 2026-08-12 10:51:20 +08:00
Stephen Zhou
574461604d Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/commands/system.py
#	api/controllers/console/init_validate.py
#	api/controllers/console/workspace/account.py
#	api/controllers/console/wraps.py
#	api/controllers/openapi/auth/data.py
#	api/core/hosting_configuration.py
#	api/core/provider_manager.py
#	api/core/rag/retrieval/dataset_retrieval.py
#	api/services/workspace_service.py
#	api/tasks/workflow_cfs_scheduler/entities.py
#	api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py
#	api/tests/test_containers_integration_tests/services/test_feature_service.py
#	api/tests/unit_tests/controllers/console/auth/test_account_activation.py
#	api/tests/unit_tests/controllers/openapi/auth/test_data.py
#	api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py
#	api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py
#	api/tests/unit_tests/services/test_feature_service_knowledge_fs.py
#	dev/start-worker
#	packages/contracts/console.ts
#	packages/contracts/package.json
#	packages/iconify-collections/custom-public/icons.json
#	web/app/components/workflow/__tests__/index.spec.tsx
#	web/app/components/workflow/index.tsx
#	web/features/new-rag/document-chunk-tree.tsx
#	web/service/fetch.spec.ts
2026-08-12 10:33:29 +08:00
Stephen Zhou
9f687bccb7 fix(knowledge-fs): refresh contract subtree lock 2026-08-12 10:14:58 +08:00
Stephen Zhou
05c0072c6b test(knowledge-fs): expect datasource parameters 2026-08-12 10:08:23 +08:00
Stephen Zhou
d70a3f562b fix(knowledge-fs): disambiguate preview page types 2026-08-12 09:59:43 +08:00
Stephen Zhou
244fad601f fix(knowledge-fs): format initial source preview task 2026-08-12 09:38:44 +08:00
Stephen Zhou
10b9804ef8 fix(knowledge-fs): preserve connected source after rename 2026-08-12 09:28:57 +08:00
Stephen Zhou
3f20431334 feat(knowledge-fs): refine website datasource form 2026-08-12 09:22:09 +08:00
Stephen Zhou
a201e133de feat(knowledge-fs): support datasource-driven source previews 2026-08-12 08:33:52 +08:00
Jyong
a220bf394f docs(test): record KnowledgeFS workflow v2 coverage
Document the executed Workflow Knowledge Retrieval v2 matrix, Linear issue mapping, fix verification, remaining environment blockers, and reusable DSL fixtures.
2026-08-11 11:51:17 -04:00
Jyong
c8780dcb7c fix(workflow): stabilize KnowledgeFS retrieval access
Keep the workflow editor mounted after history updates, expose an independent Workflow Access control, and map KnowledgeFS admission failures to actionable localized errors. Add frontend and backend regression coverage for KF-BUG-020 and KF-BUG-021.
2026-08-11 11:51:17 -04:00
FFXN
4769070af0 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
#	packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts
#	packages/contracts/generated/api/console/knowledge-fs/types.gen.ts
#	packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts
2026-08-11 19:54:25 +08:00
FFXN
0853258f92 feat(knowledge-fs): add bulk logical document deletion support 2026-08-11 19:47:37 +08:00
FFXN
7f0efa20fa feat(knowledge-fs): add bulk logical document deletion support 2026-08-11 19:47:12 +08:00
Stephen Zhou
308795393a fix(knowledge-fs): refresh contract subtree lock 2026-08-11 18:24:28 +08:00
Jyong
4ce6a36b12 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	web/features/new-rag/__tests__/create-knowledge-page.spec.tsx
#	web/i18n/de-DE/dataset.json
#	web/i18n/nl-NL/dataset.json
2026-08-11 06:12:00 -04:00
Jyong
14e5afa54d Update application components and supporting logic 2026-08-11 05:52:22 -04:00
Stephen Zhou
7ee9296bea fix(knowledge-fs): wire document download actions 2026-08-11 17:28:20 +08:00
Stephen Zhou
fc1eaf3287 fix(knowledge-fs): reject empty file uploads (WTA-1929) 2026-08-11 16:43:10 +08:00
FFXN
fd7854de7e Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-11 16:29:46 +08:00
FFXN
0d4441e2cf feat(knowledge): support KnowledgeFS document downloads
- add single logical document download endpoint
- add batch ZIP download endpoint
- stream files from KnowledgeFS object storage
- validate batch size and deduplicate ZIP filenames
- add download service and facade unit tests
2026-08-11 16:29:23 +08:00
Stephen Zhou
8dabdb64ea fix(knowledge-fs): handle disabled reindex results 2026-08-11 16:10:20 +08:00
Stephen Zhou
cb62c52255 feat(knowledge-fs): align document actions with design 2026-08-11 15:48:25 +08:00
Stephen Zhou
668b122632 style(knowledge-fs): format availability changes 2026-08-11 11:26:36 +08:00
Stephen Zhou
c50c815e81 Merge branch 'deploy/konwledge' of github.com:langgenius/dify into deploy/konwledge 2026-08-11 11:24:20 +08:00
Stephen Zhou
429850531a fix(knowledge-fs): display source provider names 2026-08-11 11:23:48 +08:00
FFXN
14d53ff11e Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-11 11:21:10 +08:00
FFXN
79348cfc12 test(migrate): add additional migration ID assertions for logical document availability 2026-08-11 11:20:34 +08:00
Stephen Zhou
e4c7987cf0 fix(knowledge-fs): sync capability operation count 2026-08-11 11:19:02 +08:00
Stephen Zhou
d9e25e40f6 fix(knowledge-fs): sync api migration expectations 2026-08-11 11:12:31 +08:00
FFXN
465d3c53cb Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-11 11:06:53 +08:00
FFXN
17cc4e20e7 fix(migration): add logical document availability migration ID 2026-08-11 11:06:31 +08:00
Stephen Zhou
dabd74ac44 fix(knowledge-fs): align availability schema migration 2026-08-11 11:04:00 +08:00
FFXN
5fed29ed57 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-11 10:57:34 +08:00
FFXN
703c4b5f83 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-11 10:50:55 +08:00
Stephen Zhou
43f76aec9a fix: reconcile remaining main merge regressions 2026-08-11 10:45:17 +08:00
FFXN
6b3957af9a feat(knowledge_fs): add APIs for managing logical document availability with bulk update support 2026-08-11 10:39:44 +08:00
FFXN
c0b57f2f54 feat(knowledge_fs): add APIs for managing logical document availability with bulk update support 2026-08-11 10:39:28 +08:00
Stephen Zhou
3bcc648f07 fix: restore upstream behavior after main merge 2026-08-11 10:23:44 +08:00
Stephen Zhou
4120cb5eef Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/controllers/common/wraps.py
#	api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py
#	api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py
#	api/tests/unit_tests/services/agent/test_workflow_publish_service.py
2026-08-11 10:14:53 +08:00
FFXN
26544610ff feat(knowledge_fs): add document availability management with bulk update support 2026-08-11 10:01:33 +08:00
Stephen Zhou
aea50cd647 fix(knowledge-fs): sync deployment env contract 2026-08-10 23:20:31 +08:00
Jyong
fcbfb49ef7 fix(knowledge-fs): refresh secret scan allowlist 2026-08-10 11:07:13 -04:00
Jyong
28fd666fa6 feat(knowledge): stage document uploads before admission 2026-08-10 10:58:17 -04:00
Stephen Zhou
655e596106 refactor(web): share new RAG setup controls 2026-08-10 22:51:52 +08:00
Stephen Zhou
fbde89eeb9 fix(knowledge-fs): align datasource credential type 2026-08-10 22:38:33 +08:00
Stephen Zhou
a2194300cc fix(knowledge-fs): stabilize initial source creation 2026-08-10 22:35:24 +08:00
Stephen Zhou
e4e9271fbe feat(knowledge-fs): create spaces with initial sources 2026-08-10 22:08:01 +08:00
Stephen Zhou
62cebcf64d fix(web): discover source providers dynamically 2026-08-10 20:51:20 +08:00
Stephen Zhou
b2956fd544 fix(knowledge-fs): refresh contract subtree lock 2026-08-10 19:50:00 +08:00
Stephen Zhou
436cb0f544 tweaks 2026-08-10 19:30:03 +08:00
Stephen Zhou
e1bbb85e3e fix(web): stop waiting for source indexing 2026-08-10 19:23:58 +08:00
Stephen Zhou
99b67d56e3 fix(knowledge-fs): scope deletion fences by source 2026-08-10 19:16:57 +08:00
Stephen Zhou
9993b4f13d fix(web): connect installed website datasource providers 2026-08-10 18:36:19 +08:00
Jyong
b630259d21 fix(web): wait for KnowledgeFS provisioning before upload 2026-08-10 06:08:36 -04:00
FFXN
ec276ef415 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-10 18:01:22 +08:00
FFXN
810cfb6ea5 fix(index-projection-builders): add getManyByNodeIds method for efficient projection retrieval 2026-08-10 18:00:54 +08:00
FFXN
1989fe923a Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-10 18:00:44 +08:00
Stephen Zhou
7af76cfa75 fix(web): create knowledge with initial source 2026-08-10 17:58:12 +08:00
FFXN
5008b98b05 fix(index-projection-builders): add getManyByNodeIds method for efficient projection retrieval 2026-08-10 17:56:33 +08:00
Stephen Zhou
48ea979f5b refactor(web): share knowledge setup components 2026-08-10 17:14:52 +08:00
FFXN
37f357b4e8 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-10 17:03:27 +08:00
FFXN
c537b130fe fix(index-projection-builders): optimize dense projection handling to reuse persisted projections 2026-08-10 17:02:42 +08:00
FFXN
8eaaef42de fix(index-projection-builders): optimize dense projection handling to reuse persisted projections 2026-08-10 17:02:30 +08:00
Stephen Zhou
1db2f68b3d fix(web): align create knowledge flow with design 2026-08-10 17:00:17 +08:00
FFXN
88ce098f55 fix(document-semantic-enrichment): update baseHeadRevision handling to use nonnegativeSafeIntegerColumn 2026-08-10 15:50:57 +08:00
FFXN
aea65f1ce0 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-10 15:50:44 +08:00
FFXN
c38d795db5 fix(document-semantic-enrichment): update baseHeadRevision handling to use nonnegativeSafeIntegerColumn 2026-08-10 15:49:25 +08:00
Stephen Zhou
e21af924d8 fix(knowledge-fs): refresh contract subtree lock 2026-08-10 15:25:37 +08:00
Stephen Zhou
64cc650e78 fix(knowledge-fs): sync metadata test contracts 2026-08-10 15:17:35 +08:00
Jyong
1ac8c9e434 feat(knowledge-fs): add durable metadata management 2026-08-10 02:55:09 -04:00
FFXN
438dc96334 fix(product_remote_http): log upstream rejection details for specific error statuses 2026-08-10 14:34:30 +08:00
FFXN
9eff9783fc fix(gateway): enhance error handling to log detailed context information 2026-08-10 13:30:36 +08:00
FFXN
8b3fc51374 fix(gateway): enhance error handling to log detailed context information 2026-08-10 13:27:31 +08:00
Stephen Zhou
efc21d5e48 fix(knowledge-fs): classify app binding coverage 2026-08-10 11:14:33 +08:00
Stephen Zhou
3f5c035329 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-10 11:00:35 +08:00
Stephen Zhou
f4e8788d29 fix(api): restore security guards after main merge 2026-08-10 11:00:06 +08:00
Jyong
93dcc67e63 Fix workflow binding sync session handling 2026-08-09 22:50:44 -04:00
Jyong
a9c92f114f Implement the requested changes across the application 2026-08-09 22:40:48 -04:00
Stephen Zhou
3e59fba46a Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/controllers/console/app/app.py
#	api/controllers/console/app/app_import.py
#	api/controllers/console/auth/activate.py
#	api/controllers/console/setup.py
#	api/tests/unit_tests/controllers/console/workspace/test_plugin.py
#	web/features/new-rag/document-list.tsx
#	web/features/new-rag/sources-page.tsx
#	web/features/new-rag/website-crawl-preview.tsx
2026-08-10 10:34:47 +08:00
Jyong
1d990de7e7 Refactor application components and update related functionality 2026-08-09 12:19:56 -04:00
Jyong
78e9d41c83 feat(knowledge-fs): support image query retrieval 2026-08-09 09:45:43 -04:00
FFXN
fec55e505d fix(web): add silent context to getSyncPolicy and updateSyncPolicy calls 2026-08-08 21:04:35 +08:00
FFXN
488bad3424 fix(knowledge_fs): enhance online-document connector to aggregate Dify content variable messages and support streaming 2026-08-08 20:38:59 +08:00
FFXN
b70136f46e fix(knowledge_fs): enhance online-document connector to aggregate Dify content variable messages and support streaming 2026-08-08 20:38:29 +08:00
FFXN
aea90b08d9 fix(knowledge_fs): add diagnostics for empty content and frame metadata logging 2026-08-08 20:16:05 +08:00
FFXN
42aecd649c fix(knowledge_fs): add diagnostics for empty content and frame metadata logging 2026-08-08 20:15:21 +08:00
FFXN
a4ee85232f fix(knowledge_fs): update lock 2026-08-08 19:43:13 +08:00
FFXN
a6bd93036b fix(knowledge_fs): implement initial Firecrawl source import for knowledge-space creation with enhanced error handling and diagnostics 2026-08-08 19:42:47 +08:00
FFXN
3b6f011c6c fix(knowledge_fs): update online-document connector to concatenate Dify text messages and support structured content envelopes 2026-08-08 19:38:12 +08:00
FFXN
faef9378e2 fix(knowledge_fs): enhance document path generation with sibling item handling 2026-08-08 18:40:53 +08:00
FFXN
f1c302826c fix(knowledge_fs): enhance document path generation with sibling item handling 2026-08-08 18:40:33 +08:00
FFXN
e733ccf47b fix(knowledge_fs): update lock 2026-08-08 16:19:16 +08:00
FFXN
757c7227f8 fix(knowledge_fs): enhance error handling and logging for source compilation failures 2026-08-08 16:18:23 +08:00
Stephen Zhou
86bfb66990 fix(knowledge-fs): update contract lock 2026-08-07 21:44:56 +08:00
Stephen Zhou
c7ef31fbeb fix(knowledge_fs): recover source sync status after reload 2026-08-07 21:25:21 +08:00
FFXN
28da39f909 fix(knowledge_fs): add last error message handling and improve logging for failed source imports 2026-08-07 20:27:57 +08:00
FFXN
03fba953ec fix(knowledge_fs): add last error message handling and improve logging for failed source imports 2026-08-07 20:16:56 +08:00
FFXN
c6edaf70b6 fix(knowledge_fs): enhance workflow handling in initial source import 2026-08-07 19:21:19 +08:00
FFXN
96c5703486 fix(knowledge_fs): add source update handling and adjust sync policy versioning 2026-08-07 18:58:01 +08:00
FFXN
11a47127cd fix(knowledge_fs): implement firecrawl credential handling and connection creation 2026-08-07 18:17:05 +08:00
FFXN
622db45bd5 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-07 17:20:02 +08:00
Stephen Zhou
f41abcd881 fix(knowledge-fs): register crawl import operation 2026-08-07 17:19:07 +08:00
FFXN
59b9453592 fix(knowledge_fs): standardize payload field names in initial source tasks 2026-08-07 17:18:44 +08:00
Stephen Zhou
ada481e836 fix(knowledge-fs): satisfy initial source type check 2026-08-07 17:10:42 +08:00
Stephen Zhou
4011cfbdbc style(knowledge-fs): format initial source task 2026-08-07 17:03:34 +08:00
FFXN
cbecb64fef fix(knowledge_fs): refactor crawl page import logic to handle missing pages 2026-08-07 16:55:05 +08:00
FFXN
6c47ee52eb Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-07 16:46:50 +08:00
FFXN
347cd11771 fix(knowledge_fs): update tests for createSourceCrawlImportWorkflow operation 2026-08-07 16:46:15 +08:00
Stephen Zhou
4bef5c4667 test(web): align website crawl sync policy default 2026-08-07 16:45:58 +08:00
Stephen Zhou
74ec7c8635 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-07 16:44:42 +08:00
Stephen Zhou
3b9be7d03f fix(knowledge-fs): improve source sync and crawl preview 2026-08-07 16:44:08 +08:00
FFXN
e91b96f0bf Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-07 16:31:47 +08:00
FFXN
cb49d83e9c feat(knowledge_fs): import selected Firecrawl pages during space creation
- include Firecrawl configuration and selected page URLs in space creation
- start a server-side crawl import after KnowledgeFS space provisioning
- require the second crawl to match the complete user selection
- keep the knowledge space valid when website content import fails
- reuse existing background task status and failure UI
- add KnowledgeFS crawl-import API and regression tests
2026-08-07 16:29:25 +08:00
Stephen Zhou
9b146706fe fix(knowledge-fs): update contract lock 2026-08-07 14:03:41 +08:00
Jyong
01074e631e Document image-query retrieval implementation plan 2026-08-07 01:50:07 -04:00
Stephen Zhou
5af14795af fix(knowledge-fs): update contract lock 2026-08-07 13:33:46 +08:00
Jyong
64b7cd58b2 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-08-07 01:21:22 -04:00
Jyong
a3683465d3 Fix KnowledgeFS document outline fidelity 2026-08-07 01:20:50 -04:00
Stephen Zhou
d2fbc37a1b fix(web): handle deleted Firecrawl credentials 2026-08-07 11:10:53 +08:00
Stephen Zhou
fab913bce5 fix(web): add provider shortcuts for sources 2026-08-07 10:55:44 +08:00
Stephen Zhou
3de65e6ce4 fix(web): make document list responsive 2026-08-07 10:29:09 +08:00
FFXN
d127b97e36 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-07 10:14:11 +08:00
FFXN
d754811d5f fix(web): add website crawl preview selection and import functionality 2026-08-07 10:12:01 +08:00
Stephen Zhou
8a5c248fbb test: restore sqlite conversation coverage 2026-08-07 10:04:39 +08:00
Stephen Zhou
fde800e337 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	.github/dependabot.yml
#	.github/workflows/deploy-dev.yml
#	api/app_factory.py
#	api/core/indexing_runner.py
#	api/services/feature_service.py
#	api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py
#	api/tests/test_containers_integration_tests/services/test_feature_service.py
#	api/tests/unit_tests/controllers/console/auth/test_account_activation.py
#	api/tests/unit_tests/controllers/service_api/app/test_conversation.py
#	api/tests/unit_tests/controllers/web/test_site.py
#	api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py
#	api/tests/unit_tests/core/tools/test_base_tool.py
#	api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py
#	api/tests/unit_tests/extensions/test_ext_login.py
#	api/tests/unit_tests/services/test_feature_service_deployment_edition.py
#	api/tests/unit_tests/services/test_feature_service_knowledge_fs.py
#	api/tests/unit_tests/services/test_feature_service_licensed_seats.py
2026-08-07 09:55:10 +08:00
Stephen Zhou
f9e4c43a48 fix(web): align background tasks with design 2026-08-06 21:54:17 +08:00
Stephen Zhou
307dd7a704 fix(web): align golden question dialog behavior 2026-08-06 20:56:50 +08:00
Stephen Zhou
88a9ada626 fix(web): align quality table header typography 2026-08-06 20:30:50 +08:00
Stephen Zhou
6c5ee0084f fix(web): align bad cases empty state with design 2026-08-06 20:19:30 +08:00
Stephen Zhou
262fbd7512 fix(web): align quality empty state with design 2026-08-06 19:39:42 +08:00
Stephen Zhou
783a645de0 fix(knowledge-fs): update contract lock 2026-08-06 18:58:05 +08:00
Stephen Zhou
b88a8933ed fix(knowledge-fs): allow zero-revision logical document deletion 2026-08-06 18:49:11 +08:00
Stephen Zhou
fd8e4a80cd fix(knowledge-fs): update contract lock 2026-08-06 18:34:51 +08:00
Stephen Zhou
e61ead98d9 fix(knowledge-fs): update contract lock 2026-08-06 18:27:51 +08:00
Stephen Zhou
3176291f40 fix(web): align documents page with design 2026-08-06 18:27:23 +08:00
Jyong
bcc328eeb5 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-06 06:10:25 -04:00
Jyong
13ddabf832 Update application functionality and related components 2026-08-06 06:10:09 -04:00
FFXN
6ac9b4e7fa fix(web): implement crawl preview page selection component 2026-08-06 18:09:01 +08:00
FFXN
50a6764eaa Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-06 17:46:30 +08:00
FFXN
e6fec870db fix: remove auto preview functionality from source and knowledge creation workflows 2026-08-06 17:43:36 +08:00
Stephen Zhou
743b534212 fix(web): align document upload with design 2026-08-06 17:40:56 +08:00
Stephen Zhou
25fee85f0f fix(web): align empty documents state with design 2026-08-06 17:02:50 +08:00
FFXN
0cf549e14a Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-06 16:45:29 +08:00
FFXN
88d9f82941 fix(web): restore crawl preview during knowledge creation
- hand off newly created spaces to the existing add-source workflow
- automatically start website crawl previews from creation flows
- consume the preview flag to prevent duplicate crawls on refresh
- add a navigation fallback when popstate is not emitted
- wait for the persisted source draft before mounting the preview
- add regression coverage for preview handoff and navigation recovery
2026-08-06 16:45:18 +08:00
Stephen Zhou
d96a7707a6 fix: restore failed retrieval trace history 2026-08-06 16:44:13 +08:00
Stephen Zhou
43f7fcae36 fix: show retrieval metrics in trace history 2026-08-06 16:30:07 +08:00
FFXN
48c8495d3e fix: truncate document section paths to the KnowledgeFS virtual path limit 2026-08-06 16:21:25 +08:00
FFXN
e6dc350cf2 fix: truncate document section paths to the KnowledgeFS virtual path limit 2026-08-06 16:05:24 +08:00
FFXN
5b05ebee28 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-06 15:52:08 +08:00
FFXN
60bf39efd5 fix: truncate document section paths to the KnowledgeFS virtual path limit 2026-08-06 15:50:39 +08:00
Stephen Zhou
09cbdda519 fix(knowledge-fs): update contract lock 2026-08-06 15:41:10 +08:00
Stephen Zhou
3ebbe0e4ae fix(web): hide tasks in empty documents state 2026-08-06 15:19:46 +08:00
Stephen Zhou
cc10ca8198 fix: clean up profile migration publication references 2026-08-06 15:13:29 +08:00
Stephen Zhou
f2255e0d27 fix(web): align retrieval failure state with design 2026-08-06 14:51:54 +08:00
FFXN
b68289f24a fix: prevent source deletion failures caused by UUID and text type mismatch 2026-08-06 14:40:30 +08:00
Stephen Zhou
18ae46f7a3 fix(web): align retrieval result annotations 2026-08-06 13:59:50 +08:00
Stephen Zhou
670fa67ef9 fix(web): align new rag retrieval states with design 2026-08-06 13:29:29 +08:00
Stephen Zhou
654884817a fix(web): align new rag retrieval test with design 2026-08-06 12:53:57 +08:00
Stephen Zhou
f1ea6338c3 fix(web): autosave new rag settings 2026-08-06 12:37:15 +08:00
Stephen Zhou
d16b9c5e8b fix(web): align new rag overview with design 2026-08-06 12:09:04 +08:00
Stephen Zhou
1fe71ee5d3 chore: update 2026-08-06 11:59:52 +08:00
hyoban
be221ec89d fix(web): restore new rag datasets page 2026-08-06 11:34:29 +08:00
Stephen Zhou
d144840384 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-06 11:02:30 +08:00
Stephen Zhou
8d2eac55d7 fix(agent): preserve workflow node execution logs 2026-08-06 11:01:54 +08:00
Stephen Zhou
a7283bccb3 Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-08-06 10:46:50 +08:00
Stephen Zhou
24847366e1 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/app_factory.py
#	api/controllers/console/workspace/workspace.py
#	api/services/agent/observability_service.py
#	api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py
#	api/tests/unit_tests/core/rag/embedding/test_token_counter.py
#	api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py
#	api/tests/unit_tests/services/agent/test_agent_observability_service.py
2026-08-06 10:46:15 +08:00
FFXN
095e9c3a31 fix: update subtreeTree hash in knowledge-fs-contract.lock.json 2026-08-06 10:36:03 +08:00
FFXN
f1e79a7ad6 feat: implement logging for durable deletion failures with job details 2026-08-05 18:57:12 +08:00
FFXN
3213741104 feat: add logging for upstream rejection details in KnowledgeFS requests 2026-08-05 15:55:28 +08:00
Jyong
ddd5e3c46b Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-05 02:06:10 -04:00
Jyong
2204ccaed5 chore(knowledge-fs): refresh contract lock 2026-08-05 02:00:43 -04:00
Stephen Zhou
a4756e953d feat(dataset): filter New RAG spaces by creator 2026-08-05 13:46:06 +08:00
Jyong
aa58dfb598 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-05 00:16:10 -04:00
Jyong
140726a1ea merge: structured document section tree 2026-08-04 23:46:34 -04:00
Jyong
90e373199c fix(knowledge-fs): use structured document sections 2026-08-04 23:46:13 -04:00
Stephen Zhou
c0a5ec4e67 feat(dataset): preview local files before upload 2026-08-05 09:36:14 +08:00
Stephen Zhou
a77f508b05 feat(dataset): show linked app counts in New RAG spaces 2026-08-04 20:49:52 +08:00
Stephen Zhou
d2b58f8636 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	web/features/new-rag/new-knowledge-list.tsx
2026-08-04 19:36:24 +08:00
Stephen Zhou
c758131fb9 feat(dataset): support New RAG document metadata 2026-08-04 19:33:19 +08:00
Jyong
4fd80c8d59 Improve knowledge grep matching for document projections 2026-08-04 05:41:52 -04:00
Jyong
fcf87ec68c fix(knowledge-fs): read current published fs paths 2026-08-04 03:46:00 -04:00
Stephen Zhou
1ac9a6ba25 fix(dataset): align New RAG quality button spacing 2026-08-04 15:34:51 +08:00
Jyong
3d9feaba0b fix(knowledge-fs): classify capability reads from operations 2026-08-04 03:17:15 -04:00
Jyong
c944ffe558 fix(knowledge-fs): honor capability grants in fs handlers 2026-08-04 02:56:03 -04:00
Stephen Zhou
28b3306867 Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-08-04 14:43:38 +08:00
Jyong
87d305da1d chore(knowledge-fs): refresh contract subtree lock 2026-08-04 00:26:36 -04:00
Jyong
505fe93d2c fix(knowledge-fs): update vulnerable transitive dependencies 2026-08-04 00:17:57 -04:00
Jyong
e08568b3b5 ci(cli): build edge on knowledge deploy branch 2026-08-04 00:08:13 -04:00
Stephen Zhou
a65845e5fa fix(dataset): synchronize New RAG translations 2026-08-03 23:22:30 +08:00
Stephen Zhou
da92158f26 fix(knowledge-fs): refine Chinese document copy 2026-08-03 17:57:54 +08:00
Jyong
e65905b4b3 Enrich research task progress with stage details 2026-08-03 05:38:52 -04:00
Jyong
17ccc0b995 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-08-03 05:04:06 -04:00
Jyong
3d99416631 Implement application updates 2026-08-03 05:01:00 -04:00
Stephen Zhou
53e9a62b6e fix(knowledge-fs): enable research citation navigation 2026-08-03 12:50:27 +08:00
Stephen Zhou
19ad4b6a72 fix(knowledge-fs): sync retrieval record selection with URL 2026-08-03 11:49:42 +08:00
Stephen Zhou
701f591c5e fix(knowledge-fs): sync document upload state with URL 2026-08-03 11:31:05 +08:00
Stephen Zhou
ac0031b75d fix(knowledge-fs): restore discard dialog spacing 2026-08-03 11:18:25 +08:00
Stephen Zhou
c7e4cb0c29 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/extensions/test_ext_login.py
#	web/app/components/main-nav/__tests__/index.spec.tsx
2026-08-03 11:10:06 +08:00
Stephen Zhou
d040afebfb fix(knowledge-fs): align new rag edge states 2026-08-03 11:06:21 +08:00
Jyong
1648d8ac6b fix(knowledge-fs): handle initial revisions and published evidence 2026-08-02 14:47:09 -04:00
Jyong
afee5e28d2 Update knowledge filesystem contract subtree reference 2026-08-02 13:46:37 -04:00
Jyong
4a7195be12 Bound Dify embedding requests and preserve result ordering 2026-08-02 13:28:31 -04:00
Jyong
923507baeb Stream research task answer deltas through durable progress events 2026-08-02 12:45:51 -04:00
Jyong
6aacd46b03 test(knowledge-fs): cover openapi filesystem controller 2026-08-02 12:06:26 -04:00
Jyong
d93902eaff fix(knowledge-fs): validate evidence match response 2026-08-02 11:56:25 -04:00
Jyong
4d5f845b8c Merge codex/golden-question-bulk-import into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
#	api/services/knowledge_fs/data_facade.py
#	api/tests/unit_tests/services/test_knowledge_fs_data_facade.py
#	knowledge-fs/scripts/export-capability-v2-operations.test.mjs
#	packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts
2026-08-02 11:35:09 -04:00
Jyong
c9cdc34abc feat(dataset): add golden question bulk import 2026-08-02 11:29:11 -04:00
Jyong
737f315b53 Merge deploy/konwledge into codex/knowledge-fs-difyctl 2026-08-02 11:24:06 -04:00
Stephen Zhou
c91e48127a fix(dataset): align New RAG overview indexing state 2026-07-31 17:20:07 +08:00
Stephen Zhou
0eb582492f fix(knowledge-fs): update contract lock 2026-07-31 17:15:40 +08:00
Stephen Zhou
74ec0c78b9 test(knowledge-fs): update capability export count 2026-07-31 17:09:21 +08:00
Stephen Zhou
abee50b90e fix(dataset): align New RAG activity filters 2026-07-31 17:00:59 +08:00
Stephen Zhou
e891790ce0 fix(dataset): connect New RAG overview data 2026-07-31 16:23:50 +08:00
Jyong
ce847f2b6f feat(cli): add KnowledgeFS filesystem commands 2026-07-31 04:11:14 -04:00
Stephen Zhou
26f95a3bca fix(dataset): align New RAG overview skeleton layout 2026-07-31 15:45:46 +08:00
Stephen Zhou
0950bf609d fix(dataset): polish New RAG overview states 2026-07-31 15:29:41 +08:00
Stephen Zhou
5b939067e1 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/services/test_app_dsl_service.py
#	web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
2026-07-31 14:28:03 +08:00
Stephen Zhou
465e7011e5 fix(dataset): align New RAG overview design 2026-07-31 12:05:53 +08:00
Stephen Zhou
fe79fed793 fix(knowledge-fs): update contract lock 2026-07-31 11:55:29 +08:00
Stephen Zhou
c0b8e901ba fix(dataset): make quality table responsive 2026-07-31 11:48:54 +08:00
Stephen Zhou
007b28e2ed fix(dataset): fill document detail height 2026-07-31 11:45:48 +08:00
Stephen Zhou
480deebf02 fix(knowledge-fs): harden research progress and scoring 2026-07-31 11:39:36 +08:00
Jyong
f1045bc099 Retry provider errors in document compilation runtime 2026-07-30 23:35:31 -04:00
Stephen Zhou
8a7da3192f fix(dataset): align New RAG retrieval test design 2026-07-31 01:29:45 +08:00
Stephen Zhou
bc312f249e fix(dataset): refresh quality after golden question save 2026-07-31 01:16:14 +08:00
Stephen Zhou
a392199048 fix(dataset): align New RAG sidebar active state 2026-07-31 01:11:04 +08:00
Stephen Zhou
337c4f8f87 fix(knowledge-fs): update contract lock 2026-07-31 01:08:45 +08:00
Stephen Zhou
c517805213 fix(dataset): remove redundant quality table scrollbar 2026-07-31 01:01:07 +08:00
Stephen Zhou
049208c095 fix(knowledge-fs): fence capability profile activation 2026-07-31 00:54:37 +08:00
Stephen Zhou
c07f3d9373 fix(dataset): align New RAG document chunk presentation 2026-07-31 00:18:37 +08:00
Jyong
f39b15f76d Resume document compilation from persisted checkpoints 2026-07-30 12:03:59 -04:00
Stephen Zhou
cb02cdd545 fix(dataset): stabilize New RAG re-index task handling 2026-07-30 23:51:33 +08:00
Stephen Zhou
3190ea955a fix(dataset): auto-load New RAG document chunks 2026-07-30 22:06:25 +08:00
Stephen Zhou
9dca9e139f fix(knowledge-fs): update contract lock 2026-07-30 21:53:14 +08:00
Stephen Zhou
3adb119dd3 test(knowledge-fs): update capability export count 2026-07-30 21:46:43 +08:00
Stephen Zhou
13778d96a0 fix(dataset): wire New RAG document actions 2026-07-30 21:37:05 +08:00
Stephen Zhou
121a2e7b87 fix(knowledge-fs): update contract lock 2026-07-30 20:46:46 +08:00
Stephen Zhou
29602d261c Revert "fix(dataset): retry failed New RAG document processing"
This reverts commit 3bca4b3485.
2026-07-30 20:37:15 +08:00
Stephen Zhou
bf5f916cd4 fix(knowledge-fs): improve semantic extraction resilience 2026-07-30 20:36:53 +08:00
Jyong
5eabe14982 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-07-30 07:37:23 -04:00
Jyong
6c59bdf724 Merge branch 'codex/research-semantic-llm-tree-search' into deploy/konwledge 2026-07-30 07:35:43 -04:00
Jyong
02e8867588 feat(knowledge-fs): add semantic LLM research retrieval 2026-07-30 07:35:31 -04:00
Stephen Zhou
3bca4b3485 fix(dataset): retry failed New RAG document processing 2026-07-30 18:26:04 +08:00
Stephen Zhou
8f19a9163a fix(dataset): center New RAG document action icon 2026-07-30 18:18:42 +08:00
Stephen Zhou
1a4ad7edb2 fix(dataset): center New RAG documents content 2026-07-30 18:08:17 +08:00
Stephen Zhou
178c301c5f fix(dataset): align New RAG settings edge states 2026-07-30 17:59:00 +08:00
Stephen Zhou
1e2ebf43f9 fix(knowledge-fs): update contract lock 2026-07-30 17:33:25 +08:00
Jyong
c5597305e0 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-07-30 05:20:42 -04:00
Jyong
0e06266770 Aggregate knowledge-space overview queries across all members 2026-07-30 05:20:20 -04:00
Stephen Zhou
4ee61733ec fix(dataset): remove New RAG create chevron 2026-07-30 17:08:21 +08:00
Stephen Zhou
dc959e0c0f fix(dataset): align New RAG settings with design 2026-07-30 17:04:54 +08:00
Stephen Zhou
7b8770d203 fix(knowledge-fs): update contract lock 2026-07-30 17:03:18 +08:00
Jyong
4b53ad8b9d Route knowledge graph ingestion through space-scoped reasoning models 2026-07-30 04:54:09 -04:00
Stephen Zhou
1b9db252bc fix(dataset): enable New RAG settings scrolling 2026-07-30 16:23:50 +08:00
Stephen Zhou
3caeb28f4a fix(dataset): align New RAG sources layout 2026-07-30 16:17:59 +08:00
Stephen Zhou
360668492f fix(dataset): align New RAG source icon 2026-07-30 16:03:14 +08:00
Jyong
4707a4226a chore: align KnowledgeFS contract lock after merge 2026-07-30 03:53:07 -04:00
Jyong
ca0aca6fd7 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-07-30 03:43:36 -04:00
Stephen Zhou
9b6796df1c fix(dataset): align New RAG empty state with design 2026-07-30 15:25:10 +08:00
Jyong
1af4d746d3 merge: fix golden questions latency 2026-07-30 03:15:13 -04:00
Jyong
a82290fe8f perf(knowledge-fs): reduce capability request overhead 2026-07-30 03:14:51 -04:00
Jyong
fc377d4b8f chore: refresh KnowledgeFS contract lock 2026-07-30 02:52:31 -04:00
Jyong
0ec4940c9a fix research result partial refresh 2026-07-30 02:49:10 -04:00
Jyong
acac966cdc fix duplicate background task polling 2026-07-30 02:34:24 -04:00
Jyong
79984e1c84 perf(knowledge-fs): eliminate graph and publication query hot spots
Fixes five scaling hot spots in the KnowledgeFS repository layer. All changes
preserve existing behavior and dual-dialect (Postgres/TiDB) parity; verified via
the full packages/api suite and by generating the emitted SQL for both dialects.

- Graph traversal (#2): restrict the relation_fanout CTE to relations whose
  subject is the start entity or one of its objects, so the ROW_NUMBER window no
  longer ranks every relation in the space. maxDepth is capped at 2, so the
  reachable-subject seed is a provable superset of the visited frontier; results
  are unchanged. Uses the existing graph_relations_subject_traversal_idx.
- Community materialization (#3): bound the per-entity traversal fan-out with a
  concurrency limiter instead of an unbounded Promise.all, preventing connection
  pool exhaustion. Identical queries and results.
- Profile-migration PageIndex promotion (#4): replace the up-to-100k per-row
  SELECT ... FOR UPDATE loop with one set-based UPDATE plus a NOT EXISTS
  completeness fence, keeping the safety bound and error code.
- Chunk snapshot publish (#5): batch the per-chunk INSERT loop into multi-row
  VALUES statements (<=1000 rows/batch, under the bind-parameter limit).
- Graph batch upsert read-back (#7): replace the per-row SELECT after each batch
  upsert with one indexed IN-list query per (space, generation) group, preserving
  the mismatch, duplicate, and did-not-persist fences.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:27:43 -04:00
Stephen Zhou
b66c7c9a52 Remove KnowledgeFS full-chain validation record 2026-07-30 14:23:25 +08:00
Jyong
e052b2e27f Support emoji icons and map Knowledge FS bad requests 2026-07-30 01:34:56 -04:00
Stephen Zhou
9bfe856f59 fix(dataset): make New RAG quality tables responsive 2026-07-30 11:30:15 +08:00
Stephen Zhou
321263af61 fix(dataset): align New RAG page layouts 2026-07-30 11:09:03 +08:00
Stephen Zhou
37b07b1709 fix(dataset): align New RAG empty states 2026-07-30 10:51:26 +08:00
Stephen Zhou
72a75fe3bb fix(dataset): align New RAG sources empty state 2026-07-30 10:13:45 +08:00
Jyong
5869016c42 Restore Research answer synthesis routing 2026-07-29 13:32:01 -04:00
Jyong
e653935288 Normalize retrieval scores before fusion and ranking 2026-07-29 12:45:45 -04:00
Jyong
08e974ed48 chore: refresh KnowledgeFS contract lock 2026-07-29 11:52:00 -04:00
Stephen Zhou
cf0d5afcef fix(dataset): stabilize overview loading state 2026-07-29 23:27:37 +08:00
Jyong
3a42945fcc Refresh retry base revision and preserve immutable candidates 2026-07-29 11:14:51 -04:00
Jyong
b8a2f991c1 Keep interactive retrieval evidence-only and isolate Research synthesis 2026-07-29 11:14:51 -04:00
Jyong
5c359b710a Normalize Unstructured layout and enable coordinates 2026-07-29 11:14:51 -04:00
Stephen Zhou
4ffe1870d1 fix(dataset): synchronize New RAG translations 2026-07-29 22:52:23 +08:00
Stephen Zhou
2f892071d2 fix(dataset): align New RAG acceptance flows 2026-07-29 22:41:42 +08:00
Stephen Zhou
effcefca34 fix(knowledge-fs): update contract lock 2026-07-29 20:45:34 +08:00
Stephen Zhou
617ba37988 lint 2026-07-29 20:29:28 +08:00
Stephen Zhou
addd754eb3 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-07-29 20:27:52 +08:00
Stephen Zhou
f2138ac15e Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge 2026-07-29 20:26:19 +08:00
Stephen Zhou
f9bd5ad923 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/services/test_account_service.py
#	web/features/new-rag/add-source-page.tsx
#	web/features/new-rag/components/knowledge-space-card.tsx
#	web/features/new-rag/components/knowledge-view-switcher.tsx
#	web/features/new-rag/document-chunk-detail.tsx
#	web/features/new-rag/document-list.tsx
#	web/features/new-rag/knowledge-space-shell.tsx
#	web/features/new-rag/sources-page.tsx
2026-07-29 20:26:13 +08:00
Jyong
dd1d24e341 Preserve Dify route identity across upstream model aliases 2026-07-29 07:27:19 -04:00
Jyong
7731f1bdc0 Harden Knowledge FS remote transport validation 2026-07-29 07:01:59 -04:00
Jyong
5270f78ff1 Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
# Conflicts:
#	api/knowledge-fs-contract.lock.json
2026-07-29 06:22:07 -04:00
Jyong
34f4e3b145 Update frontend components and application workflows 2026-07-29 06:18:05 -04:00
Stephen Zhou
360fa45b3e Merge remote-tracking branch 'origin/main' into deploy/konwledge 2026-07-29 17:42:53 +08:00
Stephen Zhou
9187eb61ac fix(knowledge-fs): update contract lock 2026-07-29 17:29:04 +08:00
Stephen Zhou
5c311d3eef feat(dataset): add New Knowledge overview 2026-07-29 17:20:40 +08:00
Jyong
1884f03350 Reduce KnowledgeFS task polling page size 2026-07-29 04:36:43 -04:00
Jyong
333c50f663 Increase preflight reasoning output token limit 2026-07-29 04:10:35 -04:00
Jyong
71e9755d6c Scope knowledge rate limiting to entry points 2026-07-29 03:09:42 -04:00
Jyong
6cfd1abb59 Refactor frontend architecture and simplify implementation 2026-07-29 02:30:27 -04:00
Jyong
5e7ea24340 fix(knowledge-fs): use declared startup probe table 2026-07-29 01:39:16 -04:00
Jyong
71d07d8770 fix(knowledge-fs): retry database startup recovery 2026-07-29 00:51:28 -04:00
Stephen Zhou
6dd0f85f67 feat(dataset): add New Knowledge space actions 2026-07-29 12:15:33 +08:00
Stephen Zhou
85fbeb644a chore: refresh KnowledgeFS contract lock 2026-07-29 11:35:43 +08:00
Stephen Zhou
b8a4586b51 test(knowledge-fs): cover product validation branches 2026-07-29 11:29:04 +08:00
Stephen Zhou
d51692a62d fix(api): restore agent app binding lookup 2026-07-29 11:29:01 +08:00
Stephen Zhou
6f6198f719 test: align password reset token assertion 2026-07-29 11:01:25 +08:00
Stephen Zhou
cd86ffd6f8 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/controllers/console/app/completion.py
#	api/core/app/apps/agent_app/app_generator.py
#	api/services/agent/composer_service.py
#	api/services/agent/roster_service.py
#	api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py
#	api/tests/unit_tests/core/agent/test_publish_visibility.py
#	api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py
#	api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py
#	api/tests/unit_tests/services/agent/test_agent_services.py
2026-07-29 10:58:29 +08:00
Stephen Zhou
20a06fdb94 feat(dataset): add New RAG quality control 2026-07-29 10:49:52 +08:00
Stephen Zhou
4bb1165c9d chore: refresh KnowledgeFS contract lock 2026-07-28 23:50:28 +08:00
Stephen Zhou
3a7a5075de feat(dataset): add New RAG retrieval test 2026-07-28 23:34:06 +08:00
Stephen Zhou
f5fa4d8213 feat(dataset): support deferred New RAG model setup 2026-07-28 23:19:25 +08:00
Jyong
51d36b5c9e test(knowledge-fs): cover archived workspace bootstrap 2026-07-28 09:25:21 -04:00
Jyong
2674190bb4 fix(knowledge-fs): align retrieval profile payload alias 2026-07-28 09:21:40 -04:00
Jyong
ff07021c22 feat(knowledge-fs): automate greenfield workspace cutover 2026-07-28 09:12:50 -04:00
Stephen Zhou
d2380dd951 feat(dataset): add New RAG knowledge settings 2026-07-28 20:56:00 +08:00
Jyong
a7fb5513e4 chore: refresh KnowledgeFS contract lock 2026-07-28 06:44:29 -04:00
Jyong
76f67f096e fix(knowledge-fs): decode freeze bigint evidence 2026-07-28 06:34:02 -04:00
Jyong
da9870b5e1 fix(knowledge-fs): isolate reconciliation grants 2026-07-28 06:15:17 -04:00
Jyong
79a681b88b fix(knowledge-fs): persist greenfield audit anchor 2026-07-28 06:08:21 -04:00
Jyong
0ed37b82d3 fix(knowledge-fs): support empty workspace cutover 2026-07-28 05:53:02 -04:00
Stephen Zhou
fbb1043f58 fix(dataset): align New RAG permission behavior 2026-07-28 16:24:54 +08:00
Stephen Zhou
65b9dc09a2 fix(dataset): stabilize New RAG creation and indexing 2026-07-28 15:49:52 +08:00
Jyong
6eed85af89 Merge branch 'main' into deploy/konwledge
# Conflicts:
#	api/tests/test_containers_integration_tests/services/test_workspace_service.py
#	api/tests/unit_tests/controllers/web/test_web_login.py
#	api/tests/unit_tests/libs/test_helper.py
2026-07-28 03:25:30 -04:00
Jyong
4df8c6662f chore: refresh KnowledgeFS contract lock 2026-07-28 02:35:21 -04:00
Jyong
d89a92dbad ci: exclude KnowledgeFS API coverage gate 2026-07-28 02:01:51 -04:00
Stephen Zhou
4ebee54ffd fix(ci): restore KnowledgeFS branch coverage gate 2026-07-28 13:22:00 +08:00
Jyong
1b6b811108 ci: wait for KnowledgeFS before deployment 2026-07-28 01:01:32 -04:00
Stephen Zhou
ab0b2282ab fix(dataset): complete New RAG connected source flows 2026-07-28 12:58:18 +08:00
Jyong
f02de24470 ci: gate Knowledge deployment on KnowledgeFS 2026-07-28 00:47:39 -04:00
Stephen Zhou
665515da40 chore: refresh KnowledgeFS contract lock 2026-07-28 12:01:14 +08:00
Stephen Zhou
68fb92c8d5 fix(ci): align KnowledgeFS integration tests 2026-07-28 11:54:55 +08:00
Stephen Zhou
7a20d54847 fix(ci): format KnowledgeFS data facade 2026-07-28 11:47:05 +08:00
Stephen Zhou
53e12ff376 fix(ci): format KnowledgeFS deletion capability test 2026-07-28 11:40:57 +08:00
Stephen Zhou
055143f67a fix(ci): align KnowledgeFS deployment env assertion 2026-07-28 11:36:03 +08:00
Stephen Zhou
b4a8f7f180 fix(dataset): align New RAG document views 2026-07-27 22:56:59 +08:00
Stephen Zhou
e5d4f37469 fix(dataset): restore New RAG document uploads 2026-07-27 21:46:16 +08:00
Stephen Zhou
556fc7a03a fix(dataset): align New RAG sidebar icons 2026-07-27 18:34:23 +08:00
Stephen Zhou
cc37dfcb01 fix(dataset): align New RAG controls with design system 2026-07-27 18:19:11 +08:00
Stephen Zhou
3de75a3315 fix(dataset): stabilize New RAG KnowledgeFS flows 2026-07-27 16:18:51 +08:00
Stephen Zhou
28c2b6ee95 Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts:
#	api/controllers/console/auth/login.py
#	api/controllers/console/feature.py
#	api/controllers/web/site.py
#	api/openapi/markdown/console-openapi.md
#	api/openapi/markdown/web-openapi.md
#	api/services/feature_service.py
#	api/tests/test_containers_integration_tests/services/test_account_service.py
#	api/tests/test_containers_integration_tests/services/test_feature_service.py
#	api/tests/unit_tests/controllers/console/auth/test_email_verification.py
#	api/tests/unit_tests/controllers/console/auth/test_login_logout.py
#	api/tests/unit_tests/controllers/web/test_site.py
#	api/tests/unit_tests/services/test_recommended_app_service.py
2026-07-27 13:16:01 +08:00
Stephen Zhou
1cde846bcf fix(dataset): integrate New RAG with KnowledgeFS (#39621) 2026-07-27 12:52:44 +08:00
Stephen Zhou
ce0dee9afb Merge branch 'main' into deploy/konwledge
# Conflicts:
#	api/controllers/console/knowledge_fs_proxy.py
#	api/dev/generate_knowledge_fs_contract.py
#	api/extensions/storage/aws_s3_storage.py
#	api/knowledge-fs-contract.lock.json
#	api/services/knowledge_fs_proxy.py
#	api/tests/unit_tests/controllers/console/test_knowledge_fs_proxy.py
#	api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py
#	api/tests/unit_tests/extensions/storage/test_aws_s3_storage.py
#	api/tests/unit_tests/services/test_knowledge_fs_proxy.py
2026-07-24 23:43:42 +08:00
Jyong
11ae998de0 ci: gate dev deploy on all image builds 2026-07-24 03:13:40 -04:00
Jyong
901923457c Update deploy-dev.yml 2026-07-24 00:54:50 -04:00
Jyong
1b28bacb46 Update deploy-dev.yml 2026-07-24 00:14:28 -04:00
Jyong
918989b98a test: restore KnowledgeFS API branch coverage 2026-07-24 00:01:36 -04:00
Jyong
372930ce71 Update knowledge-fs lock for bulk operations migration 2026-07-23 23:45:17 -04:00
Jyong
652dccd2c4 Update deploy-dev.yml 2026-07-23 23:35:59 -04:00
Jyong
90b6a7f012 Expand workflow features and update related components 2026-07-23 22:53:41 -04:00
Jyong
7efe5a4783 chore: refresh KnowledgeFS contract lock 2026-07-23 03:00:24 -04:00
Jyong
0300a4d881 fix: support Dify-managed KnowledgeFS deployment 2026-07-23 02:51:05 -04:00
Jyong
85c8d026a2 chore: refresh KnowledgeFS contract lock 2026-07-23 02:33:24 -04:00
Jyong
c09d61d8bc ci: cover KnowledgeFS inner API controllers 2026-07-23 02:25:45 -04:00
Jyong
f482bf0aa1 Refactor codebase and remove obsolete implementations 2026-07-23 01:53:40 -04:00
Jyong
2eb328104a fix: type Dify Agent core tool boundaries 2026-07-22 07:32:29 -04:00
Jyong
91a240c072 ci: register KnowledgeFS cleanup migrations 2026-07-22 07:24:07 -04:00
Jyong
dd70011fd9 test: align RBAC expectations after billing boundary fix 2026-07-22 07:12:02 -04:00
Jyong
954be8443c fix: unblock KnowledgeFS secure image publishing 2026-07-22 07:01:02 -04:00
Jyong
cae5553f1a build: publish KnowledgeFS from deploy branch 2026-07-22 06:39:35 -04:00
Jyong
fc7fabe544 Merge branch 'codex/migrate-knowledge-fs' into deploy/konwledge
# Conflicts:
#	api/tests/unit_tests/services/enterprise/test_rbac_service.py
#	api/tests/unit_tests/services/test_agent_tool_inner_service.py
2026-07-22 06:16:29 -04:00
Jyong
d74499c09d update github action 2026-07-22 06:07:12 -04:00
Jyong
29858d0db0 Expand application capabilities and refactor core workflows 2026-07-22 05:09:09 -04:00
Jyong
77732c7bb1 docs: decouple KnowledgeFS from Dify datasets 2026-07-20 06:02:06 -04:00
Jyong
dde79fc9bf docs: add Dify KnowledgeFS integration plan 2026-07-20 05:33:56 -04:00
Jyong
4ee43b8afc chore: migrate knowledge-fs source tree
Import the committed KnowledgeFS snapshot dc4072ee302317145612087ce7440851dc329fd0 under knowledge-fs/ without its Git history, local IDE settings, or build artifacts.
2026-07-20 04:54:20 -04:00
1218 changed files with 638082 additions and 40720 deletions

View File

@@ -1,6 +1,6 @@
---
name: frontend-code-review
description: Use only when the user explicitly requests a review or audit of frontend code under `web/` or `packages/dify-ui/`. Supports pending-change, file-focused, and pasted-diff reviews. Do not use for implementation-only requests, diagnosis without review intent, or backend-only code.
description: Use only when the user explicitly requests a bug, regression, correctness, security, accessibility, performance, or test-quality review of frontend code under `web/` or `packages/dify-ui/`. Supports pending-change, file-focused, and pasted-diff reviews. Do not use for architecture-oriented evaluation of component ownership, state graphs, props, boundaries, or refactoring design unless concrete defects are also requested; do not use for implementation-only requests, diagnosis without review intent, or backend-only code.
---
# Frontend Code Review
@@ -11,8 +11,9 @@ Review the requested scope for concrete defects and violations of explicit proje
1. Establish the review scope from the requested files or current diff.
2. Read the changed lines, their behavior owner, and the nearest scoped `AGENTS.md`.
3. Trace public consumers, generated contracts, primitive APIs, or runtime configuration only when they decide correctness.
4. Report findings tied to an observable failure, violated contract, security boundary, or demonstrated maintenance risk. Explicit team conventions are contracts: establish their scope and exceptions, and do not invent user impact to justify a convention finding.
3. For a named component or a diff that changes component ownership, props, or state, read the component-architecture rule pack and complete its Root-To-Leaf State And Props Audit before drawing conclusions.
4. Trace other public consumers, generated contracts, primitive APIs, or runtime configuration only when they decide correctness.
5. Report findings tied to an observable failure, violated contract, security boundary, or demonstrated maintenance risk. Explicit team conventions are contracts: establish their scope and exceptions, and do not invent user impact to justify a convention finding.
## Rule Routing

View File

@@ -9,6 +9,19 @@ Use the canonical reference for the changed concern. These links share rules; th
| Effects, navigation, memoization, and subscriptions | [Runtime] |
| Hotkeys, focus, and secondary surfaces | [Interactions] |
## Root-To-Leaf State And Props Audit
For a file-focused component review, use the named component as the root. For a pending diff, use each changed page, feature entrypoint, or nearest behavior owner whose state or props contract changed.
1. Build the locally owned rendered component tree from each root to every leaf. Follow imported feature components, conditional branches, lists, portals, dialogs, drawers, and popovers. Stop at third-party or Dify UI primitives and explicit stable public feature boundaries, but record the props passed across that final edge.
2. At every component, inventory state sources: React state/reducers, form state, URL state, context/store/Jotai reads and writes, queries, mutations, refs that hold workflow state, and custom-hook results.
3. For every state value, derived fact, and handler, record its consuming branches and required lifetime, including persistence across descendant unmounts. Assess its placement against [Ownership] and [State], including their coordination and persistence exceptions.
4. Inspect every props edge. Mark values that are read by the child, forwarded unchanged, renamed, recomputed, mirrored into local state, or paired with lifecycle fields such as `data/pending/error/retry` and `open/onOpenChange`.
5. Trace what each forwarding parent actually owns before reporting an unnecessary intermediary. Apply [Ownership] to distinguish required coordination or persistence from merely redistributing workflow fields, and [State] to determine whether a feature graph is warranted.
6. Remove unused or redundant props at their source. Do not accept replacing many props with a props bag, context, or hook result unless that abstraction becomes the real owner and gives consumers a narrower contract.
Review the complete paths before concluding that a state or prop is necessary. When several edges share one ownership defect, report one finding at the highest incorrect owner and include a representative path such as `Root -> Section -> Leaf`; do not emit a duplicate finding for every child.
## Apply Rules In Their Actual Scope
Explicit team conventions are reviewable contracts, including module organization and public API boundaries. Check the documented exception before reporting a violation. Do not infer an exception solely because the code appears to work, or invent a user-facing failure for a convention finding.

View File

@@ -1,24 +1,59 @@
---
name: how-to-write-component
description: Use when implementing or refactoring React/TypeScript components and the task requires decisions about component ownership, feature boundaries, state, data flow, effects, or interaction ownership. Do not use for review-only requests, test-only work, copy-only edits, or styling-only changes.
description: Use when implementing, refactoring, or architecture-reviewing React/TypeScript components whose ownership, state lifetimes, data flow, props, feature boundaries, effects, or interaction surfaces need redesign. Do not use for bug, regression, security, or accessibility audits; test-only work; copy-only edits; or styling-only changes.
---
# How To Write A Component
# React Component Architecture
Use this skill to route component architecture decisions to its bundled references. Read only the references required by the current change.
Use this skill to make component ownership and lifecycle explicit before implementation. Classify every stateful value on three independent dimensions:
1. **Owner/source:** a component, parent boundary, URL/framework API, TanStack Query, or feature workflow.
2. **Graph role:** primitive input, query/mutation node, derived business fact, or workflow command.
3. **Lifecycle:** instance isolation, persistence, and reset requirements.
Do not force one label to answer all three dimensions.
Keep display state, submit-only fields, and owner-local queries or mutations with the component or DOM that owns their complete lifecycle. A value enters the feature state graph only when it crosses an ownership boundary, participates in shared derivation or coordination, or must outlive/reset independently of its current owner. Driving an owner-local query or command alone is not sufficient. In this repository, use feature-local Jotai as the default representation once a value enters that graph.
## Operating Modes
Choose the mode from the user's requested outcome:
- **Architecture audit:** Perform a read-only, root-to-leaf analysis and propose the target ownership model. Read [`references/audit.md`][audit] and [`references/ownership.md`][ownership].
- **Refactoring design:** Perform the architecture audit, then provide target contracts, migration slices, and a verification strategy without modifying code. Read [`references/audit.md`][audit] and [`references/ownership.md`][ownership].
- **Implementation:** Establish the affected owner map, state graph, props edges, and reset boundaries before editing. Implement one coherent vertical slice at a time, then run the final audit in [`references/audit.md`][audit]. Read [`references/ownership.md`][ownership].
- **Bug or regression review:** Use the frontend code-review workflow unless the user explicitly requests this skill's architecture model as an additional lens.
## Topic Routing
After selecting the operating mode, read only the topic references required by the change:
- 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.
- Jotai, graph-entry decisions, form drafts, shared client state, route identity, URL state, isolation/reset, or persistence: read [`references/state.md`][state].
- Generated contracts, nullable API data, Query, mutations, SSR, authentication, or workspace state: read [`references/data.md`][data].
- A feature graph that combines Jotai isolation with TanStack Query atoms or commands: read both [`references/state.md`][state] and [`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 session state.
- Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime].
## Common Workflow
Follow this order so component splitting does not precede ownership decisions:
1. **Boundary and evidence:** identify the route, tab, workflow, or action surface that owns the behavior and lifetime. Inspect nearby implementation, tests, sibling state files, and established feature boundaries.
2. **Current model:** identify generated data contracts and authoritative external inputs, then inventory state, queries, mutations, derived facts, commands, refs, and meaningful props edges.
3. **Target graph and owners:** decide explicitly which values remain local and which enter the feature graph. Define authoritative bridges, graph nodes, component owners, and scope/reset boundaries before creating files or wiring components.
4. **Re-cut the tree:** treat current files as evidence rather than target constraints. Keep, split, merge, remove, rename, promote, or demote components based on ownership, lifecycle, behavior, and independently changing visual regions.
5. **Contracts and interactions:** place data, loading, empty, error, and handlers at the lowest real consumer. Cross true owner boundaries only with stable identity, a small immutable snapshot, placement, or a named command. Give forms and secondary surfaces explicit lifecycle owners.
6. **Finish and verify:** remove copied state and unnecessary Effects, wrappers, memoization, or nullable coercion. Rerun the final owner, graph, props-edge, and reset audit, then verify observable behavior at the narrowest sufficient boundary.
## Scope And Verification
Identify the behavior owner, state lifetime, and public contract from the nearby implementation and relevant references. Keep the change within that vertical slice unless the contract requires changes elsewhere. Verify the changed behavior and complete the owning package's required checks. For Web, read `web/docs/test.md` for test work and `web/docs/lint.md` for static checks. Dify UI verification is owned by `packages/dify-ui/docs/testing.md`.
In architecture-audit and refactoring-design modes, do not modify code. In implementation mode, complete equivalent ownership fixes inside the audited feature when the target contract depends on them, but do not expand into unrelated features. Incremental slices may retain documented temporary edges; the final requested slice must pass the completion gate in [`references/audit.md`][audit].
Run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI.
[audit]: references/audit.md
## Tailwind CSS

View File

@@ -0,0 +1,64 @@
# Component Architecture Audit
Read this document for architecture audits, refactoring designs, and the final ownership verification after an implementation refactor.
## Audit Procedure
For a named component, use it as the audit root. For a refactoring request, use the nearest route, page, feature entrypoint, or behavior owner whose state contract is changing.
1. Build the complete locally owned rendered component tree from the root to every leaf. Follow imported feature components, conditional branches, lists, portals, dialogs, drawers, popovers, and secondary surfaces. Stop at third-party primitives, Dify UI primitives, or an explicit stable feature boundary, but record the props passed across the final edge.
2. At every component, inventory React state and reducers, form and uncontrolled DOM state, URL and route state, Context/Jotai/store reads and writes, queries, mutations, refs that hold workflow or lifecycle state, custom-hook results, render-time derived facts, and workflow commands or handlers.
3. For every value, record its authoritative owner/source, graph role, declaring component, consuming branches, lifetime and reset/isolation requirements, and whether it is local, shared, forwarded, mirrored, or persisted.
4. Inspect every meaningful props edge. Mark each prop as consumed, forwarded unchanged, renamed, recomputed, mirrored into local state, or paired with lifecycle fields such as `data/pending/error/retry` and `open/onOpenChange`.
5. Draw the current state graph in dependency order and assess ownership only after the complete paths are mapped. Group related problems at the highest incorrect owner instead of repeating the same issue for every descendant.
6. Give every current local component an explicit target disposition: keep, split, merge, remove, rename, promote to an owner, or demote to presentation. Map every current state or workflow owner to a target component and reset boundary.
7. Redraw the target rendered tree independently of the current file layout. Split only when state lifetime, interaction lifecycle, behavior ownership, or an independently changing visual region establishes a real boundary. Merge or remove components that only forward, rename, or obscure a single owner's contract.
8. For refactoring design or implementation planning, define ordered migration slices with explicit state-file changes, component moves/splits/merges, observable verification boundaries, and intentionally temporary props edges.
A rename, facade wrapper, props bag, Context, or provider around the same switchboard does not count as a boundary redesign. Correctness bugs may illustrate an ownership defect, but architecture-audit mode does not prioritize or severity-rank bug findings.
## Audit Output
Cover the following information. Merge sections or omit empty tables when the review is small, but do not omit applicable values, paths, edges, owners, or reset boundaries:
1. **Review boundary:** root component, locally owned rendered paths, and stopping boundaries.
2. **Rendered component tree:** every local branch and secondary surface followed.
3. **State inventory:** owner/source, graph role, graph-entry decision, declaration, consumers, and lifetime/reset needs for every stateful value, query, mutation, ref, custom-hook result, derived fact, and command.
4. **Props-edge ledger:** every meaningful parent-child edge and how each prop is treated.
5. **Current architecture:** state graph, component owners, and ownership assessment.
6. **Component dispositions:** the target disposition of every current local component and the destination of every current state/workflow owner.
7. **Target architecture:** target rendered tree, state graph, owners, component contracts, and reset boundaries.
8. **Migration slices:** required for refactoring design; include them in an audit when they materially clarify how to reach the target.
Use compact tables when they make full accounting easier to verify:
| Value | Source/role | Declared at | Consumers | Props path | Lifetime/reset | Recommended owner |
| --- | --- | --- | --- | --- | --- | --- |
| Edge | Prop | Treatment | Real consumer | Assessment |
| --- | --- | --- | --- | --- |
| Current component | Current responsibilities | Target disposition | Target owner(s) | Reason |
| --- | --- | --- | --- | --- |
| Target component | Owned state/workflow | Children | Public contract | Reset boundary |
| --- | --- | --- | --- | --- |
## Implementation Completion Gate
Repeat the audit against the final code. Do not describe an implementation or refactor as complete until all applicable checks pass:
- every locally owned rendered path, meaningful props edge, and stateful value is represented in the component tree, inventory, or ledger;
- every current local component has a target disposition, and every current state/workflow owner maps to an implemented component and reset boundary;
- every multi-consumer graph value has one authoritative bridge or graph node and a named owner;
- route or URL identity is read directly by a single consumer or bridged once, not also threaded through descendant props;
- query keys, observer objects, `refetch`, loading/error groups, and cache invalidation mechanics do not cross a boundary unless the child owns the query surface;
- no page, hook, provider, or Context mainly destructures, renames, and redistributes another state machine;
- components consume focused fields, named facts, or domain commands instead of rebuilding shared business conclusions;
- query and mutation atoms remain cache-shared, while only resettable workflow primitives or boundary snapshots are scoped;
- a feature scope that depends on application state still observes parent/global atom updates; a fresh `Provider` is used only when full-store isolation is an explicit requirement;
- scoped-state verification covers parent-store interoperation, sibling-instance isolation when multiple instances are supported, and semantic-identity reset;
- every remaining multi-layer prop edge is justified as stable identity, an immutable display snapshot, placement, or a named cross-boundary command;
- the final rendered tree, state graph, owner map, contracts, and reset boundaries match the implemented code rather than only the initial design.
If the user requests incremental commits, a slice may intentionally leave documented edges for a later slice. The final requested slice must still pass this gate.

View File

@@ -17,18 +17,24 @@ Read this document when a component consumes generated contracts, nullable API v
- Use generated options directly with `useQuery(consoleQuery.xxx.queryOptions(...))`, `marketplaceQuery`, or the equivalent generated client.
- If query input comes from atom state, keep it in `atomWithQuery`; do not unwrap the atom in a component solely to call `useQuery`.
- Keep owner-local queries in `useQuery` when their pending, error, and data state serve only that component. Promote them to query atoms only when atom input drives them or other graph nodes consume their result.
- When a query supplies several sibling owners, shared derived facts, or workflow commands, model it as a graph node with field selectors. Do not keep it in a page and pass its data, query key, observer methods, and status fields back down as a deconstructed query object.
- For missing required input, branch the whole generated input with `skipToken`. Add `enabled` only for an independent execution condition; do not put `skipToken` inside a placeholder payload or coerce IDs to empty strings.
- Return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly from TanStack Query atoms. Pass supported options into the generated call instead of spreading into a parallel object.
- For the same logical request, preserve key, input, operation, and result contracts. Reuse exact options only when
transport, context, and cache policy are shared; imperative and observer freshness may differ. Do not extract a helper
merely to reuse input construction.
- For the same logical request, preserve key, input, operation, and result contracts. Share the exact options between prefetch and render only when transport, context, and cache policy are shared; imperative and observer freshness may differ. Do not extract option helpers merely to reuse input construction.
- In Jotai-backed components, consume field-level selectors or named facts rather than the complete query result unless observer methods such as `refetch` or an infinite-scroll field group are part of that owner's contract.
- Keep observer methods inside the component that owns the query surface or behind a named graph command. A parent must not pass raw `refetch`, query keys, or invalidation details merely because a descendant action needs fresh data.
- Treat `refetch()` as an explicit execution command: it can run a query whose observer currently has `enabled: false`. Retry and refresh commands must recheck the permission, identity, or availability condition before including conditionally enabled queries.
- Cache writes and query-observer notifications may reach React through asynchronous batching, including when `atomWithQuery` observes an existing QueryClient. Tests that call `setQueryData`, invalidate, or complete a refetch must await the resulting DOM fact with `findBy*` or `waitFor`; do not assume the observer rerender is synchronous with the cache call.
- Avoid pass-through service hooks that only rename generated options. Keep feature hooks for actual orchestration or shared domain behavior.
## Mutations And Cache
- Use generated `mutationOptions()` directly for owner-local mutations.
- Keep a mutation in `useMutation` when pending and error state belong to one dialog or form. Use a mutation atom only when shared workflow orchestration or graph-derived state consumes it; query and mutation atoms remain unscoped.
- Do not introduce deprecated `useInvalid` or `useReset` APIs.
- Put shared invalidation, retries, and cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. In oRPC v1, caller options override defaults, including callbacks. Local callbacks may own toast, close, and navigation effects but must preserve shared cache behavior.
- Workflow-specific invalidation belongs beside the mutation/command that understands which caches become stale. Construct generated query options or keys from graph identity there instead of receiving keys from a rendering parent.
- Prefer `mutate(...)`. Use `mutateAsync(...)` only when Promise composition is required, and catch awaited failures.
- Preserve intentional empty values and current list/detail ownership when updating data. Do not add optimistic updates without a verified owner contract.

View File

@@ -21,11 +21,14 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
## Secondary Surfaces
- Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy.
- A controlled composite may emit several callbacks synchronously for one gesture, such as changing a mode and then initializing its selection. Every callback sees the same render snapshot. Compose changes through one owner command or functional state update; when later callbacks or immediate side effects require the latest draft before React rerenders, maintain an interaction-owned ref updated in the handlers and clear it at the same semantic reset boundary as the draft.
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
- Treat an action surface as a state boundary when its input feeds several sibling menus, dialogs, default values, confirmation messages, or mutation parameters. Inject the smallest stable identity or snapshot once; keep the form draft local unless other owners must react to it.
- Use the [overlay contract] to determine content lifetime and preserve the Root's closing lifecycle. Follow [state ownership] for draft placement and semantic identity; portal placement alone does not locate the state owner.
- Place query subscriptions and mutation observers at the owner whose lifetime matches when they should run. Mounted-session work may belong inside content; work that must start or stop exactly with `open` needs an explicit open-state condition.
- 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.
- Do not merge dialogs with different loading guards, reset policies, or lifetimes into one discriminated `currentDialog` state merely to reduce atom count.
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
[state ownership]: state.md

View File

@@ -1,6 +1,6 @@
# Component Ownership And Modules
Read this document when adding, moving, splitting, or refactoring React components or feature modules.
Read this document when auditing, adding, moving, splitting, or refactoring React components or feature modules.
## Vertical Modules
@@ -12,12 +12,20 @@ Read this document when adding, moving, splitting, or refactoring React componen
## Component Ownership
- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence.
- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
- Repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient are
acceptable; shared cache is not a reason to hoist. Separate Server Component QueryClients do not share it, so
request-level deduplication needs an identified request-local cache or verified framework or transport owner.
- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept.
- Before declaring state, a query, or a workflow hook in a page or parent, identify the direct descendant branches that consume each returned value.
- If only one branch consumes a value, declare it in the lowest owner in that branch whose mounted lifetime matches the required persistence. The parent may pass stable identity or the smallest boundary input, but must not own child state merely to construct props.
- A parent may own a value when it must intentionally survive the local owner's unmount, or when several sibling branches require one live snapshot and the parent genuinely derives or coordinates submission, selection, batch behavior, navigation, lifecycle, loading, or errors. If it only destructures, renames, and forwards fields, it is a switchboard rather than an owner; put the shared workflow in a feature-local state graph so each surface consumes only its named facts and commands. Use a provider only when it becomes the authoritative input, scope, or external-dependency owner.
- A provider is a real boundary only when it establishes authoritative input, scope/isolation, or a stable external dependency. A provider that calls a large hook and repackages its return value into Context remains a switchboard.
- A page or feature root may wire route identity, providers, layout, navigation, and genuine cross-surface coordination. It must not call a child-specific state or query hook merely to assemble that child's props.
- Keep child contracts at the ownership boundary: stable domain identity, a small immutable snapshot, placement options, or named cross-boundary commands. Do not pass an internal state machine as separate `data`, `pending`, `error`, `retry`, `open`, setter, and callback props when the parent does not use them, and do not hide the same fan-out in a props bag or hook result object.
- Repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient share that cache. Separate Server Component QueryClients do not, so request-level deduplication needs an identified request-local cache or verified framework or transport owner.
- Treat parent input according to what the child boundary does with it:
- If the child only renders or performs a light local decision, pass the snapshot as props. It does not become a new state owner.
- If the child builds queries, dialogs, mutations, derivations, commands, or a reset lifecycle around a stable identity or snapshot, give that boundary a feature-local state file. This does not by itself require a new module or directory.
- Pass stable domain identity or the smallest sufficient action snapshot across boundaries. Do not copy props into atoms merely to avoid passing them, and do not pass raw server data together with separately derived flags for the same concept.
- One pass-through layer is acceptable for stable identity and placement. It is not permission to relay workflow state and handlers through an unrelated component.
- Route identity may pass once from a framework route into its feature boundary. If multiple descendants, queries, facts, or commands need it, bridge it into the feature graph and stop passing it as props.
- A query snapshot may cross once as immutable display input. Query keys, observer methods, retry/loading/error groups, and invalidation mechanics belong to the query surface or feature graph and must not be decomposed into props.
- Revisit prop forwarding when intermediate components obscure the behavior owner; keep clear data flow rather than introducing shared state merely to avoid passing props.
- Do not replace prop drilling with one large view-model hook. Move each query, derived value, and handler to the concrete owner that consumes it.
- Keep source selection, defaults, validation, dirty checks, and payload shaping beside the workflow that owns submission.

View File

@@ -8,6 +8,9 @@ Read this document when a change introduces Effects, navigation side effects, me
- Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API.
- Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query.
- Initialize query-backed form sessions after their defaults are available instead of copying data through Effects. Use a stable semantic identity key when the represented identity changes; use the intended surface lifecycle for per-session reset.
- When external synchronization participates in a feature state graph, prefer a small headless runtime controller that reads and writes focused atoms. Do not hide storage, polling, invalidation, and workflow refs in a giant hook that returns a state-and-handler object for another component or provider to redistribute.
- React state is not an immediate mutex for async serializers, debounce pipelines, migrations, or latest-wins queues because state updates do not synchronously change an in-flight closure. Keep renderable progress in state, but use owner-local refs for immediate locks, active operation identities, and queued latest snapshots; update those refs before awaiting work or scheduling the corresponding state update.
- A state value and ref may describe one workflow only when their roles are explicit: state renders the external status, while the ref coordinates events between renders. Retire both at the same success, failure, cancellation, identity-change, and unmount boundaries so neither can resurrect stale work.
## Navigation
@@ -18,6 +21,7 @@ Read this document when a change introduces Effects, navigation side effects, me
## Runtime Cost
- Move changing state to the smallest consumer before considering memoization. Stable parent content can be lifted and passed as children.
- When a broad state owner causes unrelated rendering, move the owner or expose a narrower atom/query selector before adding memoization.
- Avoid `memo`, `useMemo`, and `useCallback` unless identity or computation has a demonstrated consumer or measurable cost.
- Start independent remote work together and await it near the branch that consumes it. Avoid introducing request waterfalls.
- Load heavy optional surfaces on demand when they sit behind a dialog, tab, command, or feature activation.

View File

@@ -2,12 +2,23 @@
Read this document when a change involves Jotai, form drafts, route identity, shared client state, or local persistence.
## Classify State On Two Axes
First identify the owner or source of truth: one component, a parent boundary, URL/framework APIs, TanStack Query, or the feature workflow. Then identify the graph role independently: primitive input, query/mutation node, derived business fact, or workflow command. Finally mark whether any node needs per-instance isolation or reset; this is a lifecycle requirement, not a node type.
A value does not enter the feature state graph solely because it drives an owner-local query or command. It should enter when it participates in shared workflow state: another owner or graph node consumes it, several consumers need one external-source bridge, it feeds reusable business derivation or coordinated writes, or its persistence/isolation must outlive the current owner. Entering the graph does not transfer ownership from URL APIs or Query cache to Jotai.
Once a value enters the graph, represent it in a feature-local Jotai state file by default. Do not leave graph inputs and async nodes inside a component or large custom hook while exporting only the final result through props or Context; that hides the dependency graph instead of modeling it.
Keep values out of the graph when they only affect one component's presentation, are read only at form submission, or are one-off render computations without domain meaning.
## Choose The Owner
- Keep synchronous state local when one component owns it: dialog and menu state, confirmations, field drafts, and local selections usually belong to the component or DOM.
- Use feature-scoped Jotai when siblings need one source of truth, values drive other atoms, or a scoped workflow must preserve state across hidden or unmounted steps.
- Use feature-scoped Jotai when siblings need one source of truth, values drive other atoms, a parent input establishes its own state graph, or a scoped workflow must preserve state across hidden or unmounted steps.
- Keep server and cache state in TanStack Query. Use existing feature stores for complex, high-frequency interaction state such as workflow canvas drag, resize, and runtime panels.
- Use feature-owned storage only for low-frequency client preferences, dismissed notices, and UI defaults. Live application state does not belong in local storage.
- Use Context for an existing authoritative product boundary or stable dependency injection. Do not introduce Context merely to translate a hook result, query observer, or prop fan-out into another large value object.
## Forms And Sessions
@@ -21,19 +32,52 @@ Read this document when a change involves Jotai, form drafts, route identity, sh
## Route And URL State
- Treat `useParams`, route arguments, and `nuqs` as the owners of URL identity and updates.
- Hydrate a primitive atom at the route or surface boundary only when query atoms or shared derived atoms require route identity. Keep URL writes in route and query-state APIs.
- A single consumer should read the owner hook directly. When several consumers, query atoms, or shared derived atoms require route identity, first match the bridge lifetime to the route or surface instance. An unscoped primitive is safe only when the owning store cannot contain two live instances of that bridge; otherwise scope the route primitive by instance. Keep URL writes in route and query-state APIs.
- A route, framework, or permission bridge that must follow later owner changes should use `dangerouslyForceHydrate: true` at that controlled boundary so descendants observe one synchronous authoritative snapshot. This is the canonical exception to ordinary render-phase write guidance when the hydration tuples contain only those authoritative external inputs and the target store or scope belongs to the current instance. The resulting React warning alone is not an ownership problem, but it must not be used to dismiss cross-instance sharing.
- A scoped workflow input is different: initialize it once, key the scope by semantic identity when switching entities should reset it, and do not force later parent refreshes into an in-progress session. Never extend the bridge exception to feature-owned primitives, drafts, query or mutation atoms, or edit-session snapshots.
- Within one route-owned feature, choose one route-identity source. Do not hydrate route identity into atoms while also threading the same ID through multiple component layers.
- A route parameter may cross the route-to-feature entry edge once. After a route bridge exists, queries, facts, commands, and descendant surfaces must read that bridge instead of accepting the same ID as props.
- Put shareable filters, tabs, selected panels, pagination, and search state in the URL. Keep one-shot navigation signals and transient UI state out of persistent subscriptions.
## Jotai And Query
## Build A Clean Jotai Graph
- A Jotai-backed feature may keep one feature-local state module ordered by dependency: types and constants, primitives, query atoms, query-data derivations, business facts, commands, mutations, submission orchestration, and provider exports.
- Keep one feature-local state file or folder ordered by dependency: primitives and boundary inputs, query atoms, query-data selectors, business facts, commands, mutation atoms when justified, submission orchestration, then provider exports.
- Keep graph flow one-way: primitive inputs -> queries -> named facts -> commands. Do not let components maintain a second writable entry for the same fact.
- Create a derived atom only when its value is reused, consumed by another atom, or deserves a stable business name such as `rows`, `showEmpty`, or `canSubmit`. Leave one-off presentation assembly in the component.
- Keep internal graph nodes unexported. Export only atoms that a component, provider boundary, or another state module must read or write.
- Use `atomWithQuery` or `atomWithMutation` for async work driven by atom state. Do not hand-roll loading, error, or in-flight state for atom-orchestrated work.
- Use field-specific derived atoms for query results. `jotai-tanstack-query` does not provide TanStack Query tracked properties, so reading a whole query atom subscribes to the entire observer result.
- Use `selectAtom` or another field-specific derived atom for query results. `jotai-tanstack-query` does not provide TanStack Query tracked properties, so reading a whole query atom subscribes to the entire observer result. Read the whole result only when the consumer truly needs observer methods or a coordinated group of fields.
- Name derived atoms as business facts and write atoms as user or workflow commands. Commands should express actions such as selecting a source or submitting a wizard, not merely rename setter callbacks.
- Treat query keys, observer methods, cache invalidation, retries, and refresh composition as graph internals. Export a named command such as `refreshDocument`, not the query key plus a raw `refetch` callback.
- A headless runtime controller may synchronize atoms with storage, timers, subscriptions, or other external systems. It should read and write focused graph nodes and render no UI; it must not return a large object for a parent to redistribute.
## Scope, Isolation, And Reset
- Leave query and mutation atoms unscoped so they retain the shared QueryClient cache. Scope resettable primitives and hydration tuples; scope a derived atom only when all dependencies should be private to the surface.
- Use non-null lazy primitives for values always hydrated by a scope provider. Name derived atoms as business facts and write atoms as user or workflow commands.
- `jotai-scope` recursively scopes the dependencies of an explicitly scoped derived or write atom. Review that transitive dependency closure, not only the provider's atom list: scoping a fact or command that reaches a query atom can also privatize route inputs, `queryClientAtom`, and other cache dependencies. Keep query-facing facts and commands outside that scope, or redesign the command to receive the event snapshot it needs instead of reading the query graph.
- Do not add a derived fact or write command to the scoped list merely because it reads or writes scoped primitives. When consumed inside the scope, an unlisted fact or command can resolve those selected primitive dependencies from the current scope while retaining access to unscoped parent atoms; listing it instead recursively privatizes its dependency closure.
- Prefer `ScopeProvider` when an instance must isolate selected workflow atoms while retaining access to atoms in the parent application store. A nested `Provider` or `createStore` boundary cuts interoperability with every parent atom, not only `queryClientAtom`; injecting the current QueryClient repairs cache access but does not restore other global state. Use a fresh store only when full-store isolation is intentional, then inject every required external dependency explicitly.
- Use parent-store interoperation as the boundary test: if descendants must observe application auth, workspace, permissions, cache, or any other parent atom, a fresh `Provider` is not an instance-scope mechanism. Choose it only when the surface intentionally owns a complete store and must not observe later parent-atom updates.
- For an intentionally unscoped route or parent-input bridge, call `useHydrateAtoms` in the boundary component that returns `ScopeProvider`; hooks read the nearest provider available where that component itself renders. When the bridge requires per-instance isolation, initialize its primitives through the `ScopeProvider` tuples instead. If later authoritative owner changes must follow, render a dedicated hydration bridge beneath that provider so `useHydrateAtoms` resolves the scoped primitives.
- Use `atomWithLazy` or another non-null lazy primitive for required values injected by a scope provider; fail when the boundary forgot to provide them instead of inventing an empty ID.
- Maintain the scoped primitive list explicitly. Use a semantic `key` when switching identity should create a fresh session; do not use forced hydration for an edit snapshot that must remain stable while an outer query refreshes.
- Scope exists for per-instance isolation and natural reset, not as a general module boundary. A state file may be warranted even when its atoms remain unscoped.
- Keep independent dialog lifecycles separate. A scoped open-state atom is acceptable only when composed sibling surfaces would otherwise pass confusing lifecycle props through unrelated owners.
## Graph Review Checklist
Before implementation and again after the final slice, draw the actual graph in dependency order and verify:
- every route, URL, parent, and persisted input has one bridge;
- every query driven by graph input is represented in the graph and remains unscoped;
- components read field selectors or named facts rather than complete query results;
- write atoms and exported commands describe user or workflow intent;
- scoped atoms are limited to primitives and snapshots with a documented reset boundary;
- the scope still observes required parent/global atom updates, including the application's shared QueryClient;
- two simultaneously mounted route or surface instances with different identities or URL state do not overwrite each other's bridged primitives or scoped workflow state, and a semantic identity change resets the intended state;
- no equivalent value remains available through both atoms and descendant props;
- every exported atom has a real component, boundary, or state-module consumer.
## Persistence
- Use feature-owned storage modules built on `createLocalStorageState`; callers should not scatter direct storage access or raw keys.

View File

@@ -0,0 +1,62 @@
// A workflow_run event is privileged: accept only trusted pushes and the exact tested revision.
module.exports = async function knowledgeDeployGates({
github,
context,
core,
now = Date.now,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
}) {
const source = context.payload.workflow_run
const repository = `${context.repo.owner}/${context.repo.repo}`
const branch = 'deploy/konwledge'
if (
source?.event !== 'push' ||
source.conclusion !== 'success' ||
source.head_branch !== branch ||
source.head_repository?.full_name !== repository ||
!/^[0-9a-f]{40}$/.test(source.head_sha)
) {
throw new Error('Deployment requires a successful trusted branch push.')
}
const isCurrent = async () => {
const { data } = await github.rest.repos.getBranch({ ...context.repo, branch })
return data.commit.sha === source.head_sha
}
const skip = () => {
core.info('Skipping a superseded deployment revision.')
core.setOutput('current', 'false')
}
if (!(await isCurrent())) return skip()
const deadline = now() + 30 * 60 * 1000
while (now() < deadline) {
const { data } = await github.rest.actions.listWorkflowRuns({
...context.repo,
workflow_id: 'knowledge-fs-ci.yml',
branch,
event: 'push',
head_sha: source.head_sha,
per_page: 100,
})
const run = data.workflow_runs
.filter(
(candidate) =>
candidate.head_sha === source.head_sha &&
candidate.head_branch === branch &&
candidate.event === 'push' &&
candidate.head_repository?.full_name === repository,
)
.sort((a, b) => b.run_number - a.run_number || b.run_attempt - a.run_attempt)[0]
if (run?.status === 'completed') {
if (run.conclusion !== 'success')
throw new Error(`KnowledgeFS CI did not succeed for ${source.head_sha}: ${run.conclusion}.`)
if (!(await isCurrent())) return skip()
core.info(`KnowledgeFS CI and image build succeeded for ${source.head_sha}.`)
core.setOutput('current', 'true')
return
}
if (!(await isCurrent())) return skip()
core.info(`Waiting for KnowledgeFS CI at ${source.head_sha}.`)
await sleep(15000)
}
throw new Error(`Timed out waiting for KnowledgeFS CI for ${source.head_sha}.`)
}

View File

@@ -0,0 +1,128 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const gate = require('./knowledge-deploy-gates.cjs')
const sha = 'a'.repeat(40)
const otherSha = 'b'.repeat(40)
function fixture() {
const source = {
event: 'push',
conclusion: 'success',
head_branch: 'deploy/konwledge',
head_repository: { full_name: 'langgenius/dify' },
head_sha: sha,
}
let time = 0
let branchSha = sha
let runs = [{ ...source, status: 'completed', run_number: 2, run_attempt: 1 }]
const outputs = {}
const requests = []
return {
source,
outputs,
requests,
setRuns: (value) => {
runs = value
},
setBranch: (value) => {
branchSha = value
},
args: {
context: { repo: { owner: 'langgenius', repo: 'dify' }, payload: { workflow_run: source } },
github: {
rest: {
repos: { getBranch: async () => ({ data: { commit: { sha: branchSha } } }) },
actions: {
listWorkflowRuns: async (request) => {
requests.push(request)
return { data: { workflow_runs: runs } }
},
},
},
},
core: {
info: () => {},
setOutput: (key, value) => {
outputs[key] = value
},
},
now: () => time,
sleep: async (ms) => {
time += ms
},
},
}
}
test('only a successful CI run from the same branch, repo, event, and SHA admits deployment', async () => {
const f = fixture()
await gate(f.args)
assert.equal(f.outputs.current, 'true')
assert.equal(f.requests[0].head_sha, sha)
assert.equal(f.requests[0].workflow_id, 'knowledge-fs-ci.yml')
})
for (const [name, mutation] of [
['pull request', { event: 'pull_request' }],
['failed build', { conclusion: 'failure' }],
['foreign branch', { head_branch: 'main' }],
['foreign repo', { head_repository: { full_name: 'attacker/fork' } }],
['invalid revision', { head_sha: 'main' }],
])
test(`rejects ${name} workflow events before any API query`, async () => {
const f = fixture()
Object.assign(f.source, mutation)
await assert.rejects(gate(f.args), /trusted branch push/)
assert.deepEqual(f.requests, [])
})
for (const conclusion of ['failure', 'cancelled', 'skipped', 'timed_out', 'neutral'])
test(`CI ${conclusion} fails closed`, async () => {
const f = fixture()
f.setRuns([{ ...f.source, status: 'completed', conclusion }])
await assert.rejects(gate(f.args), /did not succeed/)
assert.notEqual(f.outputs.current, 'true')
})
for (const mutation of [
{ head_sha: otherSha },
{ head_branch: 'main' },
{ event: 'pull_request' },
{ head_repository: { full_name: 'attacker/fork' } },
])
test(`ignores unrelated successful CI run ${JSON.stringify(mutation)}`, async () => {
const f = fixture()
f.setRuns([{ ...f.source, ...mutation, status: 'completed' }])
await assert.rejects(gate(f.args), /Timed out/)
assert.notEqual(f.outputs.current, 'true')
})
test('latest failed attempt cannot be hidden by an earlier successful run', async () => {
const f = fixture()
f.setRuns([
{ ...f.source, status: 'completed', run_number: 1, run_attempt: 1 },
{ ...f.source, status: 'completed', run_number: 2, run_attempt: 1, conclusion: 'failure' },
])
await assert.rejects(gate(f.args), /did not succeed/)
})
test('stale revision skips without waiting or deploying', async () => {
const f = fixture()
f.setBranch(otherSha)
await gate(f.args)
assert.equal(f.outputs.current, 'false')
assert.deepEqual(f.requests, [])
})
test('branch advancing during CI wait prevents deployment', async () => {
const f = fixture()
f.setRuns([{ ...f.source, status: 'in_progress' }])
f.args.sleep = async () => {
f.setBranch(otherSha)
f.setRuns([{ ...f.source, status: 'completed' }])
}
await gate(f.args)
assert.equal(f.outputs.current, 'false')
})
test('running CI is awaited before reporting success', async () => {
const f = fixture()
f.setRuns([])
f.args.sleep = async () => {
f.setRuns([{ ...f.source, status: 'completed' }])
}
await gate(f.args)
assert.equal(f.outputs.current, 'true')
assert.equal(f.requests.length, 2)
})

View File

@@ -2,10 +2,11 @@ name: CLI Edge Publish
on:
push:
branches: [main]
branches: [main, deploy/konwledge]
paths:
- 'cli/**'
- 'packages/contracts/generated/api/openapi/**'
- '.github/workflows/cli-edge.yml'
workflow_dispatch:
concurrency:

View File

@@ -7,67 +7,72 @@ permissions:
on:
workflow_run:
workflows: ['Build and Push API & Web']
branches:
- 'deploy/konwledge'
types:
- completed
branches: ['deploy/konwledge']
types: [completed]
# A second workflow used to deploy the same server after KnowledgeFS CI. This is the single
# Dify deployment owner; the independent KnowledgeFS release retains its own deployment lock.
concurrency:
group: deploy-knowledge-dify-test
cancel-in-progress: false
jobs:
deploy:
runs-on: depot-ubuntu-24.04
timeout-minutes: 340
if: |
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/konwledge'
github.event.workflow_run.head_branch == 'deploy/konwledge' &&
github.event.workflow_run.head_repository.full_name == github.repository
steps:
- name: Wait for KnowledgeFS CI
- name: Checkout tested deployment gates
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
sparse-checkout: .github/scripts
- name: Require same-revision KnowledgeFS CI and current branch head
id: gates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
timeout-minutes: 35
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const workflowId = "knowledge-fs-ci.yml";
const headBranch = context.payload.workflow_run.head_branch;
const headSha = context.payload.workflow_run.head_sha;
const deadline = Date.now() + 30 * 60 * 1000;
const pollIntervalMs = 15 * 1000;
await require('./.github/scripts/knowledge-deploy-gates.cjs')({ github, context, core });
while (Date.now() < deadline) {
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
branch: headBranch,
event: "push",
head_sha: headSha,
per_page: 10,
});
const run = data.workflow_runs[0];
if (!run) {
core.info(`Waiting for ${workflowId} to start for ${headSha}.`);
} else if (run.status !== "completed") {
core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`);
} else if (run.conclusion !== "success") {
throw new Error(
`${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`,
);
} else {
core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`);
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`);
- name: Deploy to server
- name: Deploy tested Dify revision while preserving KnowledgeFS
if: steps.gates.outputs.current == 'true'
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
EXPECTED_DIFY_REVISION: ${{ github.event.workflow_run.head_sha }}
DEPLOY_DIR: /home/ubuntu/langgenius
with:
host: ${{ secrets.SSH_NEW_RAG_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
# Allow the shared deployment lock and Celery warm shutdown to finish.
command_timeout: 300m
envs: EXPECTED_DIFY_REVISION,DEPLOY_DIR
script: |
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
set -euo pipefail
[[ "$EXPECTED_DIFY_REVISION" =~ ^[0-9a-f]{40}$ ]] || {
echo 'Expected a full tested Dify revision.' >&2; exit 1;
}
# Verify the installed bridge forwards revision arguments before invoking it. Never
# overwrite an operator's custom entrypoint or fall back to an unverified script.
python3 - "$DEPLOY_DIR" <<'PY'
import shlex
import sys
from pathlib import Path
project = Path(sys.argv[1]).resolve()
controller = project / 'knowledge-fs-independent-deploy.py'
expected = ('#!/usr/bin/env bash\nset -euo pipefail\nexec python3 ' +
shlex.quote(str(controller)) + ' --dify --project-dir ' +
shlex.quote(str(project)) + ' "$@"\n')
if not controller.is_file() or (project / 'deploy.sh').read_text() != expected:
raise SystemExit('Expected the installed KnowledgeFS-preserving Dify release bridge.')
PY
"$DEPLOY_DIR/deploy.sh" --expected-revision "$EXPECTED_DIFY_REVISION"

532
.github/workflows/knowledge-fs-ci.yml vendored Normal file
View File

@@ -0,0 +1,532 @@
name: KnowledgeFS CI
on:
pull_request:
branches: ['main']
merge_group:
branches: ['main']
types: [checks_requested]
push:
branches: ['main', 'deploy/konwledge']
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: knowledge-fs-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CI: true
jobs:
check-changes:
name: Check KnowledgeFS changes
runs-on: depot-ubuntu-24.04-4
outputs:
knowledge-fs: ${{ steps.changes.outputs.knowledge-fs }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Detect KnowledgeFS changes
id: changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
filters: |
knowledge-fs:
- 'api/knowledge-fs-contract/**'
- 'api/tests/knowledge_fs_vector_integration/**'
- 'api/services/tidb_binding_service.py'
- 'api/tests/unit_tests/services/test_tidb_binding_service.py'
- 'api/providers/vdb/vdb-tidb-on-qdrant/**'
- 'api/Dockerfile'
- 'api/dev/generate_knowledge_fs_contract.py'
- 'api/dev/knowledge_fs_product_contract.py'
- 'api/knowledge-fs-contract.lock.json'
- 'api/knowledge-fs-product-operation-gaps.json'
- 'api/knowledge-fs-product-operations.json'
- 'api/**/knowledge_fs/**'
- 'api/**/*knowledge_fs*'
- 'api/**/*knowledge-fs*'
- 'api/.env.example'
- 'api/app_factory.py'
- 'api/commands/__init__.py'
- 'api/controllers/console/__init__.py'
- 'api/controllers/console/workspace/rbac.py'
- 'api/controllers/inner_api/__init__.py'
- 'api/controllers/service_api/__init__.py'
- 'api/core/agent/base_agent_runner.py'
- 'api/core/app/apps/agent_app/runtime_request_builder.py'
- 'api/core/rbac/entities.py'
- 'api/core/tools/__base/tool_runtime.py'
- 'api/core/tools/builtin_tool/_position.yaml'
- 'api/core/workflow/node_runtime.py'
- 'api/core/workflow/nodes/agent_v2/runtime_request_builder.py'
- 'api/extensions/ext_celery.py'
- 'api/extensions/ext_commands.py'
- 'api/models/__init__.py'
- 'api/models/enums.py'
- 'api/models/model.py'
- 'api/services/account_service.py'
- 'api/services/agent_tool_inner_service.py'
- 'api/services/enterprise/rbac_service.py'
- 'api/services/entities/agent_tool_inner.py'
- 'api/services/vector_space_admission_service.py'
- 'api/services/knowledge_fs/**'
- 'api/services/knowledge_fs_capability.py'
- 'api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py'
- 'api/tests/unit_tests/controllers/console/workspace/test_rbac.py'
- 'api/tests/unit_tests/core/agent/test_base_agent_runner.py'
- 'api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py'
- 'api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py'
- 'api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py'
- 'api/tests/unit_tests/core/workflow/test_node_runtime.py'
- 'api/tests/unit_tests/services/enterprise/test_rbac_service.py'
- 'api/tests/unit_tests/services/test_account_service.py'
- 'api/tests/unit_tests/services/test_agent_tool_inner_service.py'
- 'api/tests/unit_tests/services/test_vector_space_admission_service.py'
- 'api/tests/unit_tests/services/test_knowledge_fs_capability.py'
- 'api/tests/unit_tests/services/test_knowledge_fs_product_operations.py'
- 'api/pyproject.toml'
- 'api/uv.lock'
- 'dify-agent/src/dify_agent/layers/dify_core_tools/client.py'
- 'dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_client.py'
- 'packages/contracts/generated/api/console/**'
- 'packages/contracts/generated/api/service/**'
- 'docker/.env.example'
- 'docker/README.md'
- 'docker/dify-env-sync.py'
- 'docker/dify-env-sync.sh'
- 'docker/docker-compose-template.yaml'
- 'docker/docker-compose.yaml'
- 'docker/envs/core-services/api.env.example'
- 'docker/envs/core-services/knowledge-fs.env.example'
- 'docker/envs/core-services/knowledge-fs-unstructured-service.defaults'
- 'docker/envs/core-services/knowledge-fs-unstructured.env.example'
- 'docker/knowledge-fs-unstructured-sandbox.compose.yaml'
- 'docker/generate_docker_compose'
- 'docs/design/knowledge-fs*'
- '.github/dependabot.yml'
- '.github/workflows/deploy-knowledge.yml'
- '.github/workflows/deploy-dev.yml'
- '.github/scripts/knowledge-deploy-*'
- '.github/workflows/knowledge-fs-ci.yml'
quality:
name: Run KnowledgeFS quality and contract gates
needs: check-changes
if: needs.check-changes.outputs.knowledge-fs == 'true' || github.event_name == 'workflow_dispatch'
runs-on: depot-ubuntu-24.04-4
defaults:
run:
shell: bash
working-directory: .
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node for deployment gate regression tests
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Test deployment revision gates
run: node --test .github/scripts/knowledge-deploy-gates.test.cjs
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: '3.12'
cache-dependency-glob: |
api/uv.lock
dify-agent/uv.lock
- name: Verify Dify dependency lock
working-directory: .
run: uv lock --project api --check
- name: Install Dify contract dependencies
working-directory: .
run: uv sync --project api --locked --dev --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant
# The standalone KnowledgeFS repository owns TypeScript, parser, image and deployment gates.
# This repository verifies its immutable published contract and all Dify integration code.
- name: Verify pinned KnowledgeFS contract
run: uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant python api/dev/generate_knowledge_fs_contract.py --check
- name: Collect Dify KnowledgeFS gate targets
working-directory: .
run: |
set -euo pipefail
target_dir="${RUNNER_TEMP:?}/knowledge-fs-ci-targets"
mkdir -p "$target_dir"
production_targets=()
add_production_target() {
local path="$1"
if [[ ! -f "$path" ]]; then
echo "required Dify KnowledgeFS production target is missing: $path" >&2
exit 1
fi
production_targets+=("$path")
}
while IFS= read -r -d '' path; do
if [[ "$path" == *knowledge_fs* ]]; then
add_production_target "$path"
fi
done < <(
find api \
\( -path 'api/.venv' -o -path 'api/tests' -o -path 'api/storage' \) -prune \
-o -type f -name '*.py' -print0
)
production_touchpoints=(
api/app_factory.py
api/commands/__init__.py
api/controllers/console/__init__.py
api/controllers/console/datasets/datasets.py
api/controllers/console/workspace/rbac.py
api/controllers/inner_api/__init__.py
api/controllers/service_api/__init__.py
api/core/agent/base_agent_runner.py
api/core/app/apps/agent_app/runtime_request_builder.py
api/core/rbac/entities.py
api/core/tools/__base/tool_runtime.py
api/core/workflow/node_runtime.py
api/core/workflow/nodes/agent_v2/runtime_request_builder.py
api/extensions/ext_celery.py
api/extensions/ext_commands.py
api/models/__init__.py
api/models/enums.py
api/models/model.py
api/services/account_service.py
api/services/agent_tool_inner_service.py
api/services/enterprise/rbac_service.py
api/services/entities/agent_tool_inner.py
api/services/vector_space_admission_service.py
api/services/tidb_binding_service.py
api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py
api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py
)
for path in "${production_touchpoints[@]}"; do
add_production_target "$path"
done
printf '%s\0' "${production_touchpoints[@]}" > "$target_dir/glue-files"
if ((${#production_targets[@]} == 0)); then
echo "Dify KnowledgeFS production target set is empty" >&2
exit 1
fi
printf '%s\0' "${production_targets[@]}" > "$target_dir/production-files"
test_targets=()
add_test_target() {
local path="$1"
if [[ ! -f "$path" ]]; then
echo "required Dify KnowledgeFS unit test is missing: $path" >&2
exit 1
fi
test_targets+=("$path")
}
while IFS= read -r -d '' path; do
if [[ "$path" == *knowledge_fs* ]]; then
add_test_target "$path"
fi
done < <(find api/tests/unit_tests -type f -name '*.py' -print0)
test_touchpoints=(
api/tests/unit_tests/controllers/console/datasets/test_datasets.py
api/tests/unit_tests/controllers/console/test_wraps.py
api/tests/unit_tests/controllers/console/workspace/test_rbac.py
api/tests/unit_tests/core/agent/test_base_agent_runner.py
api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py
api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py
api/tests/unit_tests/core/workflow/test_node_runtime.py
api/tests/unit_tests/services/enterprise/test_rbac_service.py
api/tests/unit_tests/services/test_account_service.py
api/tests/unit_tests/services/test_agent_tool_inner_service.py
api/tests/unit_tests/services/test_vector_space_admission_service.py
api/tests/unit_tests/services/test_tidb_binding_service.py
)
for path in "${test_touchpoints[@]}"; do
add_test_target "$path"
done
required_test_scopes=(
/commands/
/configs/
/controllers/
/core/agent/
/core/app/apps/agent_app/
/core/tools/builtin_tool/providers/knowledge_fs/
/core/workflow/
/dev/
/extensions/
/migrations/
/models/
/repositories/
/services/
/tasks/
)
for required_scope in "${required_test_scopes[@]}"; do
scope_found=false
for path in "${test_targets[@]}"; do
if [[ "$path" == *"$required_scope"* ]]; then
scope_found=true
break
fi
done
if [[ "$scope_found" != true ]]; then
echo "required Dify KnowledgeFS test scope is empty: $required_scope" >&2
exit 1
fi
done
if ((${#test_targets[@]} == 0)); then
echo "Dify KnowledgeFS unit test target set is empty" >&2
exit 1
fi
printf '%s\0' "${test_targets[@]}" > "$target_dir/unit-test-files"
- name: Lint Dify KnowledgeFS integration
working-directory: .
run: |
set -euo pipefail
targets=()
while IFS= read -r -d '' path; do
targets+=("$path")
done < "${RUNNER_TEMP:?}/knowledge-fs-ci-targets/production-files"
if ((${#targets[@]} == 0)); then
echo "Dify KnowledgeFS production target manifest is empty" >&2
exit 1
fi
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev ruff format --check "${targets[@]}"
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev ruff check "${targets[@]}"
- name: Type-check Dify KnowledgeFS integration
working-directory: .
run: |
set -euo pipefail
targets=()
while IFS= read -r -d '' path; do
targets+=("$path")
done < "${RUNNER_TEMP:?}/knowledge-fs-ci-targets/production-files"
if ((${#targets[@]} == 0)); then
echo "Dify KnowledgeFS production target manifest is empty" >&2
exit 1
fi
UV_NO_SYNC=1 PYREFLY_OUTPUT_FORMAT=github ./dev/pyrefly-check-local "${targets[@]}"
mypy_targets=()
for path in "${targets[@]}"; do
if [[ "$path" != api/migrations/* ]]; then
mypy_targets+=("${path#api/}")
fi
done
if ((${#mypy_targets[@]} == 0)); then
echo "Dify KnowledgeFS Mypy target set is empty" >&2
exit 1
fi
uv run --directory api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev mypy \
--explicit-package-bases \
--exclude-gitignore \
--exclude '(^|/)conftest\.py$' \
--exclude 'tests/' \
--exclude 'migrations/' \
--check-untyped-defs \
--disable-error-code=import-untyped \
"${mypy_targets[@]}"
- name: Test Dify KnowledgeFS unit surface
working-directory: .
env:
COVERAGE_FILE: ${{ runner.temp }}/dify-knowledge-fs.coverage
run: |
set -euo pipefail
targets=()
while IFS= read -r -d '' path; do
targets+=("$path")
done < "${RUNNER_TEMP:?}/knowledge-fs-ci-targets/unit-test-files"
if ((${#targets[@]} == 0)); then
echo "Dify KnowledgeFS unit test target manifest is empty" >&2
exit 1
fi
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev coverage run --branch --source=api -m pytest "${targets[@]}" --no-cov -q
- name: Enforce Dify KnowledgeFS focused coverage
working-directory: .
env:
COVERAGE_FILE: ${{ runner.temp }}/dify-knowledge-fs.coverage
KNOWLEDGE_FS_COVERAGE_BASE: >-
${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || '' }}
run: |
set -euo pipefail
report="${RUNNER_TEMP:?}/dify-knowledge-fs-coverage.json"
coverage_base="$KNOWLEDGE_FS_COVERAGE_BASE"
if [[ "$GITHUB_EVENT_NAME" == 'push' && "$GITHUB_REF" == 'refs/heads/deploy/konwledge' ]]; then
latest_merge="$(git rev-list --first-parent --merges --max-count=1 "${coverage_base}..HEAD")"
if [[ -n "$latest_merge" ]]; then
# Mainline syncs are already gated on main; cover deployment-branch
# changes made after the newest sync instead of the merged diff.
coverage_base="$latest_merge"
fi
fi
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev coverage json --show-contexts -o "$report"
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev python api/dev/check_knowledge_fs_coverage.py \
--coverage-json "$report" \
--glue-manifest "${RUNNER_TEMP:?}/knowledge-fs-ci-targets/glue-files" \
--base "$coverage_base" \
--minimum 90 \
--glue-minimum 90
- name: Verify Dify KnowledgeFS contract
working-directory: .
run: uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant python api/dev/generate_knowledge_fs_contract.py --check
- name: Verify Dify Agent dependency lock
working-directory: .
run: uv lock --project dify-agent --check
- name: Install Dify Agent gate dependencies
working-directory: .
run: uv sync --project dify-agent --locked --dev
- name: Lint Dify Agent KnowledgeFS integration
working-directory: ./dify-agent
run: |
uv run --project . --dev ruff format --check \
src/dify_agent/layers/dify_core_tools/client.py \
tests/local/dify_agent/layers/dify_core_tools/test_client.py
uv run --project . --dev ruff check \
src/dify_agent/layers/dify_core_tools/client.py \
tests/local/dify_agent/layers/dify_core_tools/test_client.py
- name: Type-check Dify Agent KnowledgeFS integration
working-directory: ./dify-agent
run: >-
uv run --project . --dev pyrefly check --summary=none
src/dify_agent/layers/dify_core_tools/client.py
tests/local/dify_agent/layers/dify_core_tools/test_client.py
- name: Test Dify Agent KnowledgeFS integration
working-directory: ./dify-agent
run: >-
uv run --project . --dev python -m pytest
tests/local/dify_agent/layers/dify_core_tools/test_client.py
-q
skip:
name: Skip KnowledgeFS quality and contract gates
needs: check-changes
if: needs.check-changes.outputs.knowledge-fs != 'true' && github.event_name != 'workflow_dispatch'
runs-on: depot-ubuntu-24.04-4
steps:
- name: Report skipped KnowledgeFS checks
run: echo "No KnowledgeFS-related changes detected; skipping KnowledgeFS checks."
final:
name: KnowledgeFS CI
if: ${{ always() }}
needs:
- check-changes
- quality
- vector-backends
- skip
runs-on: depot-ubuntu-24.04-4
steps:
- name: Finalize KnowledgeFS CI status
env:
EVENT_NAME: ${{ github.event_name }}
KNOWLEDGE_FS_CHANGED: ${{ needs.check-changes.outputs.knowledge-fs }}
QUALITY_RESULT: ${{ needs.quality.result }}
VECTOR_RESULT: ${{ needs.vector-backends.result }}
SKIP_RESULT: ${{ needs.skip.result }}
run: |
if [[ "$EVENT_NAME" == 'workflow_dispatch' || "$KNOWLEDGE_FS_CHANGED" == 'true' ]]; then
if [[ "$QUALITY_RESULT" == 'success' && "$VECTOR_RESULT" == 'success' ]]; then
echo "Dify KnowledgeFS contract and integration checks ran successfully."
exit 0
fi
echo "Dify KnowledgeFS integration checks failed: quality=$QUALITY_RESULT vectors=$VECTOR_RESULT" >&2
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "KnowledgeFS checks were skipped because no related files changed."
exit 0
fi
echo "KnowledgeFS change detection or skip reporting failed with result: $SKIP_RESULT" >&2
exit 1
vector-backends:
name: Test native KnowledgeFS vector backends
needs: check-changes
if: needs.check-changes.outputs.knowledge-fs == 'true' || github.event_name == 'workflow_dispatch'
runs-on: depot-ubuntu-24.04-4
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.14.3
env:
discovery.type: single-node
xpack.security.enabled: 'false'
ES_JAVA_OPTS: '-Xms512m -Xmx512m'
ports:
- 9200:9200
options: >-
--health-cmd "curl -fsS http://localhost:9200/_cluster/health"
--health-interval 10s --health-timeout 5s --health-retries 30
weaviate:
image: cr.weaviate.io/semitechnologies/weaviate:1.39.2
env:
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
PERSISTENCE_DATA_PATH: /var/lib/weaviate
DEFAULT_VECTORIZER_MODULE: none
CLUSTER_HOSTNAME: node1
ports:
- 8080:8080
- 50051:50051
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install native vector SDKs
run: uv sync --project api --locked --dev --group vdb-elasticsearch --group vdb-weaviate
- name: Wait for Weaviate readiness
run: |
for attempt in $(seq 1 60); do
if curl -fsS http://localhost:8080/v1/.well-known/ready; then exit 0; fi
sleep 2
done
exit 1
- name: Exercise real write, retry, filtered search, isolation and deletion
env:
KNOWLEDGE_FS_VECTOR_INTEGRATION: '1'
DEPLOYMENT_EDITION: COMMUNITY
ELASTICSEARCH_HOST: localhost
ELASTICSEARCH_PORT: '9200'
WEAVIATE_ENDPOINT: http://localhost:8080
WEAVIATE_GRPC_ENDPOINT: grpc://localhost:50051
run: >-
uv run --project api --no-sync pytest
api/tests/knowledge_fs_vector_integration --no-cov -q

View File

@@ -724,11 +724,27 @@ AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240
# KnowledgeFS (Dataset 2.0)
KNOWLEDGE_FS_ENABLED=false
# Explicitly bypass the Community edition gate for development deployments.
KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED=false
# Production deployments require HTTPS; plain HTTP is limited to non-production or loopback.
KNOWLEDGE_FS_BASE_URL=
# Shared with KnowledgeFS; use at least 32 random characters.
KNOWLEDGE_FS_JWT_SECRET=
KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS=300
KNOWLEDGE_FS_DIRECT_ORIGIN=
KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED=false
KNOWLEDGE_FS_INTEGRATED_PROVISION_READY=false
KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY=false
KNOWLEDGE_FS_LIFECYCLE_POLL_INTERVAL_SECONDS=15
KNOWLEDGE_FS_LIFECYCLE_LEASE_SECONDS=60
KNOWLEDGE_FS_LIFECYCLE_BATCH_SIZE=25
KNOWLEDGE_FS_CAPABILITY_V2_ENABLED=false
KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID=
KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM=
KNOWLEDGE_FS_CAPABILITY_V2_PREVIOUS_PUBLIC_JWKS=
KNOWLEDGE_FS_CAPABILITY_V2_ISSUER=dify-control-plane
KNOWLEDGE_FS_CAPABILITY_V2_AUDIENCE=knowledge-fs
KNOWLEDGE_FS_CAPABILITY_V2_MAX_TTL_SECONDS=60
KNOWLEDGE_FS_TIMEOUT_SECONDS=10
KNOWLEDGE_FS_JWKS_CACHE_MAX_AGE_SECONDS=300
KNOWLEDGE_FS_PRODUCT_MAX_RESPONSE_BYTES=4194304
# Marketplace configuration
MARKETPLACE_ENABLED=true

View File

@@ -1,3 +1,6 @@
# KnowledgeFS is released independently. Its release controller atomically mounts the tested
# engine bundles and native dependencies at /app/knowledge-fs in the dedicated Celery workers.
# Ordinary Dify API images do not rebuild or download a second copy of the private engine.
# base image
FROM python:3.12-slim-bookworm AS base
@@ -89,6 +92,8 @@ RUN NODE_MAJOR="${NODE_PACKAGE_VERSION%%.*}" \
media-types \
# install libmagic to support the use of python-magic guess MIMETYPE
libmagic1 \
# Bounded PDF metadata/rasterization invoked by the local KnowledgeFS worker engine.
poppler-utils \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -7,10 +7,27 @@
!dify-agent/README.md
!dify-agent/src/
!dify-agent/src/**
!knowledge-fs/
!knowledge-fs/package.json
!knowledge-fs/pnpm-lock.yaml
!knowledge-fs/pnpm-workspace.yaml
!knowledge-fs/turbo.json
!knowledge-fs/tsconfig.base.json
!knowledge-fs/apps/
!knowledge-fs/apps/api/
!knowledge-fs/apps/api/**
!knowledge-fs/packages/
!knowledge-fs/packages/**
**/node_modules/
**/dist/
**/.turbo/
# Environment configuration and example
.env
*.env.*
**/.env
**/*.env
**/*.env.*
# Python related files
**/__pycache__

View File

@@ -50,7 +50,9 @@ The scripts resolve paths relative to their location, so you can run them from a
./dev/start-worker
```
1. Optional: start Celery Beat (scheduled tasks).
1. Start Celery Beat when scheduled tasks are needed. This is required when
`KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED=true` so provisioning and deletion
outbox commands are dispatched to the lifecycle worker.
```bash
./dev/start-beat

View File

@@ -190,6 +190,7 @@ def initialize_extensions(app: DifyApp):
ext_hosting_provider,
ext_import_modules,
ext_key_provider,
ext_knowledge_fs_observability,
ext_logging,
ext_login,
ext_logstore,
@@ -241,6 +242,7 @@ def initialize_extensions(app: DifyApp):
ext_enterprise_telemetry,
ext_request_logging,
ext_session_factory,
ext_knowledge_fs_observability,
ext_application_services,
ext_oauth_bearer,
]

View File

@@ -1,11 +1,15 @@
import psycogreen.gevent as pscycogreen_gevent
from grpc.experimental import gevent as grpc_gevent
from gevent.monkey import is_module_patched
# grpc gevent
grpc_gevent.init_gevent()
print("gRPC patched with gevent.", flush=True) # noqa: T201
pscycogreen_gevent.patch_psycopg()
print("psycopg2 patched with gevent.", flush=True) # noqa: T201
# Celery patches the stdlib before importing this entrypoint for -P gevent. Prefork workers
# (including the KnowledgeFS local engine) must retain native subprocess, DB and gRPC behavior.
if is_module_patched("socket"):
import psycogreen.gevent as pscycogreen_gevent
from grpc.experimental import gevent as grpc_gevent
grpc_gevent.init_gevent()
print("gRPC patched with gevent.", flush=True) # noqa: T201
pscycogreen_gevent.patch_psycopg()
print("psycopg2 patched with gevent.", flush=True) # noqa: T201
from app import app, celery

View File

@@ -33,6 +33,7 @@ from dify_agent.layers.execution_context import (
DifyExecutionContextLayerConfig,
)
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
from dify_agent.layers.knowledge_fs import DIFY_KNOWLEDGE_FS_LAYER_TYPE_ID, DifyKnowledgeFsLayerConfig
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
@@ -79,6 +80,17 @@ def _config_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _knowledge_fs_spec(run_input: AgentBackendWorkflowNodeRunInput | AgentBackendAgentAppRunInput) -> RunLayerSpec:
if not run_input.include_shell or run_input.knowledge is not None:
raise ValueError("KnowledgeFS requires shell and cannot be combined with legacy knowledge retrieval")
return RunLayerSpec(
name="knowledge_fs",
type=DIFY_KNOWLEDGE_FS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, "shell": DIFY_SHELL_LAYER_ID},
config=run_input.knowledge_fs,
)
def _markdown_backtick_fence(text: str) -> str:
"""Choose a fence that will not terminate inside the prompt body."""
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
@@ -212,6 +224,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
tools: DifyPluginToolsLayerConfig | None = None
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
knowledge_fs: DifyKnowledgeFsLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement; a deferred call ends the run and
@@ -259,6 +272,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
tools: DifyPluginToolsLayerConfig | None = None
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
knowledge_fs: DifyKnowledgeFsLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement (ENG-635).
@@ -412,6 +426,8 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge_fs is not None:
layers.append(_knowledge_fs_spec(run_input))
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(
@@ -590,6 +606,8 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge_fs is not None:
layers.append(_knowledge_fs_spec(run_input))
if run_input.knowledge is not None and run_input.knowledge.sets:
layers.append(
RunLayerSpec(

View File

@@ -10,6 +10,7 @@ from .data_migration import (
import_migration_data,
migration_data_wizard,
)
from .knowledge_fs import knowledge_fs_control_space
from .plugin import (
backfill_plugin_auto_upgrade,
extract_plugins,
@@ -81,6 +82,7 @@ __all__ = [
"import_migration_data",
"install_plugins",
"install_rag_pipeline_plugins",
"knowledge_fs_control_space",
"legacy_model_types",
"migrate_agent_permissions_to_rbac",
"migrate_annotation_vector_database",

View File

@@ -0,0 +1,621 @@
"""Operator commands for the independent KnowledgeFS control-plane."""
from __future__ import annotations
import json
from collections.abc import Callable
from datetime import datetime
from functools import partial
from pathlib import Path
import click
from pydantic import BaseModel, ValidationError
from core.db.session_factory import session_factory
from services.knowledge_fs.cleanup import (
CleanupApprovalInput,
CleanupCompletionEvidenceInput,
CleanupReadinessEvidenceInput,
CleanupStartInput,
KnowledgeFSCleanupError,
KnowledgeFSCleanupService,
)
from services.knowledge_fs.control_space_commands import KnowledgeFSControlSpaceCommandService
from services.knowledge_fs.control_space_lifecycle import KnowledgeFSControlSpaceLifecycleError
from services.knowledge_fs.control_space_management import (
KnowledgeFSControlSpaceManagementService,
KnowledgeFSControlSpaceRegistration,
)
from services.knowledge_fs.cutover import (
CutoverSmokeResultsInput,
FinalDeltaInput,
KnowledgeFSCutoverError,
KnowledgeFSWorkspaceCutoverService,
LegacyDependencyInput,
QuarantineResolutionInput,
ShadowAuthorizationObservationInput,
ShadowCompletionInput,
WorkspaceInventoryInput,
)
from services.knowledge_fs.greenfield_initializer import KnowledgeFSWorkspaceGreenfieldInitializer
from services.knowledge_fs.orphan_reconciler import KnowledgeFSOrphanReconciler
from services.knowledge_fs.remote_registry import get_knowledge_fs_lifecycle_remote
@click.group("knowledge-fs-control-space")
def knowledge_fs_control_space() -> None:
"""Inspect and repair Dify-owned KnowledgeFS control-space state."""
@knowledge_fs_control_space.command("dry-run")
@click.option("--tenant-id", default=None)
def dry_run(tenant_id: str | None) -> None:
report = _management_service().dry_run(tenant_id=tenant_id)
click.echo(json.dumps(report._asdict(), sort_keys=True))
@knowledge_fs_control_space.command("inventory")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Create ledgers; omitted means read-only inventory.")
def inventory(input_path: Path, apply: bool) -> None:
"""Validate strict Workspace inventory JSONL and optionally create ledgers."""
service = _cutover_service()
for payload in _read_jsonl(input_path, WorkspaceInventoryInput):
report = _operator_call(partial(service.inventory, payload, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("register")
@click.option("--tenant-id", required=True)
@click.option("--owner-account-id", required=True)
@click.option("--provisioning-key", required=True)
@click.option("--knowledge-space-id", required=True)
@click.option("--knowledge-space-revision", type=click.IntRange(min=0), required=True)
def register(
tenant_id: str,
owner_account_id: str,
provisioning_key: str,
knowledge_space_id: str,
knowledge_space_revision: int,
) -> None:
control_space, replayed = _management_service().register(
KnowledgeFSControlSpaceRegistration(
tenant_id,
owner_account_id,
provisioning_key,
knowledge_space_id,
knowledge_space_revision,
)
)
click.echo(json.dumps({"control_space_id": control_space.id, "replayed": replayed}, sort_keys=True))
@knowledge_fs_control_space.command("backfill")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist registrations; omitted means dry-run.")
def backfill(input_path: Path, apply: bool) -> None:
"""Backfill strict Workspace inventory JSONL; dry-run unless --apply is explicit."""
service = _cutover_service()
for payload in _read_jsonl(input_path, WorkspaceInventoryInput):
report = _operator_call(partial(service.backfill, payload, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("quarantine-resolve")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist resolutions; omitted means dry-run.")
def quarantine_resolve(input_path: Path, apply: bool) -> None:
"""Resolve strict tenant-scoped quarantine JSONL with immutable operator evidence."""
service = _cutover_service()
for payload in _read_jsonl(input_path, QuarantineResolutionInput):
report = _operator_call(partial(service.resolve_quarantine, payload, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("shadow-start")
@click.option("--tenant-id", required=True)
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
@click.option("--at", "started_at", default=None, help="Optional explicit timezone-aware shadow start.")
def shadow_start(tenant_id: str, expected_cas_version: int, started_at: str | None) -> None:
service = _cutover_service()
_operator_call(
lambda: service.begin_shadow(
tenant_id=tenant_id,
expected_cas_version=expected_cas_version,
started_at=_parse_timestamp(started_at) if started_at is not None else None,
)
)
_echo_json(service.status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("shadow-report")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist observations; omitted means dry-run.")
def shadow_report(input_path: Path, apply: bool) -> None:
observations = _read_jsonl(input_path, ShadowAuthorizationObservationInput)
report = _operator_call(lambda: _cutover_service().record_shadow_report(observations, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("shadow-complete")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist completion; omitted means dry-run.")
def shadow_complete(input_path: Path, apply: bool) -> None:
payload = _read_one_jsonl(input_path, ShadowCompletionInput)
report = _operator_call(lambda: _cutover_service().complete_shadow(payload, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("issue-approve")
@click.option("--tenant-id", required=True)
@click.option("--issue-key", required=True)
@click.option("--account-id", required=True)
@click.option("--at", "approved_at", required=True)
def issue_approve(tenant_id: str, issue_key: str, account_id: str, approved_at: str) -> None:
_operator_call(
lambda: _cutover_service().approve_issue_fail_closed(
tenant_id=tenant_id,
issue_key=issue_key,
account_id=account_id,
approved_at=_parse_timestamp(approved_at),
)
)
_echo_json(_cutover_service().status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("issue-resolve")
@click.option("--tenant-id", required=True)
@click.option("--issue-key", required=True)
@click.option("--account-id", required=True)
@click.option("--at", "resolved_at", required=True)
def issue_resolve(tenant_id: str, issue_key: str, account_id: str, resolved_at: str) -> None:
_operator_call(
lambda: _cutover_service().resolve_issue(
tenant_id=tenant_id,
issue_key=issue_key,
account_id=account_id,
resolved_at=_parse_timestamp(resolved_at),
)
)
_echo_json(_cutover_service().status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("shadow-approve")
@click.option("--tenant-id", required=True)
@click.option("--diff-key", required=True)
@click.option("--account-id", required=True)
@click.option("--at", "approved_at", required=True)
def shadow_approve(tenant_id: str, diff_key: str, account_id: str, approved_at: str) -> None:
_operator_call(
lambda: _cutover_service().approve_shadow_diff(
tenant_id=tenant_id,
diff_key=diff_key,
account_id=account_id,
approved_at=_parse_timestamp(approved_at),
)
)
_echo_json(_cutover_service().status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("shadow-resolve")
@click.option("--tenant-id", required=True)
@click.option("--diff-key", required=True)
@click.option("--account-id", required=True)
@click.option("--at", "resolved_at", required=True)
def shadow_resolve(tenant_id: str, diff_key: str, account_id: str, resolved_at: str) -> None:
_operator_call(
lambda: _cutover_service().resolve_shadow_diff(
tenant_id=tenant_id,
diff_key=diff_key,
account_id=account_id,
resolved_at=_parse_timestamp(resolved_at),
)
)
_echo_json(_cutover_service().status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("legacy-dashboard")
@click.option("--tenant-id", required=True)
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--checked-at", required=True)
@click.option("--expected-cas-version", type=click.IntRange(min=0), default=None)
@click.option("--apply", is_flag=True, default=False, help="Persist gate evidence; omitted means read-only report.")
def legacy_dashboard(
tenant_id: str,
input_path: Path,
checked_at: str,
expected_cas_version: int | None,
apply: bool,
) -> None:
dependencies = _read_jsonl(input_path, LegacyDependencyInput, allow_empty=True)
report = _operator_call(
lambda: _cutover_service().legacy_dependency_dashboard(
tenant_id=tenant_id,
dependencies=dependencies,
expected_cas_version=expected_cas_version,
checked_at=_parse_timestamp(checked_at),
apply=apply,
)
)
_echo_json(report._asdict())
@knowledge_fs_control_space.command("legacy-check")
@click.option("--tenant-id", required=True)
def legacy_check(tenant_id: str) -> None:
status_report = _operator_call(lambda: _cutover_service().status(tenant_id=tenant_id))
passed = (
bool(status_report["legacy_dependency_ready"])
and status_report["open_issues"] == 0
and status_report["unresolved_cutover_quarantine"] == 0
)
_echo_json({"tenant_id": tenant_id, "passed": passed, "status": status_report})
if not passed:
raise click.exceptions.Exit(1)
@knowledge_fs_control_space.command("freeze")
@click.option("--tenant-id", required=True)
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
@click.option("--at", "freeze_at", required=True)
def freeze(tenant_id: str, expected_cas_version: int, freeze_at: str) -> None:
service = _cutover_service()
_operator_call(
lambda: service.freeze(
tenant_id=tenant_id,
expected_cas_version=expected_cas_version,
freeze_at=_parse_timestamp(freeze_at),
)
)
_echo_json(service.status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("final-delta")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
def final_delta(input_path: Path) -> None:
payload = _read_one_jsonl(input_path, FinalDeltaInput)
service = _cutover_service()
_operator_call(lambda: service.apply_final_delta(payload))
_echo_json(service.status(tenant_id=str(payload.tenant_id)))
@knowledge_fs_control_space.command("cutover")
@click.option("--tenant-id", required=True)
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
@click.option("--at", "cutover_at", required=True)
@click.option("--rollback-cutoff-at", required=True)
def cutover(tenant_id: str, expected_cas_version: int, cutover_at: str, rollback_cutoff_at: str) -> None:
service = _cutover_service()
_operator_call(
lambda: service.cutover(
tenant_id=tenant_id,
expected_cas_version=expected_cas_version,
cutover_at=_parse_timestamp(cutover_at),
rollback_cutoff_at=_parse_timestamp(rollback_cutoff_at),
)
)
_echo_json(service.status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("smoke")
@click.option("--tenant-id", required=True)
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
def smoke(tenant_id: str, expected_cas_version: int, input_path: Path) -> None:
results = _read_one_jsonl(input_path, CutoverSmokeResultsInput)
service = _cutover_service()
_operator_call(
lambda: service.record_smoke_results(
tenant_id=tenant_id,
expected_cas_version=expected_cas_version,
results=results,
)
)
_echo_json(service.status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("observe")
@click.option("--tenant-id", required=True)
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
@click.option("--started-at", default=None)
@click.option("--window-ends-at", default=None)
@click.option("--maximum-task-expires-at", default=None)
@click.option("--observed-at", default=None)
def observe(
tenant_id: str,
expected_cas_version: int,
started_at: str | None,
window_ends_at: str | None,
maximum_task_expires_at: str | None,
observed_at: str | None,
) -> None:
service = _cutover_service()
if observed_at is not None:
if any(value is not None for value in (started_at, window_ends_at, maximum_task_expires_at)):
raise click.UsageError("--observed-at cannot be combined with observation start options")
_operator_call(
lambda: service.complete_observation(
tenant_id=tenant_id,
expected_cas_version=expected_cas_version,
observed_at=_parse_timestamp(observed_at),
)
)
else:
if started_at is None or window_ends_at is None or maximum_task_expires_at is None:
raise click.UsageError(
"observation start requires --started-at, --window-ends-at, and --maximum-task-expires-at"
)
_operator_call(
lambda: service.begin_observation(
tenant_id=tenant_id,
expected_cas_version=expected_cas_version,
started_at=_parse_timestamp(started_at),
window_ends_at=_parse_timestamp(window_ends_at),
maximum_task_expires_at=_parse_timestamp(maximum_task_expires_at),
)
)
_echo_json(service.status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("rollback")
@click.option("--tenant-id", required=True)
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
@click.option("--at", "rolled_back_at", required=True)
def rollback(tenant_id: str, expected_cas_version: int, rolled_back_at: str) -> None:
service = _cutover_service()
_operator_call(
lambda: service.rollback(
tenant_id=tenant_id,
expected_cas_version=expected_cas_version,
rolled_back_at=_parse_timestamp(rolled_back_at),
)
)
_echo_json(service.status(tenant_id=tenant_id))
@knowledge_fs_control_space.command("status")
@click.option("--tenant-id", required=True)
def status(tenant_id: str) -> None:
_echo_json(_operator_call(lambda: _cutover_service().status(tenant_id=tenant_id)))
@knowledge_fs_control_space.command("greenfield-initialize")
@click.option("--tenant-id", required=True)
def greenfield_initialize(tenant_id: str) -> None:
"""Idempotently initialize one Workspace that has no KnowledgeFS state."""
_operator_call(lambda: _greenfield_initializer().ensure_initialized(tenant_id=tenant_id))
_echo_json(_operator_call(lambda: _cutover_service().status(tenant_id=tenant_id)))
@knowledge_fs_control_space.command("cleanup-request")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist readiness evidence; omitted means dry-run.")
def cleanup_request(input_path: Path, apply: bool) -> None:
payload = _read_one_jsonl(input_path, CleanupReadinessEvidenceInput)
report = _cleanup_call(lambda: _cleanup_service().request_cleanup(payload, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("cleanup-approve")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist four-eyes approval; omitted means dry-run.")
def cleanup_approve(input_path: Path, apply: bool) -> None:
payload = _read_one_jsonl(input_path, CleanupApprovalInput)
report = _cleanup_call(lambda: _cleanup_service().approve_cleanup(payload, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("cleanup-start")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist the irreversible fence; never runs deletion.")
@click.option(
"--acknowledge-irreversible",
is_flag=True,
default=False,
help="Required with --apply; confirms rollback will be permanently closed.",
)
def cleanup_start(input_path: Path, apply: bool, acknowledge_irreversible: bool) -> None:
payload = _read_one_jsonl(input_path, CleanupStartInput)
if apply and not acknowledge_irreversible:
raise click.UsageError("--apply requires --acknowledge-irreversible")
report = _cleanup_call(lambda: _cleanup_service().start_cleanup(payload, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("cleanup-complete")
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist externally verified cleanup completion.")
@click.option(
"--acknowledge-executed",
is_flag=True,
default=False,
help="Required with --apply; confirms the reviewed destructive bundle already executed.",
)
def cleanup_complete(input_path: Path, apply: bool, acknowledge_executed: bool) -> None:
payload = _read_one_jsonl(input_path, CleanupCompletionEvidenceInput)
if apply and not acknowledge_executed:
raise click.UsageError("--apply requires --acknowledge-executed")
report = _cleanup_call(lambda: _cleanup_service().complete_cleanup(payload, apply=apply))
_echo_json(report._asdict())
@knowledge_fs_control_space.command("cleanup-status")
@click.option("--tenant-id", required=True)
@click.option("--request-id", required=True)
def cleanup_status(tenant_id: str, request_id: str) -> None:
_echo_json(_cleanup_call(lambda: _cleanup_service().status(tenant_id=tenant_id, request_id=request_id)))
@knowledge_fs_control_space.command("repair")
@click.option("--tenant-id", required=True)
@click.option("--control-space-id", required=True)
@click.option("--expected-resource-version", type=click.IntRange(min=0), required=True)
@click.option("--knowledge-space-id", required=True)
@click.option("--knowledge-space-revision", type=click.IntRange(min=0), required=True)
def repair(
tenant_id: str,
control_space_id: str,
expected_resource_version: int,
knowledge_space_id: str,
knowledge_space_revision: int,
) -> None:
control_space = _management_service().repair_registration(
tenant_id=tenant_id,
control_space_id=control_space_id,
expected_resource_version=expected_resource_version,
knowledge_space_id=knowledge_space_id,
knowledge_space_revision=knowledge_space_revision,
)
click.echo(json.dumps({"control_space_id": control_space.id, "state": control_space.state.value}, sort_keys=True))
@knowledge_fs_control_space.command("orphan-report")
@click.option("--limit", type=click.IntRange(min=1, max=10_000), default=500, show_default=True)
def orphan_report(limit: int) -> None:
report = KnowledgeFSOrphanReconciler(
session_factory.get_session_maker(),
get_knowledge_fs_lifecycle_remote(),
).reconcile(limit=limit, apply_repairs=False)
click.echo(json.dumps(report._asdict(), sort_keys=True))
@knowledge_fs_control_space.command("workspace-delete-request")
@click.option("--tenant-id", required=True)
@click.option("--apply", is_flag=True, default=False, help="Persist durable deletion intents; omitted means dry-run.")
def workspace_delete_request(tenant_id: str, apply: bool) -> None:
"""Route every KnowledgeFS Space through the canonical lifecycle deletion path."""
if not apply:
report = _management_service().dry_run(tenant_id=tenant_id)
_echo_json(
{
"apply": False,
"by_state": report.by_state,
"tenant_id": tenant_id,
"total": report.total,
}
)
return
results = _lifecycle_call(lambda: _lifecycle_service().request_workspace_cleanup(tenant_id=tenant_id))
_echo_json(
{
"apply": True,
"control_space_ids": [result.control_space.id for result in results],
"operation_ids": [result.outbox.operation_id for result in results if result.outbox is not None],
"tenant_id": tenant_id,
}
)
@knowledge_fs_control_space.command("workspace-delete-finalize")
@click.option("--tenant-id", required=True)
@click.option("--apply", is_flag=True, default=False, help="Purge terminal local control-plane rows.")
@click.option(
"--acknowledge-control-plane-purge",
is_flag=True,
default=False,
help="Required with --apply after every remote Space has reached deleted.",
)
def workspace_delete_finalize(tenant_id: str, apply: bool, acknowledge_control_plane_purge: bool) -> None:
"""Release the Workspace FK only after all remote deletions are terminal."""
service = _lifecycle_service()
if not apply:
_lifecycle_call(lambda: service.assert_workspace_deletion_allowed(tenant_id=tenant_id))
_echo_json({"apply": False, "ready": True, "tenant_id": tenant_id})
return
if not acknowledge_control_plane_purge:
raise click.UsageError("--apply requires --acknowledge-control-plane-purge")
deleted = _lifecycle_call(lambda: service.finalize_workspace_deletion(tenant_id=tenant_id))
_echo_json({"apply": True, "purged_control_spaces": deleted, "tenant_id": tenant_id})
def _read_jsonl[InputT: BaseModel](
input_path: Path, input_type: type[InputT], *, allow_empty: bool = False
) -> tuple[InputT, ...]:
records: list[InputT] = []
for line_number, line in enumerate(input_path.read_text(encoding="utf-8").splitlines(), start=1):
if not line.strip():
continue
try:
records.append(input_type.model_validate_json(line))
except ValidationError as exc:
raise click.ClickException(f"invalid strict JSONL at line {line_number}: {exc}") from exc
if not records and not allow_empty:
raise click.ClickException("strict JSONL input must contain at least one record")
return tuple(records)
def _read_one_jsonl[InputT: BaseModel](input_path: Path, input_type: type[InputT]) -> InputT:
records = _read_jsonl(input_path, input_type)
if len(records) != 1:
raise click.ClickException("this command requires exactly one JSONL record")
return records[0]
def _parse_timestamp(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value)
except ValueError as exc:
raise click.ClickException(f"invalid ISO-8601 timestamp: {value}") from exc
if parsed.tzinfo is None:
raise click.ClickException("operator timestamps must include an explicit timezone")
return parsed
def _operator_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
try:
return operation()
except KnowledgeFSCutoverError as exc:
raise click.ClickException(str(exc)) from exc
def _cleanup_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
try:
return operation()
except KnowledgeFSCleanupError as exc:
raise click.ClickException(str(exc)) from exc
def _lifecycle_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
try:
return operation()
except KnowledgeFSControlSpaceLifecycleError as exc:
raise click.ClickException(str(exc)) from exc
def _echo_json(payload: object) -> None:
click.echo(json.dumps(payload, default=str, sort_keys=True))
def _management_service() -> KnowledgeFSControlSpaceManagementService:
return KnowledgeFSControlSpaceManagementService(session_factory.get_session_maker())
def _cutover_service() -> KnowledgeFSWorkspaceCutoverService:
return KnowledgeFSWorkspaceCutoverService(
session_factory.get_session_maker(),
remote_factory=get_knowledge_fs_lifecycle_remote,
)
def _greenfield_initializer() -> KnowledgeFSWorkspaceGreenfieldInitializer:
return KnowledgeFSWorkspaceGreenfieldInitializer(
session_factory.get_session_maker(),
cutover=_cutover_service(),
)
def _cleanup_service() -> KnowledgeFSCleanupService:
return KnowledgeFSCleanupService(session_factory.get_session_maker())
def _lifecycle_service() -> KnowledgeFSControlSpaceCommandService:
return KnowledgeFSControlSpaceCommandService(session_factory.get_session_maker())
__all__ = ["knowledge_fs_control_space"]

View File

@@ -2,6 +2,7 @@ import logging
from pathlib import Path
from typing import Any, override
from pydantic import model_validator
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
@@ -91,6 +92,13 @@ class DifyConfig(
# for better readability and maintainability.
# Thanks for your concentration and consideration.
@model_validator(mode="after")
def validate_knowledge_fs_production_origin(self) -> "DifyConfig":
if self.DEPLOY_ENV.strip().upper() == "PRODUCTION":
if self.KNOWLEDGE_FS_BASE_URL and not self._is_secure_or_loopback_origin(self.KNOWLEDGE_FS_BASE_URL):
raise ValueError("KNOWLEDGE_FS_BASE_URL must use HTTPS in production unless it targets loopback")
return self
@classmethod
@override
def settings_customise_sources(

View File

@@ -1,30 +1,82 @@
"""Configuration for the optional KnowledgeFS Console bridge."""
"""Configuration for the optional KnowledgeFS control-plane integration."""
from ipaddress import ip_address
from urllib.parse import urlsplit
from pydantic import Field, PositiveFloat, SecretStr, field_validator, model_validator
from pydantic import Field, PositiveFloat, PositiveInt, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings
class KnowledgeFSConfig(BaseSettings):
"""Server-only settings for the KnowledgeFS production connection."""
"""Server-only KnowledgeFS connection and rollout settings."""
KNOWLEDGE_FS_BACKGROUND_WORKER_ENABLED: bool = Field(
default=False,
description="Publish KnowledgeFS background work to dedicated Celery queues after worker rollout.",
)
KNOWLEDGE_FS_BACKGROUND_ENGINE_PATH: str = Field(default="/app/knowledge-fs/celery-worker.mjs")
KNOWLEDGE_FS_BACKGROUND_TASK_TIMEOUT_SECONDS: PositiveInt = Field(default=7200, ge=300, le=14400)
KNOWLEDGE_FS_BACKGROUND_POLL_INTERVAL_SECONDS: PositiveInt = Field(default=5, ge=1, le=60)
KNOWLEDGE_FS_ENABLED: bool = Field(
default=False,
description="Enable the private KnowledgeFS Console bridge.",
description="Enable the KnowledgeFS control-plane product routes.",
)
KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED: bool = Field(
default=False,
description="Explicitly expose KnowledgeFS in Community edition for development deployments.",
)
KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED: bool = Field(
default=False,
description="Enable delivery of durable KnowledgeFS lifecycle commands after every rollout gate is ready.",
)
KNOWLEDGE_FS_INTEGRATED_PROVISION_READY: bool = Field(
default=False,
description="Confirm that the Capability-v2 integrated provision route is deployed and verified.",
)
KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY: bool = Field(
default=False,
description="Confirm that legacy KFS ACL mutation is frozen for integrated mode.",
)
KNOWLEDGE_FS_LIFECYCLE_POLL_INTERVAL_SECONDS: PositiveInt = Field(default=15, le=300)
KNOWLEDGE_FS_LIFECYCLE_LEASE_SECONDS: PositiveInt = Field(default=60, le=600)
KNOWLEDGE_FS_LIFECYCLE_BATCH_SIZE: PositiveInt = Field(default=25, le=1_000)
KNOWLEDGE_FS_BASE_URL: str | None = Field(default=None, description="KnowledgeFS gateway base URL.")
KNOWLEDGE_FS_JWT_SECRET: SecretStr | None = Field(
default=None,
min_length=32,
description="Shared secret used to sign short-lived KnowledgeFS service JWTs.",
KNOWLEDGE_FS_CAPABILITY_V2_ENABLED: bool = Field(
default=False,
description="Prepare resource-scoped Capability v2 issuance; disabled until rollout approval.",
)
KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS: PositiveFloat = Field(default=300.0, le=3600.0, allow_inf_nan=False)
KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID: str | None = Field(
default=None,
description="Identifier for the current asymmetric Capability v2 signing key.",
)
KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM: SecretStr | None = Field(
default=None,
description="Server-only PEM for the current Capability v2 RSA signing key.",
)
KNOWLEDGE_FS_CAPABILITY_V2_PREVIOUS_PUBLIC_JWKS: str | None = Field(
default=None,
description="Optional public-only JWKS JSON retained during key rotation overlap.",
)
KNOWLEDGE_FS_CAPABILITY_V2_ISSUER: str = Field(default="dify-control-plane", min_length=1)
KNOWLEDGE_FS_CAPABILITY_V2_AUDIENCE: str = Field(default="knowledge-fs", min_length=1)
KNOWLEDGE_FS_CAPABILITY_V2_MAX_TTL_SECONDS: PositiveInt = Field(default=60, le=60)
KNOWLEDGE_FS_JWKS_CACHE_MAX_AGE_SECONDS: PositiveInt = Field(default=300, le=86_400)
KNOWLEDGE_FS_PRODUCT_MAX_RESPONSE_BYTES: PositiveInt = Field(default=4 * 1024 * 1024, le=16 * 1024 * 1024)
KNOWLEDGE_FS_TIMEOUT_SECONDS: PositiveFloat = Field(default=10.0, le=60.0, allow_inf_nan=False)
KNOWLEDGE_FS_RETRIEVAL_TEST_TIMEOUT_SECONDS: float = Field(
default=75.0,
gt=60.0,
le=120.0,
allow_inf_nan=False,
description="Timeout for retrieval tests and Agent investigation triage, which have a 60-second model budget.",
)
@field_validator(
"KNOWLEDGE_FS_BASE_URL",
"KNOWLEDGE_FS_JWT_SECRET",
"KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID",
"KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM",
"KNOWLEDGE_FS_CAPABILITY_V2_PREVIOUS_PUBLIC_JWKS",
mode="before",
)
@classmethod
@@ -40,25 +92,45 @@ class KnowledgeFSConfig(BaseSettings):
@field_validator("KNOWLEDGE_FS_BASE_URL")
@classmethod
def validate_base_url(cls, value: str | None) -> str | None:
return cls._validate_origin(value, name="KNOWLEDGE_FS_BASE_URL")
@classmethod
def _validate_origin(cls, value: str | None, *, name: str) -> str | None:
if value is None:
return None
parsed = urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("KNOWLEDGE_FS_BASE_URL must be an absolute HTTP(S) URL")
raise ValueError(f"{name} must be an absolute HTTP(S) URL")
try:
_ = parsed.port
except ValueError as exc:
raise ValueError("KNOWLEDGE_FS_BASE_URL must include a valid port") from exc
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError("KNOWLEDGE_FS_BASE_URL must not include credentials, query, or fragment")
raise ValueError(f"{name} must include a valid port") from exc
if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
raise ValueError(f"{name} must be an origin without credentials, path, query, or fragment")
return value.rstrip("/")
@model_validator(mode="after")
def validate_enabled_connection(self) -> "KnowledgeFSConfig":
if not self.KNOWLEDGE_FS_ENABLED:
return self
if bool(self.KNOWLEDGE_FS_BASE_URL) != bool(self.KNOWLEDGE_FS_JWT_SECRET):
raise ValueError("KNOWLEDGE_FS_BASE_URL and KNOWLEDGE_FS_JWT_SECRET must be configured together")
if not self.KNOWLEDGE_FS_BASE_URL:
raise ValueError("KnowledgeFS connection settings are required when the integration is enabled")
if self.KNOWLEDGE_FS_ENABLED:
if not self.KNOWLEDGE_FS_BASE_URL:
raise ValueError("KnowledgeFS base URL is required when the integration is enabled")
if not self.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED:
raise ValueError("KnowledgeFS product routes require Capability v2 when enabled")
if self.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED and not (
self.KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID and self.KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM
):
raise ValueError("Capability v2 signing kid and private key are required when issuance is enabled")
return self
@staticmethod
def _is_secure_or_loopback_origin(value: str) -> bool:
parsed = urlsplit(value)
if parsed.scheme == "https":
return True
hostname = (parsed.hostname or "").rstrip(".").lower()
if hostname == "localhost":
return True
try:
return ip_address(hostname).is_loopback
except ValueError:
return False

View File

@@ -293,6 +293,13 @@ class CeleryConfig(DatabaseConfig):
default=None,
)
CELERY_BROKER_VISIBILITY_TIMEOUT: PositiveInt | None = Field(
default=None,
description=(
"Redis/Sentinel unacknowledged delivery visibility in seconds; set identically on all Celery processes."
),
)
CELERY_USE_SENTINEL: bool | None = Field(
description="Whether to use Redis Sentinel for high availability.",
default=False,

View File

@@ -79,4 +79,6 @@ 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_IDEMPOTENCY_KEY = "Idempotency-Key"
HEADER_NAME_REQUEST_ID = "X-Request-ID"
HEADER_NAME_CATALOG = "X-Dify-Catalog"

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Annotated, Any, Literal
from dify_agent.protocol.knowledge_fs import KnowledgeFsCitation
from pydantic import BaseModel, ConfigDict, Field, RootModel, WithJsonSchema, computed_field
from fields.base import ResponseModel
@@ -157,6 +158,7 @@ class BlockingUsageResponse(ResponseModel):
class BlockingRetrieverResourceResponse(ResponseModel):
knowledge_fs_citation: KnowledgeFsCitation | None = None
model_config = ConfigDict(extra="allow")
id: UUIDString | None = None

View File

@@ -234,15 +234,9 @@ def _query_param_from_property(
items = param_schema.get("items")
if isinstance(items, Mapping):
item_schema = _resolve_schema_ref(items, definitions)
item_type = item_schema.get("type")
if isinstance(item_type, str):
param_doc["items"] = {"type": item_type}
item_enum = item_schema.get("enum")
if isinstance(item_enum, list):
param_doc.setdefault("items", {})["enum"] = item_enum
item_format = item_schema.get("format")
if isinstance(item_format, str):
param_doc.setdefault("items", {})["format"] = item_format
item_doc = _query_param_item_schema(item_schema)
if item_doc:
param_doc["items"] = item_doc
enum = param_schema.get("enum")
if isinstance(enum, list):
@@ -303,6 +297,31 @@ def _query_param_from_property(
return param_doc
def _query_param_item_schema(item_schema: Mapping[str, Any]) -> dict[str, object]:
item_doc: dict[str, object] = {}
for key in ("type", "format", "pattern"):
value = item_schema.get(key)
if isinstance(value, str):
item_doc[key] = value
enum = item_schema.get("enum")
if isinstance(enum, list):
item_doc["enum"] = enum
for key in ("minLength", "maxLength"):
value = item_schema.get(key)
if isinstance(value, int):
item_doc[key] = value
for key in ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"):
value = item_schema.get(key)
if isinstance(value, int | float):
item_doc[key] = value
return item_doc
def _resolve_schema_ref(property_schema: Mapping[str, Any], definitions: Mapping[str, Any]) -> Mapping[str, Any]:
ref = property_schema.get("$ref")
if not isinstance(ref, str):

View File

@@ -60,7 +60,6 @@ from . import (
feature,
human_input_form,
init_validate,
knowledge_fs_proxy,
notification,
onboarding,
setup,
@@ -147,6 +146,7 @@ from .explore import (
saved_message,
trial,
)
from .knowledge_fs import resources as knowledge_fs_resources
from .snippets import snippet_workflow, snippet_workflow_draft_variable
from .socketio import workflow as socketio_workflow
@@ -217,7 +217,7 @@ __all__ = [
"human_input_form",
"init_validate",
"installed_app",
"knowledge_fs_proxy",
"knowledge_fs_resources",
"load_balancing_config",
"login",
"mcp_server",

View File

@@ -25,7 +25,7 @@ from services.auth.api_key_contracts import (
ApiKeyResourceNotFoundError,
)
from services.errors.account import NoPermissionError
from services.knowledge.api_key_service import UnknownDatasetIdsError
from services.knowledge.api_key_service import UnknownDatasetIdsError, UnknownKnowledgeSpaceIdsError
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
API_KEY_DELETE_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
@@ -42,7 +42,7 @@ def api_key_errors() -> Generator[None]:
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:
except (UnknownDatasetIdsError, UnknownKnowledgeSpaceIdsError) as error:
flask_restx.abort(400, message=str(error))
except NoPermissionError as error:
flask_restx.abort(403, message=str(error))

View File

@@ -283,10 +283,7 @@ class AppImportConfirmApi(Resource):
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))
result = import_service.confirm_import(import_id=import_id, account=account)
if result.status == ImportStatus.FAILED:
session.rollback()
else:

View File

@@ -1,14 +1,14 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from uuid import UUID
from typing import Annotated, Any
from uuid import UUID, uuid4
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound
import services
from configs import dify_config
@@ -32,6 +32,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.db.session_factory import session_factory
from core.entities.knowledge_entities import IndexingEstimate
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.indexing_runner import IndexingRunner
@@ -57,21 +58,79 @@ from libs.url_utils import normalize_api_base_url
from machinery.context import RequestContext
from models import Account, App, Dataset, Document, UploadFile
from models.dataset import DatasetPermission, DatasetPermissionEnum, DatasetQuery
from models.knowledge_fs import KnowledgeFSUpgradeJobStatus
from models.provider_ids import ModelProviderID
from services.app_service import AppService
from services.dataset_knowledge_fs_upgrade_service import (
KnowledgeFSUpgradeConflictError,
KnowledgeFSUpgradeNotFoundError,
KnowledgeFSUpgradeSnapshotService,
upgrade_discovery_response,
upgrade_job_response,
)
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
from services.knowledge_fs.product_dto import (
KnowledgeFSIdempotencyHeader,
KnowledgeFSUpgradeDiscoveryResponse,
KnowledgeFSUpgradeJobListResponse,
KnowledgeFSUpgradeJobResponse,
KnowledgeFSUpgradeRetryResponse,
)
register_response_schema_models(console_ns, ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse)
DATASET_LIST_PERMISSION_KEYS = frozenset({"dataset.preview", "dataset.acl.preview", "dataset.full_access"})
_KNOWLEDGE_FS_UPGRADE_IDEMPOTENCY_HEADER_PARAMS = {
"Idempotency-Key": {
"description": "Stable key used to make the Dataset upgrade safe to retry",
"in": "header",
"maxLength": 255,
"minLength": 8,
"required": True,
"type": "string",
}
}
def _has_dataset_list_permission(permission_keys: list[str]) -> bool:
return any(permission_key in DATASET_LIST_PERMISSION_KEYS for permission_key in permission_keys)
def _dataset_list_access_scope(
*, tenant_id: str, user: Account, session: Session, permissions: Any | None = None
) -> tuple[list[str] | None, bool]:
if not dify_config.RBAC_ENABLED:
return None, False
permissions = permissions or enterprise_rbac_service.RBACService.MyPermissions.get(
tenant_id, user.id, session=session
)
whitelist_scope = enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources(tenant_id, user.id)
has_default_readonly = _has_dataset_list_permission(
permissions.dataset.default_permission_keys
) or _has_dataset_list_permission(permissions.workspace.permission_keys)
permission_dataset_ids: set[str] | None = None
if not has_default_readonly:
permission_dataset_ids = {
override.resource_id
for override in permissions.dataset.overrides
if _has_dataset_list_permission(override.permission_keys)
}
include_own_datasets = False
if whitelist_scope.unrestricted:
filtered_dataset_ids = permission_dataset_ids
include_own_datasets = "dataset.create_and_management" in permissions.workspace.permission_keys
else:
# A restricted dataset whitelist is the highest-priority visibility gate:
# default readonly, per-dataset permission overrides, and own-dataset
# management must not expose datasets outside this set.
filtered_dataset_ids = set(whitelist_scope.resource_ids)
accessible_dataset_ids = sorted(filtered_dataset_ids) if filtered_dataset_ids is not None else None
return accessible_dataset_ids, include_own_datasets
def _get_accessible_dataset(dataset_id: UUID, tenant_id: str, current_user: Account, session: Session) -> Dataset:
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), tenant_id, session=session)
if dataset is None:
@@ -84,6 +143,16 @@ def _get_accessible_dataset(dataset_id: UUID, tenant_id: str, current_user: Acco
return dataset
def _knowledge_fs_upgrade_idempotency_key() -> str:
try:
header = KnowledgeFSIdempotencyHeader.model_validate(
{"idempotency-key": request.headers.get("Idempotency-Key")}
)
except ValidationError as error:
raise BadRequest("A valid Idempotency-Key header is required") from error
return header.idempotency_key
def _validate_indexing_technique(value: str | None) -> str | None:
if value is None:
return value
@@ -170,9 +239,12 @@ class IndexingEstimatePayload(BaseModel):
class DatasetApiKeyCreatePayload(BaseModel):
# Knowledge bases to scope the key to. Absent/empty => the key can access every
# dataset in the tenant (default). Declared so the generated client can send it.
# Knowledge bases to scope the key to. Both lists absent/empty => the key can access
# every knowledge base in the tenant (default). Legacy datasets and KnowledgeFS spaces
# live in different tables, so they are listed separately. Declared so the generated
# client can send them.
dataset_ids: list[str] = Field(default_factory=list)
knowledge_space_ids: list[str] = Field(default_factory=list)
class ConsoleDatasetListQuery(BaseModel):
@@ -182,10 +254,16 @@ class ConsoleDatasetListQuery(BaseModel):
include_all: bool = Field(default=False, description="Include all datasets")
ids: list[str] = Field(default_factory=list, description="Filter by dataset IDs")
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
creator_ids: list[Annotated[str, Field(min_length=1, max_length=255)]] = Field(
default_factory=list,
max_length=100,
description="Filter by creator account IDs",
)
class DatasetListItemResponse(DatasetDetailResponse):
partial_member_list: list[str]
knowledge_fs_upgrade: KnowledgeFSUpgradeDiscoveryResponse
class DatasetListResponse(ResponseModel):
@@ -366,6 +444,10 @@ register_response_schema_models(
RetrievalSettingResponse,
PartialMemberListResponse,
AutoDisableLogsResponse,
KnowledgeFSUpgradeJobResponse,
KnowledgeFSUpgradeRetryResponse,
KnowledgeFSUpgradeDiscoveryResponse,
KnowledgeFSUpgradeJobListResponse,
)
@@ -463,46 +545,21 @@ class DatasetListApi(Resource):
def get(self, session: Session, current_tenant_id: str, current_user: Account):
# Convert query parameters to dict, handling list parameters correctly
query_params: dict[str, str | list[str]] = dict(request.args.to_dict())
# Handle ids and tag_ids as lists (Flask request.args.getlist returns list even for single value)
# Handle list fields explicitly (Flask request.args.getlist returns a list even for one value).
if "ids" in request.args:
query_params["ids"] = request.args.getlist("ids")
if "tag_ids" in request.args:
query_params["tag_ids"] = request.args.getlist("tag_ids")
if "creator_ids" in request.args:
query_params["creator_ids"] = request.args.getlist("creator_ids")
query = ConsoleDatasetListQuery.model_validate(query_params)
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
str(current_tenant_id),
current_user.id,
session=session,
str(current_tenant_id), current_user.id, session=session
)
accessible_dataset_ids, include_own_datasets = _dataset_list_access_scope(
tenant_id=str(current_tenant_id), user=current_user, session=session, permissions=permissions
)
accessible_dataset_ids: list[str] | None = None
include_own_datasets = False
if dify_config.RBAC_ENABLED:
whitelist_scope = enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources(
str(current_tenant_id),
current_user.id,
)
has_default_readonly = _has_dataset_list_permission(
permissions.dataset.default_permission_keys
) or _has_dataset_list_permission(permissions.workspace.permission_keys)
permission_dataset_ids: set[str] | None = None
if not has_default_readonly:
permission_dataset_ids = {
override.resource_id
for override in permissions.dataset.overrides
if _has_dataset_list_permission(override.permission_keys)
}
if getattr(whitelist_scope, "unrestricted", False):
filtered_dataset_ids = permission_dataset_ids
include_own_datasets = "dataset.create_and_management" in permissions.workspace.permission_keys
else:
# A restricted dataset whitelist is the highest-priority visibility gate:
# default readonly, per-dataset permission overrides, and own-dataset
# management must not expose datasets outside this set.
filtered_dataset_ids = set(whitelist_scope.resource_ids)
if filtered_dataset_ids is not None:
accessible_dataset_ids = sorted(filtered_dataset_ids)
effective_page, effective_limit = clamp_pagination(query.page, query.limit, 100)
if query.ids:
@@ -527,6 +584,7 @@ class DatasetListApi(Resource):
query.keyword,
query.tag_ids,
query.include_all,
creator_ids=query.creator_ids,
accessible_dataset_ids=accessible_dataset_ids,
include_own_datasets=include_own_datasets,
)
@@ -554,6 +612,11 @@ class DatasetListApi(Resource):
)
for dataset in datasets
]
upgrade_jobs = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker()).get_latest_by_dataset_ids(
tenant_id=current_tenant_id,
dataset_ids=[str(dataset.id) for dataset in datasets],
session=session,
)
dataset_ids = [item["id"] for item in data if item.get("permission") == "partial_members"]
partial_members_map: dict[str, list[str]] = {}
if dataset_ids:
@@ -583,6 +646,11 @@ class DatasetListApi(Resource):
else:
item.update({"partial_member_list": []})
item["permission_keys"] = permission_keys_map.get(str(item["id"]), [])
item["knowledge_fs_upgrade"] = upgrade_discovery_response(
upgrade_jobs.get(str(item["id"])),
feature_enabled=dify_config.KNOWLEDGE_FS_ENABLED,
dataset_provider=str(item["provider"]),
).model_dump(mode="json")
response = {
"data": data,
@@ -824,6 +892,199 @@ class DatasetApi(Resource):
raise DatasetInUseError()
@console_ns.route("/datasets/knowledge-fs-upgrade-jobs")
class DatasetKnowledgeFSUpgradeJobsApi(Resource):
@console_ns.response(
200,
"Recoverable upgrade jobs from the Classic Knowledge Base to an Agent Knowledge Base",
console_ns.models[KnowledgeFSUpgradeJobListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, Workspace()))
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, current_user: Account):
jobs = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker()).list_by_statuses(
tenant_id=current_tenant_id,
statuses=(
KnowledgeFSUpgradeJobStatus.QUEUED,
KnowledgeFSUpgradeJobStatus.RUNNING,
KnowledgeFSUpgradeJobStatus.FAILED,
),
)
dataset_ids = list(dict.fromkeys(job.old_dataset_id for job in jobs))
accessible_dataset_ids, include_own_datasets = _dataset_list_access_scope(
tenant_id=current_tenant_id, user=current_user, session=session
)
datasets, _ = DatasetService.get_datasets_by_ids(
dataset_ids,
current_tenant_id,
user=current_user,
accessible_dataset_ids=accessible_dataset_ids,
include_own_datasets=include_own_datasets,
session=session,
)
allowed_dataset_ids: set[str] = set()
for dataset in datasets:
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError:
continue
allowed_dataset_ids.add(str(dataset.id))
return dump_response(
KnowledgeFSUpgradeJobListResponse,
{"data": [upgrade_job_response(job) for job in jobs if job.old_dataset_id in allowed_dataset_ids]},
)
@console_ns.route("/datasets/<uuid:dataset_id>/knowledge-fs-upgrades")
class DatasetKnowledgeFSUpgradeApi(Resource):
@console_ns.response(
200,
"Upgrade availability from the Classic Knowledge Base to an Agent Knowledge Base",
console_ns.models[KnowledgeFSUpgradeDiscoveryResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()))
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID):
with session_factory.create_session() as session:
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
snapshots = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker())
job = snapshots.get_latest(tenant_id=current_tenant_id, dataset_id=str(dataset_id))
return dump_response(
KnowledgeFSUpgradeDiscoveryResponse,
upgrade_discovery_response(
job,
feature_enabled=dify_config.KNOWLEDGE_FS_ENABLED,
dataset_provider=dataset.provider,
),
)
@console_ns.doc(params=_KNOWLEDGE_FS_UPGRADE_IDEMPOTENCY_HEADER_PARAMS)
@console_ns.response(
202,
"Upgrade from the Classic Knowledge Base to an Agent Knowledge Base accepted",
console_ns.models[KnowledgeFSUpgradeJobResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID):
if not dify_config.KNOWLEDGE_FS_ENABLED:
raise NotFound()
dataset_id_str = str(dataset_id)
with session_factory.create_session() as session:
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 error:
raise Forbidden(str(error)) from error
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
raise Forbidden()
snapshots = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker())
try:
job = snapshots.create(
tenant_id=current_tenant_id,
dataset_id=dataset_id_str,
requested_by_account_id=current_user.id,
idempotency_key=_knowledge_fs_upgrade_idempotency_key(),
)
except KnowledgeFSUpgradeNotFoundError as error:
raise NotFound(str(error)) from error
except KnowledgeFSUpgradeConflictError as error:
raise Conflict(str(error)) from error
_enqueue_upgrade_job(snapshots, tenant_id=current_tenant_id, job_id=job.id)
return dump_response(KnowledgeFSUpgradeJobResponse, upgrade_job_response(job)), 202
@console_ns.route("/datasets/<uuid:dataset_id>/knowledge-fs-upgrades/<string:job_id>")
class DatasetKnowledgeFSUpgradeJobApi(Resource):
@console_ns.response(
200,
"Status of the upgrade from the Classic Knowledge Base to an Agent Knowledge Base",
console_ns.models[KnowledgeFSUpgradeJobResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()))
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, job_id: str):
with session_factory.create_session() as session:
_get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
snapshots = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker())
try:
job = snapshots.get(tenant_id=current_tenant_id, job_id=job_id)
except KnowledgeFSUpgradeNotFoundError as error:
raise NotFound(str(error)) from error
if job.old_dataset_id != str(dataset_id):
raise NotFound("Upgrade job was not found")
return dump_response(KnowledgeFSUpgradeJobResponse, upgrade_job_response(job))
@console_ns.response(
202,
"Retry of the upgrade from the Classic Knowledge Base to an Agent Knowledge Base accepted",
console_ns.models[KnowledgeFSUpgradeRetryResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, job_id: str):
with session_factory.create_session() as session:
_get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
if not dify_config.RBAC_ENABLED and not (
current_user.has_edit_permission or current_user.is_dataset_operator
):
raise Forbidden()
snapshots = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker())
try:
job = snapshots.retry(tenant_id=current_tenant_id, job_id=job_id)
except KnowledgeFSUpgradeNotFoundError as error:
raise NotFound(str(error)) from error
except KnowledgeFSUpgradeConflictError as error:
raise Conflict(str(error)) from error
if job.old_dataset_id != str(dataset_id):
raise NotFound("Upgrade job was not found")
_enqueue_upgrade_job(snapshots, tenant_id=current_tenant_id, job_id=job.id)
return dump_response(KnowledgeFSUpgradeRetryResponse, {"id": job.id, "status": "queued"}), 202
def _enqueue_upgrade_job(
snapshots: KnowledgeFSUpgradeSnapshotService,
*,
tenant_id: str,
job_id: str,
) -> None:
from tasks.knowledge_fs_upgrade_tasks import run_knowledge_fs_upgrade
task_id = str(uuid4())
if not snapshots.claim_enqueue(tenant_id=tenant_id, job_id=job_id, task_id=task_id):
return
try:
run_knowledge_fs_upgrade.apply_async(kwargs={"job_id": job_id}, task_id=task_id)
except Exception:
snapshots.release_enqueue_claim(tenant_id=tenant_id, job_id=job_id, task_id=task_id)
raise
@console_ns.route("/datasets/<uuid:dataset_id>/use-check")
class DatasetUseCheckApi(Resource):
@console_ns.doc("check_dataset_use")
@@ -1135,13 +1396,22 @@ class DatasetApiKeyApi(Resource):
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.")
raw_dataset_ids = self._id_list(payload, "dataset_ids")
knowledge_space_ids = self._id_list(payload, "knowledge_space_ids")
with api_key_errors():
key = application_services().dataset_api_keys.create_workspace_key(request_context, tuple(raw_dataset_ids))
key = application_services().dataset_api_keys.create_workspace_key(
request_context, tuple(raw_dataset_ids), knowledge_space_ids=tuple(knowledge_space_ids)
)
return dump_response(ApiKeyItem, key), 200
@staticmethod
def _id_list(payload: object, key: str) -> list[str]:
raw = payload.get(key) if isinstance(payload, dict) else None
raw = raw or []
if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
console_ns.abort(400, message=f"{key} must be a list of strings.")
return list(dict.fromkeys(raw))
@console_ns.route("/datasets/api-keys/<uuid:api_key_id>")
class DatasetApiDeleteApi(Resource):

View File

@@ -0,0 +1,5 @@
"""Typed Dify-owned KnowledgeFS Console product API."""
from . import resources
__all__ = ["resources"]

View File

@@ -0,0 +1,85 @@
"""Stable, non-enumerating Console error contract for KnowledgeFS."""
from libs.exception import BaseHTTPException
from services.knowledge_fs.product_dto import KnowledgeFSPublicFailureResponse
class _KnowledgeFSFailureHTTPError(BaseHTTPException):
def __init__(self, failure: KnowledgeFSPublicFailureResponse | None = None) -> None:
super().__init__(failure.message if failure else None)
if failure is not None and self.data is not None:
self.data["failure"] = failure.model_dump(by_alias=True, exclude_none=True)
class KnowledgeFSSpaceNotFoundHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_space_not_found"
description = "Agent Knowledge Base was not found."
code = 404
class KnowledgeFSResourceNotFoundHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_resource_not_found"
description = "Agent Knowledge Base resource was not found."
code = 404
class KnowledgeFSOperationUnavailableHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_operation_unavailable"
description = "Agent Knowledge Base operation is not available."
code = 503
class KnowledgeFSUpstreamUnavailableHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_upstream_unavailable"
description = "Agent Knowledge Base is unavailable."
code = 503
class KnowledgeFSInvalidRequestHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_invalid_request"
description = "Agent Knowledge Base request is invalid."
code = 400
class KnowledgeFSAccessDeniedHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_access_denied"
description = "Agent Knowledge Base operation is not allowed."
code = 403
class KnowledgeFSConflictHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_conflict"
description = "Agent Knowledge Base operation conflicts with the current resource state."
code = 409
class KnowledgeFSRequestTooLargeHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_request_too_large"
description = "Agent Knowledge Base request is too large."
code = 413
class KnowledgeFSRequestRejectedHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_request_rejected"
description = "Agent Knowledge Base request was rejected."
code = 422
class KnowledgeFSRateLimitHTTPError(_KnowledgeFSFailureHTTPError):
error_code = "knowledge_fs_rate_limit_exceeded"
description = "Agent Knowledge Base request rate limit exceeded."
code = 429
__all__ = [
"KnowledgeFSAccessDeniedHTTPError",
"KnowledgeFSConflictHTTPError",
"KnowledgeFSInvalidRequestHTTPError",
"KnowledgeFSOperationUnavailableHTTPError",
"KnowledgeFSRateLimitHTTPError",
"KnowledgeFSRequestRejectedHTTPError",
"KnowledgeFSRequestTooLargeHTTPError",
"KnowledgeFSResourceNotFoundHTTPError",
"KnowledgeFSSpaceNotFoundHTTPError",
"KnowledgeFSUpstreamUnavailableHTTPError",
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,419 +0,0 @@
"""Authenticated transport adapter for the Console-to-KnowledgeFS proxy.
These raw Blueprint routes deliberately stay outside Dify's OpenAPI surface:
KnowledgeFS owns the wire contract consumed by the frontend. The catch-all path
avoids resource-specific Dify controllers, while the forwarding module consumes
only the operations explicitly enabled by Dify's product registry. The registry
can be validated explicitly against the pinned KnowledgeFS contract during development.
Console auth and contract-specific dataset RBAC run before forwarding. Request
bodies are capped at 64 MiB, JSON and binary responses have separate bounds,
SSE responses remain streaming with a bounded idle read timeout, and only safe
response headers are exposed. Operation-specific upstream error mappings are
applied before Console JSON error handling; the default maps 401 to 502 so it
cannot trigger browser-session recovery and preserves resource-level 403.
"""
from __future__ import annotations
import logging
from collections.abc import Callable, Iterator
from functools import wraps
from http import HTTPStatus
from typing import NoReturn, cast
import httpx
from flask import Response, request, stream_with_context
from flask.typing import ResponseReturnValue
from werkzeug.exceptions import (
BadGateway,
Forbidden,
GatewayTimeout,
HTTPException,
NotFound,
RequestEntityTooLarge,
ServiceUnavailable,
default_exceptions,
)
from configs import dify_config
from controllers.console import api, bp
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_rate_limit_check,
setup_required,
)
from core.helper import ssrf_proxy
from libs.login import current_account_with_tenant, login_required
from services.knowledge_fs_operations import KnowledgeFSMethod
from services.knowledge_fs_proxy import (
KnowledgeFSAccessDeniedError,
KnowledgeFSAuthorization,
KnowledgeFSConfigurationError,
KnowledgeFSRouteNotAllowedError,
KnowledgeFSTimeoutError,
KnowledgeFSTransportError,
KnowledgeFSUpstreamResponse,
authorize_knowledge_fs_request,
get_knowledge_fs_operation,
proxy_authorized_knowledge_fs_request,
proxy_knowledge_fs_request,
)
logger = logging.getLogger(__name__)
type _KnowledgeFSRequestForwarder = Callable[
[str | None, str | None, bytes | None, bytes | None],
KnowledgeFSUpstreamResponse,
]
_MAX_PROXY_BODY_BYTES = 64 * 1024 * 1024
_RESPONSE_HEADER_ALLOWLIST = (
"Cache-Control",
"Content-Disposition",
"Content-Type",
"Retry-After",
"X-Trace-Id",
)
_RESPONSE_HEADER_DENYLIST = frozenset(
{
"authorization",
"connection",
"cookie",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"set-cookie",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
)
def _console_api_errors[**P](
view: Callable[P, ResponseReturnValue],
) -> Callable[P, ResponseReturnValue]:
"""Route raw Blueprint exceptions through the Console API JSON handlers."""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
try:
return view(*args, **kwargs)
except Exception as exc:
return api.handle_error(exc)
return decorated
def _knowledge_fs_enabled[**P](
view: Callable[P, ResponseReturnValue],
) -> Callable[P, ResponseReturnValue]:
"""Hide the complete KnowledgeFS route surface while the bridge is disabled."""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
if not dify_config.KNOWLEDGE_FS_ENABLED:
raise NotFound()
return view(*args, **kwargs)
return decorated
def _translate_proxy_error(exc: Exception, *, tenant_id: str) -> NoReturn:
"""Map forwarding failures to the stable Console HTTP error surface."""
if isinstance(exc, KnowledgeFSRouteNotAllowedError):
raise NotFound() from exc
if isinstance(exc, KnowledgeFSAccessDeniedError):
raise Forbidden() from exc
if isinstance(exc, KnowledgeFSConfigurationError):
logger.error("KnowledgeFS request was blocked by invalid configuration for tenant_id=%s", tenant_id)
raise ServiceUnavailable("KnowledgeFS integration is misconfigured") from exc
if isinstance(exc, KnowledgeFSTimeoutError):
raise GatewayTimeout("KnowledgeFS request timed out") from exc
if isinstance(exc, KnowledgeFSTransportError):
logger.warning("KnowledgeFS transport request failed for tenant_id=%s", tenant_id)
raise BadGateway("KnowledgeFS is unavailable") from exc
raise exc
def _knowledge_fs_operation_access_required(
view: Callable[[KnowledgeFSAuthorization], ResponseReturnValue],
) -> Callable[[KnowledgeFSMethod, str], ResponseReturnValue]:
"""Authorize one declared operation before billing and request-body work."""
@wraps(view)
def decorated(method: KnowledgeFSMethod, upstream_path: str) -> ResponseReturnValue:
current_user, tenant_id = current_account_with_tenant()
try:
authorization = authorize_knowledge_fs_request(
account=current_user,
tenant_id=tenant_id,
method=method,
path=upstream_path,
)
except KnowledgeFSRouteNotAllowedError as exc:
raise NotFound() from exc
except KnowledgeFSAccessDeniedError as exc:
_translate_proxy_error(exc, tenant_id=tenant_id)
return view(authorization)
return decorated
def _request_body() -> bytes:
"""Read the raw body up to the proxy limit or raise RequestEntityTooLarge."""
body = request.stream.read(_MAX_PROXY_BODY_BYTES + 1)
if len(body) > _MAX_PROXY_BODY_BYTES:
raise RequestEntityTooLarge("KnowledgeFS proxy request body is too large")
return body
def _stream_response_body(
upstream: httpx.Response,
*,
tenant_id: str,
max_response_bytes: int,
) -> Iterator[bytes]:
"""Yield one bounded SSE response and always release its pooled connection."""
total_bytes = 0
try:
for chunk in upstream.iter_bytes():
total_bytes += len(chunk)
if total_bytes > max_response_bytes:
logger.warning("KnowledgeFS stream exceeded the proxy limit for tenant_id=%s", tenant_id)
raise ssrf_proxy.ResponseTooLargeError(f"response exceeded {max_response_bytes} bytes")
yield chunk
finally:
upstream.close()
def _proxy_response(
upstream_result: KnowledgeFSUpstreamResponse,
*,
tenant_id: str,
contract_response_headers: tuple[str, ...],
max_response_bytes: int,
) -> Response:
"""Expose raw content, status, and allowlisted headers from KnowledgeFS.
Raises:
HTTPException: KnowledgeFS returns a status normalized by the operation contract.
"""
upstream = upstream_result.response
mapped_status = dict(upstream_result.operation.error_status_map).get(upstream.status_code)
if mapped_status is not None:
upstream.close()
description = "KnowledgeFS upstream request failed"
if upstream.status_code == HTTPStatus.UNAUTHORIZED:
description = "KnowledgeFS authentication failed"
logger.error(
"KnowledgeFS rejected the Dify server credential with HTTP %s for tenant_id=%s",
upstream.status_code,
tenant_id,
)
exception_type = default_exceptions.get(mapped_status)
if exception_type is None:
exception = HTTPException(description)
exception.code = mapped_status
raise exception
raise exception_type(description)
allowed_header_names = dict.fromkeys(
name.lower() for name in (*_RESPONSE_HEADER_ALLOWLIST, *contract_response_headers)
)
headers = {
name: value
for name in allowed_header_names
if name not in _RESPONSE_HEADER_DENYLIST
if (value := upstream.headers.get(name)) is not None
}
if upstream_result.response_kind == "stream":
response = Response(
stream_with_context( # pyrefly: ignore[no-matching-overload]
_stream_response_body(
upstream,
tenant_id=tenant_id,
max_response_bytes=max_response_bytes,
)
),
status=upstream.status_code,
headers=headers,
)
response.call_on_close(upstream.close)
return response
try:
content = upstream.content
finally:
upstream.close()
return Response(content, status=upstream.status_code, headers=headers)
def _proxy_current_request(
*,
method: KnowledgeFSMethod,
tenant_id: str,
forward: _KnowledgeFSRequestForwarder,
) -> Response:
"""Forward the current raw request through one preconfigured service entry."""
if not dify_config.KNOWLEDGE_FS_ENABLED:
raise NotFound()
try:
proxy_result = forward(
request.headers.get("Accept"),
request.content_type,
request.query_string or None,
_request_body() if method != "GET" else None,
)
except (
KnowledgeFSConfigurationError,
KnowledgeFSAccessDeniedError,
KnowledgeFSRouteNotAllowedError,
KnowledgeFSTimeoutError,
KnowledgeFSTransportError,
) as exc:
_translate_proxy_error(exc, tenant_id=tenant_id)
return _proxy_response(
proxy_result,
tenant_id=tenant_id,
contract_response_headers=proxy_result.operation.response_headers,
max_response_bytes=proxy_result.operation.max_response_bytes,
)
def _proxy_request(
method: KnowledgeFSMethod,
upstream_path: str,
) -> Response:
"""Authorize and forward the current request through the combined service use case."""
if not dify_config.KNOWLEDGE_FS_ENABLED:
raise NotFound()
current_user, tenant_id = current_account_with_tenant()
def forward(
accept: str | None,
content_type: str | None,
query: bytes | None,
body: bytes | None,
) -> KnowledgeFSUpstreamResponse:
return proxy_knowledge_fs_request(
account=current_user,
method=method,
path=upstream_path,
tenant_id=tenant_id,
accept=accept,
content_type=content_type,
query=query,
body=body,
request_headers=request.headers,
)
return _proxy_current_request(method=method, tenant_id=tenant_id, forward=forward)
def _proxy_authorized_request(authorization: KnowledgeFSAuthorization) -> Response:
"""Forward the current request using one previously authorized operation capability.
Args:
authorization: Request-scoped capability produced before billing and body parsing.
Returns:
The filtered response returned by KnowledgeFS.
Raises:
HTTPException: The integration is disabled or forwarding fails.
"""
operation = authorization.operation
tenant_id = authorization.tenant_id
def forward(
accept: str | None,
content_type: str | None,
query: bytes | None,
body: bytes | None,
) -> KnowledgeFSUpstreamResponse:
return proxy_authorized_knowledge_fs_request(
authorization=authorization,
accept=accept,
content_type=content_type,
query=query,
body=body,
request_headers=request.headers,
)
return _proxy_current_request(method=operation.method, tenant_id=tenant_id, forward=forward)
@_knowledge_fs_enabled
@_knowledge_fs_operation_access_required
@cloud_edition_billing_rate_limit_check("knowledge")
def _proxy_knowledge_fs_non_get(
authorization: KnowledgeFSAuthorization,
) -> ResponseReturnValue:
"""Apply knowledge billing checks to one allowlisted non-GET operation."""
return _proxy_authorized_request(authorization)
@bp.route(
"/knowledge-fs/<path:upstream_path>",
methods=["OPTIONS"],
provide_automatic_options=False,
)
@_console_api_errors
@_knowledge_fs_enabled
def proxy_knowledge_fs_options(upstream_path: str) -> ResponseReturnValue:
"""Complete a CORS preflight only for an enabled Console operation."""
requested_method = cast(KnowledgeFSMethod, request.headers.get("Access-Control-Request-Method", "").upper())
try:
get_knowledge_fs_operation(requested_method, upstream_path)
except KnowledgeFSRouteNotAllowedError as exc:
raise NotFound() from exc
return Response(status=HTTPStatus.NO_CONTENT)
@bp.route(
"/knowledge-fs/<path:upstream_path>",
methods=["GET"],
provide_automatic_options=False,
)
@_console_api_errors
@_knowledge_fs_enabled
@setup_required
@login_required
@account_initialization_required
def proxy_knowledge_fs_get(upstream_path: str) -> ResponseReturnValue:
"""Forward one authenticated, dataset-readable GET request.
Args:
upstream_path: Relative KFS path captured after the Console proxy prefix.
Returns:
The filtered raw KnowledgeFS response or a Console JSON error response.
"""
if request.method != "GET":
raise NotFound()
return _proxy_request("GET", upstream_path)
@bp.route(
"/knowledge-fs/<path:upstream_path>",
methods=["DELETE", "PATCH", "POST", "PUT"],
provide_automatic_options=False,
)
@_console_api_errors
@_knowledge_fs_enabled
@setup_required
@login_required
@account_initialization_required
def proxy_knowledge_fs_write(upstream_path: str) -> ResponseReturnValue:
"""Forward one authenticated non-GET request under its contract access policy.
Args:
upstream_path: Relative KFS path captured after the Console proxy prefix.
Returns:
The filtered raw KnowledgeFS response or a Console JSON error response.
"""
method = cast(KnowledgeFSMethod, request.method)
return _proxy_knowledge_fs_non_get(method, upstream_path)

View File

@@ -364,7 +364,13 @@ class RBACRoleItemApi(Resource):
def put(self, role_id):
tenant_id, account_id = _current_ids()
request = _payload(_RoleUpsertRequest)
role = svc.RBACService.Roles.update(tenant_id, account_id, str(role_id), request.to_mutation())
role = svc.RBACService.KnowledgeFSRoleMutations.update_role(
tenant_id,
account_id,
str(role_id),
request.to_mutation(),
session=db.session(),
)
return _dump(role)
@login_required
@@ -372,7 +378,12 @@ class RBACRoleItemApi(Resource):
@console_ns.response(200, "Success", console_ns.models[svc.RBACRole.__name__])
def delete(self, role_id):
tenant_id, account_id = _current_ids()
svc.RBACService.Roles.delete(tenant_id, account_id, str(role_id))
svc.RBACService.KnowledgeFSRoleMutations.delete_role(
tenant_id,
account_id,
str(role_id),
session=db.session(),
)
return {"result": "success"}
@@ -936,7 +947,7 @@ class RBACMemberRolesApi(Resource):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceMemberRolesRequest)
return _dump(
svc.RBACService.MemberRoles.replace(
svc.RBACService.KnowledgeFSRoleMutations.replace_member_roles(
tenant_id,
account_id,
str(member_id),

View File

@@ -18,12 +18,16 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat
from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .agent import files as _agent_files
from .agent import knowledge as _agent_knowledge
from .agent import llm as _agent_llm
from .agent import runtime_usage as _agent_runtime_usage
from .agent import tools as _agent_tools
from .app import dsl as _app_dsl
from .app import file_grants as _app_file_grants
from .knowledge import retrieval as _knowledge_retrieval
from .knowledge_fs import background as _knowledge_fs_background
from .knowledge_fs import storage as _knowledge_fs_storage
from .knowledge_fs import vectors as _knowledge_fs_vectors
from .plugin import agent_config as _agent_config
from .plugin import plugin as _plugin
from .plugin import skills as _skills
@@ -34,11 +38,15 @@ api.add_namespace(inner_api_ns)
__all__ = [
"_agent_config",
"_agent_files",
"_agent_knowledge",
"_agent_llm",
"_agent_runtime_usage",
"_agent_tools",
"_app_dsl",
"_app_file_grants",
"_knowledge_fs_background",
"_knowledge_fs_storage",
"_knowledge_fs_vectors",
"_knowledge_retrieval",
"_mail",
"_plugin",

View File

@@ -0,0 +1,102 @@
"""Private authorization bridge for run-scoped, read-only Agent knowledge CLI."""
from dify_agent.protocol.knowledge_fs import KnowledgeFsError, KnowledgeFsPreparedRequest, KnowledgeFsPrepareRequest
from dify_agent.protocol.knowledge_investigation import KnowledgeInvestigationPayload
from flask import request
from flask_restx import Resource
from pydantic import ValidationError
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import plugin_inner_api_only
from fields.base import ResponseModel
from services.agent.knowledge_gateway import AgentKnowledgeGateway
from services.agent_config_service import AgentConfigServiceError
from services.knowledge_fs.app_admission_service import KnowledgeFSAppAdmissionError
from services.knowledge_fs.product_authorization import KnowledgeFSProductNotFoundError
from services.knowledge_fs.product_remote import KnowledgeFSOperationUnavailableError
from services.knowledge_fs.query_images import KnowledgeFSQueryImageError
from services.knowledge_fs_capability import KnowledgeFSCapabilityConfigurationError, KnowledgeFSCapabilityPolicyError
register_schema_models(
inner_api_ns, KnowledgeFsPrepareRequest, KnowledgeFsPreparedRequest, KnowledgeInvestigationPayload
)
@inner_api_ns.route("/agent/knowledge/prepare")
class AgentKnowledgePrepareApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("inner_agent_knowledge_prepare")
@inner_api_ns.expect(inner_api_ns.models[KnowledgeFsPrepareRequest.__name__])
@inner_api_ns.response(200, "Private prepared command", inner_api_ns.models[KnowledgeFsPreparedRequest.__name__])
def post(self):
try:
payload = KnowledgeFsPrepareRequest.model_validate(request.get_json(silent=True) or {})
return AgentKnowledgeGateway().prepare(payload).model_dump(mode="json", exclude_none=True)
except ValidationError:
return {
"code": "KNOWLEDGE_INVALID_REQUEST",
"message": "Invalid knowledge command or execution context.",
}, 400
except KnowledgeFsError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except (
KnowledgeFSAppAdmissionError,
KnowledgeFSProductNotFoundError,
AgentConfigServiceError,
KnowledgeFSCapabilityPolicyError,
PermissionError,
):
return {"code": "KNOWLEDGE_ACCESS_DENIED", "message": "Knowledge access is unavailable or revoked."}, 403
except (KnowledgeFSOperationUnavailableError, KnowledgeFSCapabilityConfigurationError):
return {"code": "KNOWLEDGE_UNAVAILABLE", "message": "KnowledgeFS capability is unavailable."}, 503
except (KnowledgeFSQueryImageError, ValueError):
return {
"code": "KNOWLEDGE_INVALID_REQUEST",
"message": "Query image or knowledge reference is unavailable.",
}, 400
class KnowledgeInvestigationAcceptedResponse(ResponseModel):
accepted: bool
register_response_schema_models(inner_api_ns, KnowledgeInvestigationAcceptedResponse)
@inner_api_ns.route("/agent/knowledge/investigations")
class AgentKnowledgeInvestigationApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("inner_agent_knowledge_investigation")
@inner_api_ns.response(
202, "Investigation queued", inner_api_ns.models[KnowledgeInvestigationAcceptedResponse.__name__]
)
@inner_api_ns.expect(inner_api_ns.models[KnowledgeInvestigationPayload.__name__])
def post(self):
try:
payload = KnowledgeInvestigationPayload.model_validate(request.get_json(silent=True) or {})
AgentKnowledgeGateway().authorize_investigation(payload)
from tasks.knowledge_fs_agent_investigation_tasks import capture_agent_knowledge_investigation_task
# Per-space jobs allow one unavailable knowledge base to fail independently.
# An uncertain broker acknowledgement can be retried with the same investigation ID.
for space_id in sorted({str(a.control_space_id) for a in payload.attempts}):
capture_agent_knowledge_investigation_task.delay(
report=payload.model_dump(mode="json"),
control_space_id=space_id,
)
return KnowledgeInvestigationAcceptedResponse(accepted=True).model_dump(mode="json"), 202
except ValidationError:
return {"code": "KNOWLEDGE_INVALID_REQUEST", "message": "Invalid investigation."}, 400
except KnowledgeFsError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except (
AgentConfigServiceError,
KnowledgeFSAppAdmissionError,
KnowledgeFSCapabilityPolicyError,
PermissionError,
):
return {"code": "KNOWLEDGE_ACCESS_DENIED", "message": "Knowledge access is unavailable or revoked."}, 403

View File

@@ -0,0 +1 @@
"""Trusted KnowledgeFS inner API endpoints."""

View File

@@ -0,0 +1,50 @@
"""Authenticated broker publication only; never accepts a command or document body."""
from http import HTTPStatus
from flask import Response, request
from flask_restx import Resource
from pydantic import ValidationError
from werkzeug.exceptions import BadRequest, RequestEntityTooLarge, ServiceUnavailable
from controllers.common.schema import register_schema_models
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import knowledge_fs_inner_api_only
from services.knowledge_fs.background_contract import BackgroundJobPayload
from services.knowledge_fs.background_publisher import check_background_publisher_ready, publish_background_job
register_schema_models(inner_api_ns, BackgroundJobPayload)
@inner_api_ns.route("/knowledge-fs/background/jobs")
class KnowledgeFSBackgroundJobApi(Resource):
@inner_api_ns.expect(inner_api_ns.models[BackgroundJobPayload.__name__])
@inner_api_ns.response(202, "Accepted by the Celery broker")
@knowledge_fs_inner_api_only
def post(self) -> Response:
request.max_content_length = 16_384
if request.content_length is not None and request.content_length > 16_384:
raise RequestEntityTooLarge("KnowledgeFS background locator is too large")
try:
job = BackgroundJobPayload.model_validate(inner_api_ns.payload or {})
except ValidationError as error:
raise BadRequest("Invalid KnowledgeFS background locator") from error
try:
publish_background_job(job)
except ValueError as error:
raise BadRequest("Invalid KnowledgeFS background schedule") from error
except Exception as error:
# A failed/uncertain publish must leave the durable outbox eligible for redelivery.
raise ServiceUnavailable("KnowledgeFS Celery publication is unavailable") from error
return Response(status=HTTPStatus.ACCEPTED)
@inner_api_ns.route("/knowledge-fs/background/health")
class KnowledgeFSBackgroundHealthApi(Resource):
@knowledge_fs_inner_api_only
def get(self) -> Response:
try:
check_background_publisher_ready()
except Exception as error:
raise ServiceUnavailable("KnowledgeFS Celery publication is unavailable") from error
return Response(status=HTTPStatus.NO_CONTENT)

View File

@@ -0,0 +1,415 @@
"""Trusted KnowledgeFS gateway to Dify's configured object-storage backend."""
import json
from base64 import b64decode
from binascii import Error as BinasciiError
from http import HTTPStatus
from typing import NoReturn
from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from pydantic.alias_generators import to_camel
from controllers.common.schema import query_params_from_model, register_response_schema_models
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import knowledge_fs_inner_api_only
from fields.base import ResponseModel
from libs.exception import BaseHTTPException
from libs.helper import dump_response
from services.knowledge_fs.object_storage import (
KNOWLEDGE_FS_OBJECT_MAX_BYTES,
KnowledgeFSObjectList,
KnowledgeFSObjectMetadata,
KnowledgeFSObjectStorageChecksumError,
KnowledgeFSObjectStorageCorruptError,
KnowledgeFSObjectStorageError,
KnowledgeFSObjectStorageInvalidInputError,
KnowledgeFSObjectStorageService,
KnowledgeFSObjectStorageTooLargeError,
KnowledgeFSObjectStorageUnavailableError,
)
from services.knowledge_fs.query_images import KnowledgeFSQueryImageError, load_query_image
from services.knowledge_fs.remote_images import (
KnowledgeFSRemoteImageError,
load_remote_image,
)
_METADATA_HEADER = "X-Knowledge-FS-Metadata"
_CHECKSUM_HEADER = "X-Knowledge-FS-Checksum-Sha256"
_CONTENT_TYPE_HEADER = "X-Knowledge-FS-Content-Type"
_MAX_ENCODED_METADATA_BYTES = 128 * 1024
_metadata_adapter = TypeAdapter(dict[str, str])
class KnowledgeFSObjectStorageHttpError(BaseHTTPException):
"""Safe HTTP representation of a KnowledgeFS storage boundary error."""
error_code = "knowledge_fs_object_storage_failed"
description = "KnowledgeFS object storage request failed."
code = HTTPStatus.INTERNAL_SERVER_ERROR
def __init__(self, *, error_code: str, description: str, status_code: HTTPStatus) -> None:
self.error_code = error_code
self.description = description
self.code = status_code
super().__init__(description)
class _CamelCaseResponse(ResponseModel):
model_config = ConfigDict(alias_generator=to_camel)
class KnowledgeFSObjectQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(description="Logical KnowledgeFS object key")
class KnowledgeFSObjectListQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
prefix: str = Field(default="", description="Logical object-key prefix")
cursor: str | None = Field(default=None, description="Exclusive lexical key cursor")
limit: int = Field(default=100, ge=1, le=100, description="Maximum objects to return")
class KnowledgeFSObjectMetadataResponse(_CamelCaseResponse):
checksum_sha256_base64: str
content_type: str | None = None
key: str
metadata: dict[str, str]
size_bytes: int
class KnowledgeFSObjectListResponse(_CamelCaseResponse):
objects: list[KnowledgeFSObjectMetadataResponse]
next_cursor: str | None = None
class KnowledgeFSObjectHealthResponse(ResponseModel):
ok: bool
class KnowledgeFSQueryImageQuery(BaseModel):
model_config = ConfigDict(extra="forbid", alias_generator=to_camel, populate_by_name=True)
upload_file_id: str = Field(min_length=1)
tenant_id: str = Field(min_length=1)
subject_id: str = Field(min_length=1)
class KnowledgeFSRemoteImageQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
url: str = Field(min_length=1, max_length=8_192)
register_response_schema_models(
inner_api_ns,
KnowledgeFSObjectMetadataResponse,
KnowledgeFSObjectListResponse,
KnowledgeFSObjectHealthResponse,
)
@inner_api_ns.route("/knowledge-fs/query-image")
class KnowledgeFSQueryImageApi(Resource):
"""Resolve one actor-owned or workflow-granted Dify file for a bounded KFS query run."""
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSQueryImageQuery))
@inner_api_ns.produces(["image/gif", "image/jpeg", "image/png", "image/webp"])
@knowledge_fs_inner_api_only
def get(self) -> Response:
try:
query = KnowledgeFSQueryImageQuery.model_validate(request.args.to_dict(flat=True))
access_grant = request.headers.get("X-Knowledge-FS-Query-Image-Grant")
if access_grant is not None and (not access_grant or len(access_grant) > 2_048):
raise KnowledgeFSQueryImageError("QUERY_IMAGE_GRANT_INVALID", "Workflow query image grant is invalid")
account_id = None if access_grant else _query_image_account_id(query.subject_id)
image = load_query_image(
tenant_id=query.tenant_id,
subject_id=query.subject_id,
account_id=account_id,
access_grant=access_grant,
upload_file_id=query.upload_file_id,
)
except ValidationError as exc:
raise _invalid_request_error() from exc
except KnowledgeFSQueryImageError as exc:
status = (
HTTPStatus.NOT_FOUND
if exc.code == "QUERY_IMAGE_NOT_FOUND"
else HTTPStatus.REQUEST_ENTITY_TOO_LARGE
if exc.code in {"QUERY_IMAGE_TOO_LARGE", "QUERY_IMAGE_TOTAL_TOO_LARGE"}
else HTTPStatus.BAD_REQUEST
)
raise KnowledgeFSObjectStorageHttpError(
error_code=exc.code.lower(),
description=str(exc),
status_code=status,
) from exc
response = Response(image.body, content_type=image.mime_type)
response.content_length = image.byte_size
response.headers["X-Query-Image-Sha256"] = image.sha256
response.headers["X-Query-Image-Upload-File-Id"] = image.upload_file_id
return response
@inner_api_ns.route("/knowledge-fs/remote-image")
class KnowledgeFSRemoteImageApi(Resource):
"""Resolve one bounded document image through Dify's SSRF-protected network client."""
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSRemoteImageQuery))
@inner_api_ns.produces(["image/gif", "image/jpeg", "image/png", "image/webp"])
@knowledge_fs_inner_api_only
def get(self) -> Response:
try:
query = KnowledgeFSRemoteImageQuery.model_validate(request.args.to_dict(flat=True))
image = load_remote_image(query.url)
except ValidationError as exc:
raise _invalid_request_error() from exc
except KnowledgeFSRemoteImageError as exc:
raise _remote_image_http_error(exc) from exc
response = Response(image.body, content_type=image.mime_type)
response.content_length = image.byte_size
response.headers["X-Knowledge-FS-Remote-Image-Sha256"] = image.sha256
return response
@inner_api_ns.route("/knowledge-fs/storage/object")
class KnowledgeFSObjectApi(Resource):
"""Read, write, or delete one logical KnowledgeFS object."""
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectQuery))
@inner_api_ns.response(
HTTPStatus.OK,
"Object stored",
inner_api_ns.models[KnowledgeFSObjectMetadataResponse.__name__],
)
@knowledge_fs_inner_api_only
def put(self) -> dict[str, object]:
try:
query = KnowledgeFSObjectQuery.model_validate(request.args.to_dict(flat=True))
metadata = _decode_metadata_header(request.headers.get(_METADATA_HEADER))
body = request.stream.read(KNOWLEDGE_FS_OBJECT_MAX_BYTES + 1)
if len(body) > KNOWLEDGE_FS_OBJECT_MAX_BYTES:
raise KnowledgeFSObjectStorageTooLargeError(f"object exceeds max bytes {KNOWLEDGE_FS_OBJECT_MAX_BYTES}")
result = KnowledgeFSObjectStorageService().put_object(
body=body,
checksum_sha256_base64=request.headers.get(_CHECKSUM_HEADER),
content_type=request.headers.get(_CONTENT_TYPE_HEADER),
key=query.key,
metadata=metadata,
)
except ValidationError as exc:
raise _invalid_request_error() from exc
except KnowledgeFSObjectStorageError as exc:
_raise_http_error(exc)
return _metadata_response(result)
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectQuery))
@inner_api_ns.produces(["application/octet-stream"])
@knowledge_fs_inner_api_only
def get(self) -> Response:
try:
query = KnowledgeFSObjectQuery.model_validate(request.args.to_dict(flat=True))
service = KnowledgeFSObjectStorageService()
metadata = service.head_object(key=query.key)
if metadata is None:
raise _not_found_error()
body = service.load_stream(key=query.key)
if body is None:
raise _not_found_error()
except ValidationError as exc:
raise _invalid_request_error() from exc
except KnowledgeFSObjectStorageError as exc:
_raise_http_error(exc)
response = Response(
body,
content_type=metadata.content_type or "application/octet-stream",
)
response.content_length = metadata.size_bytes
response.headers[_CHECKSUM_HEADER] = metadata.checksum_sha256_base64
return response
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectQuery))
@inner_api_ns.response(HTTPStatus.NO_CONTENT, "Object deleted")
@knowledge_fs_inner_api_only
def delete(self) -> tuple[str, int]:
try:
query = KnowledgeFSObjectQuery.model_validate(request.args.to_dict(flat=True))
KnowledgeFSObjectStorageService().delete_object(key=query.key)
except ValidationError as exc:
raise _invalid_request_error() from exc
except KnowledgeFSObjectStorageError as exc:
_raise_http_error(exc)
return "", HTTPStatus.NO_CONTENT
@inner_api_ns.route("/knowledge-fs/storage/object/metadata")
class KnowledgeFSObjectMetadataApi(Resource):
"""Read portable metadata for one logical KnowledgeFS object."""
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectQuery))
@inner_api_ns.response(
HTTPStatus.OK,
"Object metadata",
inner_api_ns.models[KnowledgeFSObjectMetadataResponse.__name__],
)
@knowledge_fs_inner_api_only
def get(self) -> dict[str, object]:
try:
query = KnowledgeFSObjectQuery.model_validate(request.args.to_dict(flat=True))
result = KnowledgeFSObjectStorageService().head_object(key=query.key)
if result is None:
raise _not_found_error()
except ValidationError as exc:
raise _invalid_request_error() from exc
except KnowledgeFSObjectStorageError as exc:
_raise_http_error(exc)
return _metadata_response(result)
@inner_api_ns.route("/knowledge-fs/storage/objects")
class KnowledgeFSObjectListApi(Resource):
"""List logical KnowledgeFS objects with bounded keyset pagination."""
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectListQuery))
@inner_api_ns.response(
HTTPStatus.OK,
"Object page",
inner_api_ns.models[KnowledgeFSObjectListResponse.__name__],
)
@knowledge_fs_inner_api_only
def get(self) -> dict[str, object]:
try:
query = KnowledgeFSObjectListQuery.model_validate(request.args.to_dict(flat=True))
result = KnowledgeFSObjectStorageService().list_objects(
cursor=query.cursor,
limit=query.limit,
prefix=query.prefix,
)
except ValidationError as exc:
raise _invalid_request_error() from exc
except KnowledgeFSObjectStorageError as exc:
_raise_http_error(exc)
return _list_response(result)
@inner_api_ns.route("/knowledge-fs/storage/health")
class KnowledgeFSObjectHealthApi(Resource):
"""Report whether Dify storage satisfies KnowledgeFS portable requirements."""
@inner_api_ns.response(
HTTPStatus.OK,
"Storage available",
inner_api_ns.models[KnowledgeFSObjectHealthResponse.__name__],
)
@inner_api_ns.response(HTTPStatus.SERVICE_UNAVAILABLE, "Storage unavailable")
@knowledge_fs_inner_api_only
def get(self) -> dict[str, bool] | tuple[dict[str, bool], int]:
if KnowledgeFSObjectStorageService().health():
return {"ok": True}
return {"ok": False}, HTTPStatus.SERVICE_UNAVAILABLE
def _decode_metadata_header(value: str | None) -> dict[str, str]:
if value is None:
return {}
if len(value.encode()) > _MAX_ENCODED_METADATA_BYTES:
raise KnowledgeFSObjectStorageInvalidInputError("object metadata header is too large")
try:
padding = "=" * (-len(value) % 4)
decoded = b64decode(value + padding, altchars=b"-_", validate=True)
return _metadata_adapter.validate_json(decoded)
except (BinasciiError, UnicodeEncodeError, ValidationError, json.JSONDecodeError) as exc:
raise KnowledgeFSObjectStorageInvalidInputError("object metadata header is invalid") from exc
def _query_image_account_id(subject_id: str) -> str:
prefix = "dify-account:"
account_id = subject_id[len(prefix) :].strip() if subject_id.startswith(prefix) else subject_id.strip()
if not account_id:
raise KnowledgeFSQueryImageError("QUERY_IMAGE_SUBJECT_INVALID", "Query image subject is invalid")
return account_id
def _metadata_response(metadata: KnowledgeFSObjectMetadata) -> dict[str, object]:
return dump_response(KnowledgeFSObjectMetadataResponse, metadata)
def _list_response(result: KnowledgeFSObjectList) -> dict[str, object]:
return dump_response(KnowledgeFSObjectListResponse, result)
def _raise_http_error(error: KnowledgeFSObjectStorageError) -> NoReturn:
if isinstance(error, KnowledgeFSObjectStorageTooLargeError):
raise KnowledgeFSObjectStorageHttpError(
error_code="knowledge_fs_object_too_large",
description="KnowledgeFS object exceeds the configured size limit.",
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
) from error
if isinstance(error, KnowledgeFSObjectStorageChecksumError):
raise KnowledgeFSObjectStorageHttpError(
error_code="knowledge_fs_object_checksum_mismatch",
description="KnowledgeFS object checksum does not match the request body.",
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
) from error
if isinstance(error, KnowledgeFSObjectStorageInvalidInputError):
raise _invalid_request_error() from error
if isinstance(error, KnowledgeFSObjectStorageCorruptError):
raise KnowledgeFSObjectStorageHttpError(
error_code="knowledge_fs_object_corrupt",
description="KnowledgeFS object metadata is inconsistent.",
status_code=HTTPStatus.BAD_GATEWAY,
) from error
if isinstance(error, KnowledgeFSObjectStorageUnavailableError):
raise KnowledgeFSObjectStorageHttpError(
error_code="knowledge_fs_object_storage_unavailable",
description="Dify object storage is unavailable for KnowledgeFS.",
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
) from error
raise KnowledgeFSObjectStorageHttpError(
error_code="knowledge_fs_object_storage_failed",
description="KnowledgeFS object storage request failed.",
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
) from error
def _invalid_request_error() -> KnowledgeFSObjectStorageHttpError:
return KnowledgeFSObjectStorageHttpError(
error_code="knowledge_fs_object_storage_invalid_request",
description="KnowledgeFS object storage request is invalid.",
status_code=HTTPStatus.BAD_REQUEST,
)
def _not_found_error() -> KnowledgeFSObjectStorageHttpError:
return KnowledgeFSObjectStorageHttpError(
error_code="knowledge_fs_object_not_found",
description="KnowledgeFS object was not found.",
status_code=HTTPStatus.NOT_FOUND,
)
def _remote_image_http_error(error: KnowledgeFSRemoteImageError) -> KnowledgeFSObjectStorageHttpError:
status_by_code = {
"REMOTE_IMAGE_BLOCKED": HTTPStatus.FORBIDDEN,
"REMOTE_IMAGE_CONTENT_UNSUPPORTED": HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
"REMOTE_IMAGE_EMPTY": HTTPStatus.UNPROCESSABLE_ENTITY,
"REMOTE_IMAGE_NOT_FOUND": HTTPStatus.NOT_FOUND,
"REMOTE_IMAGE_RATE_LIMITED": HTTPStatus.TOO_MANY_REQUESTS,
"REMOTE_IMAGE_REQUEST_REJECTED": HTTPStatus.UNPROCESSABLE_ENTITY,
"REMOTE_IMAGE_TOO_LARGE": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
"REMOTE_IMAGE_UPSTREAM_UNAVAILABLE": HTTPStatus.BAD_GATEWAY,
"REMOTE_IMAGE_URL_INVALID": HTTPStatus.BAD_REQUEST,
}
return KnowledgeFSObjectStorageHttpError(
error_code=error.code.lower(),
description=str(error),
status_code=status_by_code.get(error.code, HTTPStatus.BAD_GATEWAY),
)

View File

@@ -0,0 +1,62 @@
"""Authenticated vector bridge; only the server's configured backend is reachable."""
from flask import request
from flask_restx import Resource
from pydantic import ValidationError
from werkzeug.exceptions import BadRequest, Forbidden, ServiceUnavailable
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import knowledge_fs_inner_api_only
from fields.base import ResponseModel
from libs.helper import dump_response
from services.knowledge_fs.vector_store import (
VectorPoint,
VectorRequest,
admit_vector_request,
configured_vector_client,
execute_vector_request,
)
from services.tidb_binding_service import TidbBindingPendingError
from services.vector_space_admission_service import VectorSpaceAdmissionError
class VectorMatchResponse(ResponseModel):
id: str
score: float
class VectorResponse(ResponseModel):
points: list[VectorPoint]
matches: list[VectorMatchResponse]
register_schema_models(inner_api_ns, VectorRequest)
register_response_schema_models(inner_api_ns, VectorResponse)
@inner_api_ns.route("/knowledge-fs/vectors")
class KnowledgeFSVectorApi(Resource):
@inner_api_ns.expect(inner_api_ns.models[VectorRequest.__name__])
@inner_api_ns.response(200, "Vector operation completed", inner_api_ns.models[VectorResponse.__name__])
@knowledge_fs_inner_api_only
def post(self) -> dict[str, object]:
request.max_content_length = 32 * 1024 * 1024
try:
payload = VectorRequest.model_validate(inner_api_ns.payload or {})
except ValidationError as error:
raise BadRequest("Invalid KnowledgeFS vector operation") from error
try:
admit_vector_request(payload)
with configured_vector_client(
payload.scope.tenant_id, allow_create=payload.operation == "upsert"
) as client:
result = execute_vector_request(client, payload, check_admission=False)
except TidbBindingPendingError:
raise ServiceUnavailable("KnowledgeFS vector backend is being provisioned", retry_after=5) from None
except VectorSpaceAdmissionError as error:
raise Forbidden(str(error)) from None
except Exception:
# Native SDK exceptions can contain endpoint credentials or vectors.
raise ServiceUnavailable("KnowledgeFS vector backend is unavailable") from None
return dump_response(VectorResponse, result)

View File

@@ -1,3 +1,5 @@
import re
from flask_restx import Resource
from sqlalchemy.orm import Session
@@ -9,6 +11,7 @@ from controllers.inner_api.plugin.wraps import get_user_tenant, plugin_data
from controllers.inner_api.wraps import plugin_inner_api_only
from core.plugin.backwards_invocation.app import PluginAppBackwardsInvocation
from core.plugin.backwards_invocation.base import BaseBackwardsInvocationResponse
from core.plugin.backwards_invocation.datasource import PluginDatasourceBackwardsInvocation
from core.plugin.backwards_invocation.encrypt import PluginEncrypter
from core.plugin.backwards_invocation.model import PluginModelBackwardsInvocation
from core.plugin.backwards_invocation.node import PluginNodeBackwardsInvocation
@@ -16,10 +19,12 @@ from core.plugin.backwards_invocation.tool import PluginToolBackwardsInvocation
from core.plugin.entities.request import (
RequestFetchAppInfo,
RequestInvokeApp,
RequestInvokeDatasource,
RequestInvokeEncrypt,
RequestInvokeLLM,
RequestInvokeLLMWithStructuredOutput,
RequestInvokeModeration,
RequestInvokeMultimodalEmbedding,
RequestInvokeParameterExtractorNode,
RequestInvokeQuestionClassifierNode,
RequestInvokeRerank,
@@ -28,6 +33,7 @@ from core.plugin.entities.request import (
RequestInvokeTextEmbedding,
RequestInvokeTool,
RequestInvokeTTS,
RequestListModels,
RequestRequestDownloadFile,
RequestRequestUploadFile,
)
@@ -35,6 +41,11 @@ from core.tools.entities.tool_entities import ToolProviderType
from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import (
InvokeConnectionError,
InvokeRateLimitError,
InvokeServerUnavailableError,
)
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import length_prefixed_response
from models import Account, Tenant
@@ -42,6 +53,48 @@ from models.model import EndUser
from services.file_request_service import FileRequestService
def _model_invocation_error_response(error: Exception) -> dict[str, object]:
"""Return a backwards-compatible envelope with safe retry classification."""
message = str(error)
normalized = message.lower()
if (
isinstance(error, InvokeRateLimitError)
or "resource_exhausted" in normalized
or "rate limit" in normalized
or re.search(r"\b429\b", normalized)
):
error_code = "rate_limited"
retryable = True
status = 429
elif "timed out" in normalized or "timeout" in normalized:
error_code = "timeout"
retryable = True
status = 504
elif (
isinstance(error, InvokeConnectionError | InvokeServerUnavailableError)
or "connection error" in normalized
or "temporarily unavailable" in normalized
):
error_code = "unavailable"
retryable = True
status = 503
else:
error_code = "invocation_failed"
retryable = False
status = None
response: dict[str, object] = {
"data": None,
"error": message,
"error_code": error_code,
"retryable": retryable,
}
if status is not None:
response["status"] = status
return response
@inner_api_ns.route("/invoke/llm")
class PluginInvokeLLMApi(Resource):
@get_user_tenant
@@ -116,6 +169,36 @@ class PluginInvokeTextEmbeddingApi(Resource):
)
)
)
except Exception as e:
return _model_invocation_error_response(e)
@inner_api_ns.route("/invoke/multimodal-embedding")
class PluginInvokeMultimodalEmbeddingApi(Resource):
@get_user_tenant
@setup_required
@plugin_inner_api_only
@plugin_data(payload_type=RequestInvokeMultimodalEmbedding)
@inner_api_ns.doc("plugin_invoke_multimodal_embedding")
@inner_api_ns.doc(description="Invoke multimodal embedding models through Dify model management")
@inner_api_ns.doc(
responses={
200: "Multimodal embedding successful",
401: "Unauthorized - invalid API key",
404: "Service not available",
}
)
def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestInvokeMultimodalEmbedding):
try:
return jsonable_encoder(
BaseBackwardsInvocationResponse(
data=PluginModelBackwardsInvocation.invoke_multimodal_embedding(
user_id=user_model.id,
tenant=tenant_model,
payload=payload,
)
)
)
except Exception as e:
return jsonable_encoder(BaseBackwardsInvocationResponse(error=str(e)))
@@ -146,6 +229,70 @@ class PluginInvokeRerankApi(Resource):
return jsonable_encoder(BaseBackwardsInvocationResponse(error=str(e)))
@inner_api_ns.route("/invoke/model-catalog")
class PluginModelCatalogApi(Resource):
@get_user_tenant
@setup_required
@plugin_inner_api_only
@plugin_data(payload_type=RequestListModels)
@inner_api_ns.doc("plugin_model_catalog")
@inner_api_ns.doc(description="List tenant-active models managed by Dify")
@inner_api_ns.doc(
responses={
200: "Model catalog lookup successful",
401: "Unauthorized - invalid API key",
404: "Service not available",
}
)
def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestListModels):
try:
return jsonable_encoder(
BaseBackwardsInvocationResponse(
data=PluginModelBackwardsInvocation.list_models(
tenant_id=tenant_model.id,
user_id=user_model.id,
payload=payload,
)
)
)
except Exception as e:
return jsonable_encoder(BaseBackwardsInvocationResponse(error=str(e)))
@inner_api_ns.route("/invoke/datasource")
class PluginInvokeDatasourceApi(Resource):
"""Invoke an installed datasource with credentials resolved inside Dify."""
@get_user_tenant
@setup_required
@plugin_inner_api_only
@plugin_data(payload_type=RequestInvokeDatasource)
@inner_api_ns.doc("plugin_invoke_datasource")
@inner_api_ns.doc(description="Invoke datasource plugins through Dify credential management")
@inner_api_ns.doc(
responses={
200: "Datasource invocation successful (streaming response)",
401: "Unauthorized - invalid API key",
404: "Datasource provider, datasource, or credential not found",
}
)
def post(
self,
user_model: Account | EndUser,
tenant_model: Tenant,
payload: RequestInvokeDatasource,
):
response = PluginDatasourceBackwardsInvocation.invoke(
user_id=user_model.id,
tenant=tenant_model,
payload=payload,
)
return length_prefixed_response(
0xF,
PluginDatasourceBackwardsInvocation.convert_to_event_stream(response),
)
@inner_api_ns.route("/invoke/tts")
class PluginInvokeTTSApi(Resource):
@get_user_tenant

View File

@@ -1,5 +1,6 @@
from collections.abc import Callable
from functools import wraps
from uuid import UUID
from flask import current_app, request
from flask_login import user_logged_in
@@ -55,14 +56,15 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
# session_id, id is auto-generated) and a fresh EndUser
# was created per call, breaking multi-turn chat
# continuation (see #36736).
user_model = session.scalar(
select(EndUser)
.where(
EndUser.id == user_id,
EndUser.tenant_id == tenant_id,
if _is_uuid(user_id):
user_model = session.scalar(
select(EndUser)
.where(
EndUser.id == user_id,
EndUser.tenant_id == tenant_id,
)
.limit(1)
)
.limit(1)
)
if user_model is None:
user_model = session.scalar(
select(EndUser)
@@ -90,6 +92,14 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
return user_model
def _is_uuid(value: str) -> bool:
try:
UUID(value)
except ValueError:
return False
return True
def get_user_tenant[**P, R](view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:

View File

@@ -103,3 +103,9 @@ def agent_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""
return plugin_inner_api_only(view)
def knowledge_fs_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Authenticate the trusted KnowledgeFS process on the shared inner bridge."""
return plugin_inner_api_only(view)

View File

@@ -175,6 +175,7 @@ from . import (
files,
human_input_form,
index,
knowledge_fs,
oauth_device,
oauth_device_sso,
workflow_events,
@@ -192,6 +193,7 @@ __all__ = [
"files",
"human_input_form",
"index",
"knowledge_fs",
"oauth_device",
"oauth_device_sso",
"workflow_events",

View File

@@ -66,6 +66,13 @@ class OpenApiErrorCode(StrEnum):
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found"
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
KNOWLEDGE_FS_ACCESS_DENIED = "knowledge_fs_access_denied"
KNOWLEDGE_FS_CONFLICT = "knowledge_fs_conflict"
KNOWLEDGE_FS_INVALID_REQUEST = "knowledge_fs_invalid_request"
KNOWLEDGE_FS_REQUEST_REJECTED = "knowledge_fs_request_rejected"
KNOWLEDGE_FS_REQUEST_TOO_LARGE = "knowledge_fs_request_too_large"
KNOWLEDGE_FS_RESOURCE_NOT_FOUND = "knowledge_fs_resource_not_found"
KNOWLEDGE_FS_UNAVAILABLE = "knowledge_fs_unavailable"
CATALOG_STALE = "catalog_stale"
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
@@ -259,6 +266,48 @@ class RecipientSurfaceMismatch(OpenApiError): # noqa: N818
hint = "Action it through its channel (web app or console)."
class KnowledgeFsInvalidRequestError(OpenApiError):
code = 400
error_code = OpenApiErrorCode.KNOWLEDGE_FS_INVALID_REQUEST
description = "The Agent Knowledge Base request is invalid."
class KnowledgeFsAccessDeniedError(OpenApiError):
code = 403
error_code = OpenApiErrorCode.KNOWLEDGE_FS_ACCESS_DENIED
description = "Access to this Agent Knowledge Base is denied."
class KnowledgeFsResourceNotFoundError(OpenApiError):
code = 404
error_code = OpenApiErrorCode.KNOWLEDGE_FS_RESOURCE_NOT_FOUND
description = "The Agent Knowledge Base or entry was not found."
class KnowledgeFsConflictError(OpenApiError):
code = 409
error_code = OpenApiErrorCode.KNOWLEDGE_FS_CONFLICT
description = "The Agent Knowledge Base request conflicts with the current state."
class KnowledgeFsRequestTooLargeError(OpenApiError):
code = 413
error_code = OpenApiErrorCode.KNOWLEDGE_FS_REQUEST_TOO_LARGE
description = "The Agent Knowledge Base request exceeds the allowed size."
class KnowledgeFsRequestRejectedError(OpenApiError):
code = 422
error_code = OpenApiErrorCode.KNOWLEDGE_FS_REQUEST_REJECTED
description = "The Agent Knowledge Base request could not be processed."
class KnowledgeFsUnavailableError(OpenApiError):
code = 503
error_code = OpenApiErrorCode.KNOWLEDGE_FS_UNAVAILABLE
description = "Agent Knowledge Base is temporarily unavailable."
class CatalogStale(OpenApiError): # noqa: N818
code = 412
error_code = OpenApiErrorCode.CATALOG_STALE

View File

@@ -0,0 +1,922 @@
"""Command-oriented KnowledgeFS filesystem APIs consumed by difyctl.
Each difyctl filesystem command maps to an independent custom method under the
``fs`` resource. Public request and response models remain independent from the
internal KnowledgeFS product DTOs.
All operations are read-only, require an OAuth account bearer with workspace
read scope, and re-check the knowledge-space tenant boundary in the product
facade. Deliberately indistinguishable space/path misses share one 404 code so
callers cannot use this surface to enumerate hidden resources.
"""
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from http import HTTPStatus
from typing import Literal
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.openapi import openapi_ns
from controllers.openapi._contract import Example, Kind, endpoint
from controllers.openapi._errors import (
ErrorBody,
KnowledgeFsAccessDeniedError,
KnowledgeFsConflictError,
KnowledgeFsInvalidRequestError,
KnowledgeFsRequestRejectedError,
KnowledgeFsRequestTooLargeError,
KnowledgeFsResourceNotFoundError,
KnowledgeFsUnavailableError,
)
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.requirements import CheckScope, CheckSubject, CheckWorkspaceMember
from controllers.openapi.auth.subjects import AccountSubject
from core.db.session_factory import session_factory
from fields.base import ResponseModel
from libs.oauth_bearer import Scope
from services.knowledge_fs.data_facade import KnowledgeFSDataFacade
from services.knowledge_fs.product_authorization import KnowledgeFSProductNotFoundError
from services.knowledge_fs.product_dto import (
KNOWLEDGE_FS_PATH_PATTERN,
KnowledgeFSCatQuery,
KnowledgeFSCatResponse,
KnowledgeFSConsistencyClass,
KnowledgeFSDiffQuery,
KnowledgeFSDiffResponse,
KnowledgeFSFindQuery,
KnowledgeFSGrepQuery,
KnowledgeFSGrepResponse,
KnowledgeFSListQuery,
KnowledgeFSListResponse,
KnowledgeFSResourceType,
KnowledgeFSStatQuery,
KnowledgeFSStatResponse,
KnowledgeFSTreeQuery,
KnowledgeFSTreeResponse,
)
from services.knowledge_fs.product_dto import (
KnowledgeFSEntryResponse as ProductKnowledgeFSEntryResponse,
)
from services.knowledge_fs.product_dto import (
KnowledgeFSGrepMatchResponse as ProductKnowledgeFSGrepMatchResponse,
)
from services.knowledge_fs.product_dto import (
KnowledgeFSTreeNodeResponse as ProductKnowledgeFSTreeNodeResponse,
)
from services.knowledge_fs.product_remote import (
KnowledgeFSOperationUnavailableError,
KnowledgeFSProductRemoteError,
KnowledgeFSProductRequestRejectedError,
KnowledgeFSProductResourceNotFoundError,
)
from services.knowledge_fs.runtime import get_knowledge_fs_runtime
from services.knowledge_fs_capability import KnowledgeFSCapabilityConfigurationError
_PATH_DESCRIPTION = "Canonical Agent Knowledge Base virtual path under /sources, /knowledge, /evidence, or /workspaces."
_PAGE_SIZE_DESCRIPTION = "Maximum number of results to return (1-100)."
_PAGE_TOKEN_DESCRIPTION = (
"Opaque continuation token returned as next_page_token. Repeat the same query without inspecting the token."
)
_NEXT_PAGE_TOKEN_DESCRIPTION = "Opaque continuation token for the next page; null when no continuation is available."
_CONTENT_PAGE_SIZE_DESCRIPTION = (
"Maximum source segments to read (1-100); ignored when the selected entry has one bounded content value."
)
_CONSISTENCY_DESCRIPTION = "Optional Agent Knowledge Base read-consistency policy."
_OPEN_RESPONSE_VALUE_DESCRIPTION = (
"Open response value. Clients must tolerate unknown future values and fall back to generic display behavior."
)
_READ_SECURITY_DESCRIPTION = (
"Requires an OAuth account bearer with WORKSPACE_READ. Authentication and workspace scope are checked before "
"request validation, and Agent Knowledge Base membership is revalidated for every call. A hidden or missing "
"Agent Knowledge Base or entry uses the same 404 response. These operations are read-only and do not emit mutation "
"audit events."
)
_PAGINATION_DESCRIPTION = (
"Results use the canonical, stable Agent Knowledge Base traversal order. The opaque next_page_token captures that "
"order; reuse it with unchanged filters and consistency_class. total is intentionally omitted because tenant-aware "
"visibility scans are bounded and an exact count can require an unbounded scan."
)
_CONTENT_CONTINUATION_DESCRIPTION = (
"Content follows stable source order. When next_page_token is present, reuse it with the same path and "
"consistency_class. total is not returned because this response is a bounded content stream, not a collection."
)
_KNOWLEDGE_FS_ERROR_RESPONSES = {
400: "Invalid Agent Knowledge Base request",
401: "Missing or invalid OAuth account bearer",
403: "Caller lacks workspace or Agent Knowledge Base read access",
404: "Agent Knowledge Base or entry is missing or hidden",
409: "Requested consistency conflicts with current state",
413: "Request exceeds an Agent Knowledge Base operational bound",
422: "Request validation failed or the request was rejected",
503: "Agent Knowledge Base is temporarily unavailable",
}
class _KnowledgeFSEntryQuery(BaseModel):
path: str = Field(min_length=1, max_length=4_096, pattern=KNOWLEDGE_FS_PATH_PATTERN, description=_PATH_DESCRIPTION)
consistency_class: KnowledgeFSConsistencyClass | None = Field(default=None, description=_CONSISTENCY_DESCRIPTION)
model_config = ConfigDict(extra="forbid")
class _KnowledgeFSPaginatedEntryQuery(_KnowledgeFSEntryQuery):
page_size: int = Field(default=20, ge=1, le=100, description=_PAGE_SIZE_DESCRIPTION)
page_token: str | None = Field(default=None, min_length=1, max_length=8_192, description=_PAGE_TOKEN_DESCRIPTION)
class KnowledgeFSEntryListQuery(_KnowledgeFSPaginatedEntryQuery):
"""List direct children in stable Agent Knowledge Base traversal order."""
class KnowledgeFSEntryTreeQuery(_KnowledgeFSPaginatedEntryQuery):
depth: int | None = Field(default=None, ge=1, le=8, description="Maximum tree depth (1-8).")
class KnowledgeFSEntryContentSearchQuery(_KnowledgeFSPaginatedEntryQuery):
text: str = Field(min_length=1, max_length=4_000, description="Text to find in readable entry content.")
timeout_ms: int | None = Field(
default=None,
ge=1,
le=10_000,
description="Optional search time budget in milliseconds (1-10000).",
)
@field_validator("text", mode="before")
@classmethod
def normalize_text(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
class KnowledgeFSEntrySearchQuery(_KnowledgeFSPaginatedEntryQuery):
metadata_key: str | None = Field(
default=None,
min_length=1,
max_length=120,
description="Exact metadata key; metadata_value must be supplied with it.",
)
metadata_value: str | None = Field(
default=None,
min_length=1,
max_length=4_000,
description="Exact metadata value; metadata_key must be supplied with it.",
)
name_contains: str | None = Field(default=None, min_length=1, max_length=240)
resource_type: KnowledgeFSResourceType | None = None
@model_validator(mode="after")
def validate_metadata_pair(self) -> KnowledgeFSEntrySearchQuery:
if (self.metadata_key is None) != (self.metadata_value is None):
raise ValueError("metadata_key and metadata_value must be supplied together")
return self
class KnowledgeFSEntryReadContentQuery(_KnowledgeFSEntryQuery):
page_size: int = Field(default=100, ge=1, le=100, description=_CONTENT_PAGE_SIZE_DESCRIPTION)
page_token: str | None = Field(default=None, min_length=1, max_length=8_192, description=_PAGE_TOKEN_DESCRIPTION)
class KnowledgeFSEntryInspectQuery(_KnowledgeFSEntryQuery):
pass
class KnowledgeFSEntryComparePayload(BaseModel):
old_path: str = Field(
min_length=1,
max_length=4_096,
pattern=KNOWLEDGE_FS_PATH_PATTERN,
description="Canonical path of the baseline entry.",
)
new_path: str = Field(
min_length=1,
max_length=4_096,
pattern=KNOWLEDGE_FS_PATH_PATTERN,
description="Canonical path of the entry to compare.",
)
mode: Literal["line", "word"] | None = Field(default=None, description="Comparison granularity.")
include_semantic_summary: bool = Field(
default=False,
description="Whether to generate a bounded semantic change summary. This can consume model quota.",
)
consistency_class: KnowledgeFSConsistencyClass | None = Field(default=None, description=_CONSISTENCY_DESCRIPTION)
model_config = ConfigDict(extra="forbid")
class KnowledgeFSEntryResponse(ResponseModel):
kind: Literal["directory", "resource"]
metadata: dict[str, JsonValue]
name: str
path: str
resource_type: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
target_id: str | None = None
version: int | None = Field(default=None, ge=1)
@classmethod
def from_product_entry(cls, response: ProductKnowledgeFSEntryResponse) -> KnowledgeFSEntryResponse:
return cls.model_validate(response.model_dump(mode="python"))
class KnowledgeFSEntryListResponse(ResponseModel):
consistency_class: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
data: list[KnowledgeFSEntryResponse]
has_more: bool
next_page_token: str | None = Field(default=None, description=_NEXT_PAGE_TOKEN_DESCRIPTION)
path: str
preview: bool | None = None
truncated: bool = Field(
description="Whether operational bounds made the result incomplete, even when no continuation is available."
)
@classmethod
def from_product(cls, response: KnowledgeFSListResponse) -> KnowledgeFSEntryListResponse:
return cls(
consistency_class=response.consistency_class,
data=[KnowledgeFSEntryResponse.from_product_entry(item) for item in response.items],
has_more=response.next_cursor is not None,
next_page_token=response.next_cursor,
path=response.path,
preview=response.preview,
truncated=response.truncated,
)
class KnowledgeFSEntryTreeNodeResponse(KnowledgeFSEntryResponse):
children: list[KnowledgeFSEntryTreeNodeResponse] | None = None
@classmethod
def from_product_tree_node(cls, response: ProductKnowledgeFSTreeNodeResponse) -> KnowledgeFSEntryTreeNodeResponse:
return cls.model_validate(
{
**response.model_dump(mode="python", exclude={"children"}),
"children": (
None
if response.children is None
else [KnowledgeFSEntryTreeNodeResponse.from_product_tree_node(child) for child in response.children]
),
}
)
class KnowledgeFSEntryTreeResponse(ResponseModel):
consistency_class: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
has_more: bool
next_page_token: str | None = Field(default=None, description=_NEXT_PAGE_TOKEN_DESCRIPTION)
path: str
preview: bool | None = None
root: KnowledgeFSEntryTreeNodeResponse
truncated: bool = Field(
description="Whether operational bounds made the result incomplete, even when no continuation is available."
)
@classmethod
def from_product(cls, response: KnowledgeFSTreeResponse) -> KnowledgeFSEntryTreeResponse:
return cls(
consistency_class=response.consistency_class,
has_more=response.next_cursor is not None,
next_page_token=response.next_cursor,
path=response.path,
preview=response.preview,
root=KnowledgeFSEntryTreeNodeResponse.from_product_tree_node(response.root),
truncated=response.truncated,
)
class KnowledgeFSEntryContentMatchResponse(ResponseModel):
end_offset: int = Field(ge=0)
kind: Literal["node", "segment"]
metadata: dict[str, JsonValue]
node_id: str | None = None
path: str
segment_id: str | None = None
snippet: str
start_offset: int = Field(ge=0)
@classmethod
def from_product(cls, response: ProductKnowledgeFSGrepMatchResponse) -> KnowledgeFSEntryContentMatchResponse:
return cls.model_validate(response.model_dump(mode="python"))
class KnowledgeFSEntryContentSearchResponse(ResponseModel):
data: list[KnowledgeFSEntryContentMatchResponse]
has_more: bool
next_page_token: str | None = Field(default=None, description=_NEXT_PAGE_TOKEN_DESCRIPTION)
path: str
truncated: bool = Field(
description="Whether operational bounds made the result incomplete, even when no continuation is available."
)
@classmethod
def from_product(cls, response: KnowledgeFSGrepResponse) -> KnowledgeFSEntryContentSearchResponse:
return cls(
data=[KnowledgeFSEntryContentMatchResponse.from_product(match) for match in response.matches],
has_more=response.next_cursor is not None,
next_page_token=response.next_cursor,
path=response.path,
truncated=response.truncated,
)
class KnowledgeFSEntryReadContentResponse(ResponseModel):
content_type: str
has_more: bool
next_page_token: str | None = Field(default=None, description=_NEXT_PAGE_TOKEN_DESCRIPTION)
path: str
text: str
truncated: bool = Field(
description="Whether operational bounds made the content incomplete, even when no continuation is available."
)
@classmethod
def from_product(cls, response: KnowledgeFSCatResponse) -> KnowledgeFSEntryReadContentResponse:
return cls(
content_type=response.content_type,
has_more=response.next_cursor is not None,
next_page_token=response.next_cursor,
path=response.path,
text=response.text,
truncated=response.truncated,
)
class KnowledgeFSEntryComparisonOperationResponse(ResponseModel):
kind: Literal["equal", "insert", "delete"]
new_end: int | None = Field(default=None, ge=1)
new_start: int | None = Field(default=None, ge=1)
old_end: int | None = Field(default=None, ge=1)
old_start: int | None = Field(default=None, ge=1)
text: str
class KnowledgeFSEntryComparisonStatsResponse(ResponseModel):
delete: int = Field(ge=0)
equal: int = Field(ge=0)
insert: int = Field(ge=0)
class KnowledgeFSEntrySemanticChangeResponse(ResponseModel):
category: str
evidence: list[str]
summary: str
class KnowledgeFSEntrySemanticSummaryResponse(ResponseModel):
changes: list[KnowledgeFSEntrySemanticChangeResponse]
metadata: dict[str, JsonValue]
model: str | None = None
summary: str
class KnowledgeFSEntryComparisonResponse(ResponseModel):
mode: Literal["line", "word"]
new_path: str
old_path: str
operations: list[KnowledgeFSEntryComparisonOperationResponse]
semantic: KnowledgeFSEntrySemanticSummaryResponse | None = None
stats: KnowledgeFSEntryComparisonStatsResponse
@classmethod
def from_product(cls, response: KnowledgeFSDiffResponse) -> KnowledgeFSEntryComparisonResponse:
return cls.model_validate(response.model_dump(mode="python"))
class KnowledgeFSEntryMetadataResponse(ResponseModel):
consistency_class: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
content_type: str | None = None
metadata: dict[str, JsonValue]
parser_status: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
path: str
preview: bool | None = None
resource_type: str = Field(description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
sha256: str | None = None
size_bytes: int | None = Field(default=None, ge=0)
target_id: str
version: int | None = Field(default=None, ge=1)
@classmethod
def from_product(cls, response: KnowledgeFSStatResponse) -> KnowledgeFSEntryMetadataResponse:
return cls.model_validate(response.model_dump(mode="python"))
register_schema_models(
openapi_ns,
KnowledgeFSEntryListQuery,
KnowledgeFSEntryTreeQuery,
KnowledgeFSEntryContentSearchQuery,
KnowledgeFSEntrySearchQuery,
KnowledgeFSEntryReadContentQuery,
KnowledgeFSEntryInspectQuery,
KnowledgeFSEntryComparePayload,
)
register_response_schema_models(
openapi_ns,
KnowledgeFSEntryListResponse,
KnowledgeFSEntryTreeResponse,
KnowledgeFSEntryContentSearchResponse,
KnowledgeFSEntryReadContentResponse,
KnowledgeFSEntryComparisonResponse,
KnowledgeFSEntryMetadataResponse,
)
def _knowledge_fs_facade() -> KnowledgeFSDataFacade:
return get_knowledge_fs_runtime(session_factory.get_session_maker()).facade
def _knowledge_fs_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 (KnowledgeFSProductNotFoundError, KnowledgeFSProductResourceNotFoundError) as exc:
raise KnowledgeFsResourceNotFoundError() from exc
except KnowledgeFSProductRequestRejectedError as exc:
if exc.status_code == HTTPStatus.BAD_REQUEST:
raise KnowledgeFsInvalidRequestError() from exc
if exc.status_code == HTTPStatus.CONFLICT:
raise KnowledgeFsConflictError() from exc
if exc.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
raise KnowledgeFsRequestTooLargeError() from exc
raise KnowledgeFsRequestRejectedError() from exc
except PermissionError as exc:
raise KnowledgeFsAccessDeniedError() from exc
except (
KnowledgeFSCapabilityConfigurationError,
KnowledgeFSOperationUnavailableError,
KnowledgeFSProductRemoteError,
) as exc:
raise KnowledgeFsUnavailableError() from exc
return decorated
def _account_id(ctx: Context) -> str:
return str(ctx.subject.account_id)
def _facade_args(workspace_id: str, knowledge_space_id: str, ctx: Context) -> dict[str, str]:
# The OpenAPI resource ID is resolved through Dify's control-plane record.
# Keeping that implementation name at this adapter boundary prevents it from
# leaking into the external path and leaves the backing lookup replaceable.
return {
"tenant_id": workspace_id,
"account_id": _account_id(ctx),
"control_space_id": knowledge_space_id,
}
def _list_product_query(query: KnowledgeFSEntryListQuery) -> KnowledgeFSListQuery:
return KnowledgeFSListQuery(
path=query.path,
limit=query.page_size,
cursor=query.page_token,
consistency_class=query.consistency_class,
)
def _tree_product_query(query: KnowledgeFSEntryTreeQuery) -> KnowledgeFSTreeQuery:
return KnowledgeFSTreeQuery(
path=query.path,
limit=query.page_size,
cursor=query.page_token,
depth=query.depth,
consistency_class=query.consistency_class,
)
def _content_search_product_query(query: KnowledgeFSEntryContentSearchQuery) -> KnowledgeFSGrepQuery:
return KnowledgeFSGrepQuery(
path=query.path,
query=query.text,
limit=query.page_size,
cursor=query.page_token,
timeout_ms=query.timeout_ms,
consistency_class=query.consistency_class,
)
def _search_product_query(query: KnowledgeFSEntrySearchQuery) -> KnowledgeFSFindQuery:
return KnowledgeFSFindQuery(
path=query.path,
limit=query.page_size,
cursor=query.page_token,
metadata_key=query.metadata_key,
metadata_value=query.metadata_value,
name_contains=query.name_contains,
resource_type=query.resource_type,
consistency_class=query.consistency_class,
)
def _compare_product_query(body: KnowledgeFSEntryComparePayload) -> KnowledgeFSDiffQuery:
return KnowledgeFSDiffQuery(
old_path=body.old_path,
new_path=body.new_path,
mode=body.mode,
semantic=body.include_semantic_summary,
consistency_class=body.consistency_class,
)
def _read_content_product_query(query: KnowledgeFSEntryReadContentQuery) -> KnowledgeFSCatQuery:
return KnowledgeFSCatQuery(
path=query.path,
limit=query.page_size,
cursor=query.page_token,
consistency_class=query.consistency_class,
)
def _inspect_product_query(query: KnowledgeFSEntryInspectQuery) -> KnowledgeFSStatQuery:
return KnowledgeFSStatQuery(path=query.path, consistency_class=query.consistency_class)
_FS_ROUTE = "/workspaces/<string:workspace_id>/knowledge-fs/knowledge-spaces/<string:knowledge_space_id>/fs"
def _knowledge_fs_operation[**P, R](
operation_id: str,
*,
summary: str,
description: str,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Attach the stable operation identity and complete non-2xx contract."""
def decorator(view: Callable[P, R]) -> Callable[P, R]:
documented = openapi_ns.doc(
operation_id,
summary=summary,
description=f"{description} {_READ_SECURITY_DESCRIPTION}",
params={
"knowledge_space_id": "Stable Dify Agent Knowledge Base resource ID; treat it as opaque.",
"workspace_id": "Dify workspace ID that owns the Agent Knowledge Base.",
},
)(view)
for status_code, response_description in _KNOWLEDGE_FS_ERROR_RESPONSES.items():
documented = openapi_ns.response(
status_code,
response_description,
openapi_ns.models[ErrorBody.__name__],
)(documented)
return documented
return decorator
@openapi_ns.route(f"{_FS_ROUTE}:ls")
class KnowledgeFsEntryListApi(Resource):
"""List direct child entries; default ordering is stable and encoded by the page token."""
@endpoint(
op="knowledge_fs.ls",
kind=Kind.OBJECT,
summary="List an Agent Knowledge Base directory (ls)",
examples=(
Example(
title="List a knowledge directory",
input={
"workspace_id": "<workspace_id>",
"knowledge_space_id": "<knowledge_space_id>",
"path": "/knowledge",
},
),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
query=KnowledgeFSEntryListQuery,
returns=(200, KnowledgeFSEntryListResponse, "Agent Knowledge Base entry page"),
)
@_knowledge_fs_operation(
"ls_knowledge_fs",
summary="List an Agent Knowledge Base directory (ls)",
description=(f"Lists direct child entries under path, equivalent to difyctl fs ls. {_PAGINATION_DESCRIPTION}"),
)
@_knowledge_fs_errors
def get(
self,
workspace_id: str,
knowledge_space_id: str,
*,
ctx: Context,
query: KnowledgeFSEntryListQuery,
) -> KnowledgeFSEntryListResponse:
response = _knowledge_fs_facade().list_knowledge_fs(
**_facade_args(workspace_id, knowledge_space_id, ctx),
query=_list_product_query(query),
)
return KnowledgeFSEntryListResponse.from_product(response)
@openapi_ns.route(f"{_FS_ROUTE}:tree")
class KnowledgeFsEntryTreeApi(Resource):
@endpoint(
op="knowledge_fs.tree",
kind=Kind.OBJECT,
summary="Traverse an Agent Knowledge Base directory (tree)",
examples=(
Example(
title="Traverse a knowledge directory",
input={
"workspace_id": "<workspace_id>",
"knowledge_space_id": "<knowledge_space_id>",
"path": "/knowledge",
"depth": 2,
},
),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
query=KnowledgeFSEntryTreeQuery,
returns=(200, KnowledgeFSEntryTreeResponse, "Agent Knowledge Base entry tree"),
)
@_knowledge_fs_operation(
"tree_knowledge_fs",
summary="Traverse an Agent Knowledge Base directory (tree)",
description=(
f"Returns a depth- and page-size-bounded tree rooted at path, equivalent to difyctl fs tree. "
f"{_PAGINATION_DESCRIPTION}"
),
)
@_knowledge_fs_errors
def get(
self,
workspace_id: str,
knowledge_space_id: str,
*,
ctx: Context,
query: KnowledgeFSEntryTreeQuery,
) -> KnowledgeFSEntryTreeResponse:
response = _knowledge_fs_facade().tree_knowledge_fs(
**_facade_args(workspace_id, knowledge_space_id, ctx),
query=_tree_product_query(query),
)
return KnowledgeFSEntryTreeResponse.from_product(response)
@openapi_ns.route(f"{_FS_ROUTE}:grep")
class KnowledgeFsEntryContentSearchApi(Resource):
@endpoint(
op="knowledge_fs.grep",
kind=Kind.OBJECT,
summary="Search Agent Knowledge Base content (grep)",
examples=(
Example(
title="Search knowledge content",
input={
"workspace_id": "<workspace_id>",
"knowledge_space_id": "<knowledge_space_id>",
"path": "/knowledge",
"text": "deployment",
},
),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
query=KnowledgeFSEntryContentSearchQuery,
returns=(200, KnowledgeFSEntryContentSearchResponse, "Agent Knowledge Base content matches"),
)
@_knowledge_fs_operation(
"grep_knowledge_fs",
summary="Search Agent Knowledge Base content (grep)",
description=(
"Searches readable content beneath path, equivalent to difyctl fs grep. Matches follow canonical entry "
f"traversal order and source-offset order within each entry. {_PAGINATION_DESCRIPTION}"
),
)
@_knowledge_fs_errors
def get(
self,
workspace_id: str,
knowledge_space_id: str,
*,
ctx: Context,
query: KnowledgeFSEntryContentSearchQuery,
) -> KnowledgeFSEntryContentSearchResponse:
response = _knowledge_fs_facade().grep_knowledge_fs(
**_facade_args(workspace_id, knowledge_space_id, ctx),
query=_content_search_product_query(query),
)
return KnowledgeFSEntryContentSearchResponse.from_product(response)
@openapi_ns.route(f"{_FS_ROUTE}:find")
class KnowledgeFsEntrySearchApi(Resource):
@endpoint(
op="knowledge_fs.find",
kind=Kind.OBJECT,
summary="Find Agent Knowledge Base entries (find)",
examples=(
Example(
title="Find entries by name",
input={
"workspace_id": "<workspace_id>",
"knowledge_space_id": "<knowledge_space_id>",
"path": "/knowledge",
"name_contains": "guide",
},
),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
query=KnowledgeFSEntrySearchQuery,
returns=(200, KnowledgeFSEntryListResponse, "Agent Knowledge Base entry search results"),
)
@_knowledge_fs_operation(
"find_knowledge_fs",
summary="Find Agent Knowledge Base entries (find)",
description=(
"Searches entries beneath path by name, resource type, or an exact metadata key/value pair, equivalent "
"to difyctl fs find. "
f"{_PAGINATION_DESCRIPTION}"
),
)
@_knowledge_fs_errors
def get(
self,
workspace_id: str,
knowledge_space_id: str,
*,
ctx: Context,
query: KnowledgeFSEntrySearchQuery,
) -> KnowledgeFSEntryListResponse:
response = _knowledge_fs_facade().find_knowledge_fs(
**_facade_args(workspace_id, knowledge_space_id, ctx),
query=_search_product_query(query),
)
return KnowledgeFSEntryListResponse.from_product(response)
@openapi_ns.route(f"{_FS_ROUTE}:diff")
class KnowledgeFsEntryCompareApi(Resource):
"""Side-effect-free comparison query; semantic summaries can consume model quota on every retry."""
@endpoint(
op="knowledge_fs.diff",
kind=Kind.OBJECT,
summary="Compare two Agent Knowledge Base entries (diff)",
examples=(
Example(
title="Compare two knowledge entries",
input={
"workspace_id": "<workspace_id>",
"knowledge_space_id": "<knowledge_space_id>",
"old_path": "/knowledge/guide-v1",
"new_path": "/knowledge/guide-v2",
},
),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
body=KnowledgeFSEntryComparePayload,
returns=(200, KnowledgeFSEntryComparisonResponse, "Agent Knowledge Base entry comparison"),
)
@_knowledge_fs_operation(
"diff_knowledge_fs",
summary="Compare two Agent Knowledge Base entries (diff)",
description=(
"Performs a side-effect-free comparison, equivalent to difyctl fs diff. POST is used because this is a "
"structured query and an optional semantic summary can consume model quota. Automatic retries are not safe "
"when include_semantic_summary=true because each retry can consume quota again."
),
)
@_knowledge_fs_errors
def post(
self,
workspace_id: str,
knowledge_space_id: str,
*,
ctx: Context,
body: KnowledgeFSEntryComparePayload,
) -> KnowledgeFSEntryComparisonResponse:
response = _knowledge_fs_facade().diff_knowledge_fs(
**_facade_args(workspace_id, knowledge_space_id, ctx),
query=_compare_product_query(body),
)
return KnowledgeFSEntryComparisonResponse.from_product(response)
@openapi_ns.route(f"{_FS_ROUTE}:cat")
class KnowledgeFsEntryReadContentApi(Resource):
@endpoint(
op="knowledge_fs.cat",
kind=Kind.OBJECT,
summary="Read Agent Knowledge Base entry content (cat)",
examples=(
Example(
title="Read a knowledge entry",
input={
"workspace_id": "<workspace_id>",
"knowledge_space_id": "<knowledge_space_id>",
"path": "/knowledge/guide",
},
),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
query=KnowledgeFSEntryReadContentQuery,
returns=(200, KnowledgeFSEntryReadContentResponse, "Agent Knowledge Base entry content"),
)
@_knowledge_fs_operation(
"cat_knowledge_fs",
summary="Read Agent Knowledge Base entry content (cat)",
description=(
f"Reads a bounded text portion of one entry, equivalent to difyctl fs cat. "
f"{_CONTENT_CONTINUATION_DESCRIPTION}"
),
)
@_knowledge_fs_errors
def get(
self,
workspace_id: str,
knowledge_space_id: str,
*,
ctx: Context,
query: KnowledgeFSEntryReadContentQuery,
) -> KnowledgeFSEntryReadContentResponse:
response = _knowledge_fs_facade().cat_knowledge_fs(
**_facade_args(workspace_id, knowledge_space_id, ctx),
query=_read_content_product_query(query),
)
return KnowledgeFSEntryReadContentResponse.from_product(response)
@openapi_ns.route(f"{_FS_ROUTE}:stat")
class KnowledgeFsEntryInspectApi(Resource):
@endpoint(
op="knowledge_fs.stat",
kind=Kind.OBJECT,
summary="Inspect an Agent Knowledge Base entry (stat)",
examples=(
Example(
title="Inspect a knowledge entry",
input={
"workspace_id": "<workspace_id>",
"knowledge_space_id": "<knowledge_space_id>",
"path": "/knowledge/guide",
},
),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
query=KnowledgeFSEntryInspectQuery,
returns=(200, KnowledgeFSEntryMetadataResponse, "Agent Knowledge Base entry metadata"),
)
@_knowledge_fs_operation(
"stat_knowledge_fs",
summary="Inspect an Agent Knowledge Base entry (stat)",
description=(
"Returns stable metadata for one entry selected by canonical virtual path without reading content, "
"equivalent to difyctl fs stat."
),
)
@_knowledge_fs_errors
def get(
self,
workspace_id: str,
knowledge_space_id: str,
*,
ctx: Context,
query: KnowledgeFSEntryInspectQuery,
) -> KnowledgeFSEntryMetadataResponse:
response = _knowledge_fs_facade().stat_knowledge_fs(
**_facade_args(workspace_id, knowledge_space_id, ctx),
query=_inspect_product_query(query),
)
return KnowledgeFSEntryMetadataResponse.from_product(response)
__all__ = [
"KnowledgeFsEntryCompareApi",
"KnowledgeFsEntryContentSearchApi",
"KnowledgeFsEntryInspectApi",
"KnowledgeFsEntryListApi",
"KnowledgeFsEntryReadContentApi",
"KnowledgeFsEntrySearchApi",
"KnowledgeFsEntryTreeApi",
]

View File

@@ -39,6 +39,7 @@ from .dataset import (
)
from .dataset.rag_pipeline import rag_pipeline_workflow
from .end_user import end_user
from .knowledge_fs import resources as knowledge_fs_resources
from .workspace import models
__all__ = [
@@ -55,6 +56,7 @@ __all__ = [
"hit_testing",
"human_input_form",
"index",
"knowledge_fs_resources",
"message",
"metadata",
"models",

View File

@@ -0,0 +1,5 @@
"""KnowledgeFS-specific Service API authenticated by resource credentials."""
from . import resources
__all__ = ["resources"]

View File

@@ -0,0 +1,92 @@
"""Stable KnowledgeFS Service API error contract."""
from libs.exception import BaseHTTPException
from services.knowledge_fs.product_dto import KnowledgeFSPublicFailureResponse
class _KnowledgeFSServiceFailureHTTPError(BaseHTTPException):
def __init__(self, failure: KnowledgeFSPublicFailureResponse | None = None) -> None:
super().__init__(failure.message if failure else None)
if failure is not None and self.data is not None:
self.data["failure"] = failure.model_dump(by_alias=True, exclude_none=True)
class KnowledgeFSInvalidCredentialHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_invalid_credential"
description = "Invalid Agent Knowledge Base service credential."
code = 401
class KnowledgeFSServiceOperationUnavailableHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_operation_unavailable"
description = "Agent Knowledge Base operation is not available."
code = 503
class KnowledgeFSServiceUpstreamUnavailableHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_upstream_unavailable"
description = "Agent Knowledge Base is unavailable."
code = 502
class KnowledgeFSServiceTimeoutHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_timeout"
description = "Agent Knowledge Base request timed out."
code = 504
class KnowledgeFSServiceInvalidRequestHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_invalid_request"
description = "Agent Knowledge Base request is invalid."
code = 400
class KnowledgeFSServiceAccessDeniedHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_access_denied"
description = "Agent Knowledge Base operation is not allowed."
code = 403
class KnowledgeFSServiceResourceNotFoundHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_resource_not_found"
description = "Agent Knowledge Base resource was not found."
code = 404
class KnowledgeFSServiceConflictHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_conflict"
description = "Agent Knowledge Base operation conflicts with the current resource state."
code = 409
class KnowledgeFSServiceRequestTooLargeHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_request_too_large"
description = "Agent Knowledge Base request is too large."
code = 413
class KnowledgeFSServiceRequestRejectedHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_request_rejected"
description = "Agent Knowledge Base request was rejected."
code = 422
class KnowledgeFSServiceRateLimitHTTPError(_KnowledgeFSServiceFailureHTTPError):
error_code = "knowledge_fs_rate_limit_exceeded"
description = "Agent Knowledge Base request rate limit exceeded."
code = 429
__all__ = [
"KnowledgeFSInvalidCredentialHTTPError",
"KnowledgeFSServiceAccessDeniedHTTPError",
"KnowledgeFSServiceConflictHTTPError",
"KnowledgeFSServiceInvalidRequestHTTPError",
"KnowledgeFSServiceOperationUnavailableHTTPError",
"KnowledgeFSServiceRateLimitHTTPError",
"KnowledgeFSServiceRequestRejectedHTTPError",
"KnowledgeFSServiceRequestTooLargeHTTPError",
"KnowledgeFSServiceResourceNotFoundHTTPError",
"KnowledgeFSServiceTimeoutHTTPError",
"KnowledgeFSServiceUpstreamUnavailableHTTPError",
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
import inspect
import logging
import time
from collections.abc import Callable
from enum import StrEnum, auto
from functools import wraps
@@ -12,7 +11,7 @@ from flask_restx import Resource
from flask_restx.utils import merge
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized
from configs import dify_config
@@ -25,14 +24,14 @@ from controllers.service_api.schema import (
from enums import CloudPlan, DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.login import current_user
from models import Account, Tenant, TenantAccountJoin, TenantStatus
from models.dataset import Dataset, RateLimitLog
from models.dataset import Dataset
from models.model import ApiToken, App
from repositories.knowledge import dataset_api_key_bindings
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
from services.feature_service import FeatureService
from services.knowledge_rate_limit_service import KnowledgeRateLimitExceededError, enforce_knowledge_rate_limit
logger = logging.getLogger(__name__)
@@ -268,6 +267,21 @@ def cloud_edition_billing_knowledge_limit_check[**P, R](
return interceptor
def check_knowledge_rate_limit(api_token: ApiToken) -> None:
"""Enforce the cloud knowledge base request rate limit for a dataset API key's workspace.
Shared by the ``cloud_edition_billing_rate_limit_check`` decorator and the KnowledgeFS
service routes, which authenticate inside their own profile helper rather than through
a decorator. No-op unless the workspace has a knowledge rate limit (cloud billing).
"""
if api_token.tenant_id is None:
raise Unauthorized("Tenant id is required for this token.")
try:
enforce_knowledge_rate_limit(api_token.tenant_id)
except KnowledgeRateLimitExceededError as error:
raise Forbidden(str(error)) from error
def cloud_edition_billing_rate_limit_check[**P, R](
resource: str,
api_token_type: str,
@@ -278,32 +292,7 @@ def cloud_edition_billing_rate_limit_check[**P, R](
api_token = validate_and_get_api_token(api_token_type)
if resource == "knowledge":
tenant_id = api_token.tenant_id
if tenant_id is None:
raise Unauthorized("Tenant id is required for this token.")
knowledge_rate_limit = FeatureService.get_knowledge_rate_limit(tenant_id)
if knowledge_rate_limit.enabled:
current_time = int(time.time() * 1000)
key = f"rate_limit_{tenant_id}"
redis_client.zadd(key, {current_time: current_time})
redis_client.zremrangebyscore(key, 0, current_time - 60000)
request_count = redis_client.zcard(key)
if request_count > knowledge_rate_limit.limit:
# add ratelimit record
rate_limit_log = RateLimitLog(
tenant_id=tenant_id,
subscription_plan=knowledge_rate_limit.subscription_plan,
operation="knowledge",
)
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
session.add(rate_limit_log)
raise Forbidden(
"Sorry, you have reached the knowledge base request rate limit of your subscription."
)
check_knowledge_rate_limit(api_token)
return view(*args, **kwargs)
return decorated
@@ -337,14 +326,16 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
# N rows -> the key is limited to exactly those datasets
# A bound key may only call endpoints carrying one of its dataset ids; endpoints
# without a dataset id (e.g. list/create datasets) are rejected. The set is queried
# per request (not cached) so scope changes take effect immediately.
# N rows -> the key is limited to exactly those resources
# Only legacy-dataset bindings count here: a key bound solely to KnowledgeFS spaces
# is restricted but has no reachable legacy dataset, so it is rejected. A bound key
# may only call endpoints carrying one of its dataset ids; endpoints without a
# dataset id (e.g. list/create datasets) are rejected. The scope is queried per
# request (not cached) so changes take effect immediately.
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
# typed helper accepts it (runtime proxies every Session method through unchanged).
bound_dataset_ids = dataset_api_key_bindings.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
scope = dataset_api_key_bindings.get_key_scope(cast(Session, db.session), api_token.id)
if not scope.allows_dataset(str(dataset_id) if dataset_id else None):
raise Forbidden("The API key is not authorized to access this knowledge base.")
if dataset_id:

View File

@@ -14,7 +14,10 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.base_app_runner import AppRunner
from core.app.entities.app_invoke_entities import (
AgentChatAppGenerateEntity,
DifyRunContext,
InvokeFrom,
ModelConfigWithCredentialsEntity,
UserFrom,
)
from core.app.file_access import DatabaseFileAccessController
from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackHandler
@@ -149,6 +152,15 @@ class BaseAgentRunner(AppRunner):
user_id=self.user_id,
invoke_from=self.application_generate_entity.invoke_from,
)
invoke_from = self.application_generate_entity.invoke_from
if isinstance(invoke_from, InvokeFrom):
tool_entity.runtime.dify_run_context = DifyRunContext(
tenant_id=self.tenant_id,
app_id=self.app_config.app_id,
user_id=self.user_id,
user_from=(UserFrom.ACCOUNT if invoke_from.runs_as_account() else UserFrom.END_USER),
invoke_from=invoke_from,
)
assert tool_entity.entity.description
message_tool = PromptMessageTool(
name=tool.tool_name,

View File

@@ -50,7 +50,9 @@ from core.app.entities.queue_entities import (
QueueAgentThoughtEvent,
QueueLLMChunkEvent,
QueueMessageEndEvent,
QueueRetrieverResourcesEvent,
)
from core.app.knowledge_citations import knowledge_sources_from_tool_part
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form
from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
@@ -280,6 +282,7 @@ class _AgentProcessRecorder:
self._tool_by_index: dict[int, str] = {}
self._tool_by_call_id: dict[str, str] = {}
self._open_tool_by_name: dict[str, set[str]] = {}
self._knowledge_receipts: set[str] = set()
def handle_stream_event(self, event: AgentBackendStreamInternalEvent) -> None:
data = event.data
@@ -436,6 +439,16 @@ class _AgentProcessRecorder:
def _record_tool_return_part(self, part: dict[str, Any]) -> None:
self._close_thinking_segments()
sources = []
for source in knowledge_sources_from_tool_part(part):
citation = source.knowledge_fs_citation
if citation and citation.id not in self._knowledge_receipts and len(self._knowledge_receipts) < 200:
self._knowledge_receipts.add(citation.id)
sources.append(source)
if sources:
self._queue_manager.publish(
QueueRetrieverResourcesEvent(retriever_resources=sources), PublishFrom.APPLICATION_MANAGER
)
tool_call_id = _string_or_none(part.get("tool_call_id"))
tool_name = _string_or_none(part.get("tool_name"))
content = part.get("content")

View File

@@ -26,6 +26,7 @@ from dify_agent.layers.user_prompt import (
DifyUserPromptImageConfig,
)
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from dify_agent.protocol.knowledge_fs import KnowledgeFsError
from clients.agent_backend import (
AgentBackendAgentAppRunInput,
@@ -35,6 +36,7 @@ from clients.agent_backend import (
)
from configs import dify_config
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.app.knowledge_runtime_config import build_knowledge_fs_layer_config
from core.app.llm.model_access import resolve_model_context_window, resolve_model_supports_vision
from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity
from core.workflow.file_reference import build_file_reference, is_canonical_file_reference
@@ -49,7 +51,6 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
build_ask_human_layer_config,
build_config_aware_soul_mention_resolver,
build_config_layer_config,
build_knowledge_layer_config,
build_shell_layer_config,
load_runtime_agent_skill_configs,
)
@@ -154,7 +155,10 @@ class AgentAppRuntimeRequestBuilder:
agent_soul,
runtime_config_skills=runtime_config_skills,
)
knowledge_config = build_knowledge_layer_config(agent_soul)
try:
knowledge_config = build_knowledge_fs_layer_config(agent_soul, run_context=context.dify_context)
except KnowledgeFsError as exc:
raise AgentAppRuntimeRequestBuildError(exc.code, exc.message) from exc
context_window_tokens = resolve_model_context_window(
run_context=context.dify_context,
provider_name=agent_soul.model.model_provider,
@@ -170,6 +174,7 @@ class AgentAppRuntimeRequestBuilder:
provider_name=agent_soul.model.model_provider,
model_name=agent_soul.model.model,
image_detail_config=context.image_detail_config,
include_image_references=knowledge_config is not None,
)
request = self._request_builder.build_for_agent_app(
@@ -193,6 +198,7 @@ class AgentAppRuntimeRequestBuilder:
user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
agent_mode="agent_app",
trace_id=context.dify_context.trace_session_id,
),
backend_binding_ref=context.backend_binding_ref,
# ENG-616: expand slash-menu mention tokens to canonical names so
@@ -204,7 +210,7 @@ class AgentAppRuntimeRequestBuilder:
user_files=user_files,
tools=tool_layers.plugin_tools,
core_tools=tool_layers.core_tools,
knowledge=knowledge_config,
knowledge_fs=knowledge_config,
config_layer_config=config_layer_config,
ask_human_config=build_ask_human_layer_config(agent_soul),
include_shell=dify_config.AGENT_SHELL_ENABLED,
@@ -232,18 +238,23 @@ class AgentAppRuntimeRequestBuilder:
provider_name: str,
model_name: str,
image_detail_config: ImagePromptMessageContent.DETAIL | None,
include_image_references: bool,
) -> list[DifyUserPromptFileConfig]:
supports_vision = any(file.type == FileType.IMAGE for file in files) and resolve_model_supports_vision(
run_context=run_context,
provider_name=provider_name,
model_name=model_name,
)
return [
_build_user_image(file, image_detail_config=image_detail_config)
if supports_vision and file.type == FileType.IMAGE
else _build_user_download(file)
for file in files
]
user_files: list[DifyUserPromptFileConfig] = []
for file in files:
if supports_vision and file.type == FileType.IMAGE:
user_files.append(_build_user_image(file, image_detail_config=image_detail_config))
# KnowledgeFS image search needs the upload reference even when
# the model also receives the image as native vision content.
if not include_image_references or file.transfer_method != FileTransferMethod.LOCAL_FILE:
continue
user_files.append(_build_user_download(file))
return user_files
@staticmethod
def _validate_session_snapshot_layers(request: CreateRunRequest) -> None:
@@ -321,7 +332,9 @@ def _build_user_download(file: File) -> DifyUserPromptDownloadConfig:
if file.transfer_method == FileTransferMethod.REMOTE_URL:
if file.remote_url is None:
raise AgentAppRuntimeRequestBuildError("agent_user_file_invalid", "Remote user file is missing its URL.")
return DifyUserPromptDownloadConfig(type=file_type, transfer_method="remote_url", url=file.remote_url)
return DifyUserPromptDownloadConfig(
type=file_type, filename=file.filename or None, transfer_method="remote_url", url=file.remote_url
)
if file.reference is None:
raise AgentAppRuntimeRequestBuildError("agent_user_file_invalid", "User file is missing its reference.")
reference = file.reference
@@ -342,7 +355,9 @@ def _build_user_download(file: File) -> DifyUserPromptDownloadConfig:
"agent_user_file_invalid",
f"User file transfer method '{file.transfer_method.value}' is unsupported.",
)
return DifyUserPromptDownloadConfig(type=file_type, transfer_method=transfer_method, reference=reference)
return DifyUserPromptDownloadConfig(
type=file_type, filename=file.filename or None, transfer_method=transfer_method, reference=reference
)
__all__ = [

View File

@@ -82,12 +82,16 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
updated_resources.append(
{
"dataset_id": resource.get("dataset_id"),
"knowledge_fs_citation": resource.get("knowledge_fs_citation"),
"dataset_name": resource.get("dataset_name"),
"document_id": resource.get("document_id"),
"document_asset_id": resource.get("document_asset_id"),
"segment_id": resource.get("segment_id", ""),
"position": resource["position"],
"data_source_type": resource.get("data_source_type"),
"document_name": resource["document_name"],
"document_revision": resource.get("document_revision"),
"document_version": resource.get("document_version"),
"score": resource["score"],
"hit_count": resource.get("hit_count"),
"word_count": resource.get("word_count"),

View File

@@ -0,0 +1,63 @@
"""Project trusted runtime evidence into Dify's existing citation event contract."""
from collections.abc import Mapping
from dify_agent.protocol.knowledge_fs import KnowledgeFsCitation
from pydantic import ValidationError
from core.rag.entities.citation_metadata import RetrievalSourceMetadata
def knowledge_sources_from_tool_part(part: object) -> list[RetrievalSourceMetadata]:
if not isinstance(part, dict) or part.get("part_kind") != "tool-return":
return []
metadata = part.get("metadata")
if not isinstance(metadata, dict) or not isinstance(metadata.get("knowledge_fs_citations"), list):
return []
content = part.get("content")
results = content.get("knowledge_results", []) if isinstance(content, dict) else []
text_by_receipt: dict[str, str] = {}
for result in results if isinstance(results, list) else []:
data = result.get("data") if isinstance(result, dict) else None
if not isinstance(data, dict):
continue
items = data.get("items", []) if "items" in data else [data]
for item in items if isinstance(items, list) else []:
if isinstance(item, dict) and isinstance(item.get("receipt_id"), str) and isinstance(item.get("text"), str):
text_by_receipt[item["receipt_id"]] = item["text"][:4096]
sources = []
seen = set()
for raw in metadata["knowledge_fs_citations"][:100]:
try:
citation = KnowledgeFsCitation.model_validate(raw)
except ValidationError:
continue
if citation.id in seen:
continue
seen.add(citation.id)
sources.append(
RetrievalSourceMetadata(
dataset_id=citation.control_space_id,
dataset_name=citation.space_name,
document_id=citation.document_asset_id,
document_asset_id=citation.document_asset_id,
document_version=citation.document_version,
document_name=citation.document_title or citation.document_asset_id,
segment_id=citation.node_id,
index_node_hash=citation.artifact_hash,
data_source_type="knowledge_fs",
retriever_from="agent_knowledge_fs_cli",
content=text_by_receipt.get(citation.id, ""),
page=citation.page_number,
knowledge_fs_citation=citation,
)
)
return sources
def knowledge_sources_from_stream_event(data: object) -> list[RetrievalSourceMetadata]:
if not isinstance(data, Mapping) or data.get("event_kind") != "function_tool_result":
return []
# PydanticAI serializes FunctionToolResultEvent under `part`; retain the
# normalized adapter's `result` alias for older recorded events as well.
return knowledge_sources_from_tool_part(data.get("part") or data.get("result"))

View File

@@ -0,0 +1,47 @@
"""Shared KnowledgeFS layer mapping for Agent App and Workflow AgentV2."""
from dify_agent.layers.knowledge_fs import DifyKnowledgeFsLayerConfig
from dify_agent.protocol.knowledge_fs import KnowledgeFsBinding, KnowledgeFsError
from configs import dify_config
from core.app.entities.app_invoke_entities import DifyRunContext
from core.app.llm.model_access import DifyModelFactory
from graphon.model_runtime.entities.model_entities import ModelFeature
from models.agent_config_entities import AgentSoulConfig
def build_knowledge_fs_layer_config(
agent_soul: AgentSoulConfig, *, run_context: DifyRunContext
) -> DifyKnowledgeFsLayerConfig | None:
if agent_soul.knowledge.sets:
raise KnowledgeFsError(
"KNOWLEDGE_REBIND_REQUIRED",
"Replace Classic Knowledge Base bindings with Agent Knowledge Base bindings before running.",
)
if not agent_soul.knowledge.spaces:
return None
if any(space.is_missing for space in agent_soul.knowledge.spaces):
raise KnowledgeFsError("KNOWLEDGE_REBIND_REQUIRED", "Reselect imported or unavailable Agent Knowledge Bases.")
if not dify_config.AGENT_SHELL_ENABLED:
raise KnowledgeFsError(
"KNOWLEDGE_SHELL_UNAVAILABLE", "Agent Knowledge Base CLI requires the Agent sandbox shell.", 503
)
if not agent_soul.model:
raise KnowledgeFsError(
"KNOWLEDGE_MODEL_REQUIRED", "Select an Agent model before using an Agent Knowledge Base."
)
schema = (
DifyModelFactory(run_context=run_context)
.init_model_instance(
agent_soul.model.model_provider,
agent_soul.model.model,
)
.get_model_schema()
)
return DifyKnowledgeFsLayerConfig(
spaces=[
KnowledgeFsBinding.model_validate(space.model_dump(exclude={"is_missing"}))
for space in agent_soul.knowledge.spaces
],
agent_supports_vision=ModelFeature.VISION in (schema.features or []),
)

View File

@@ -192,6 +192,7 @@ class MessageCycleManager:
if self._application_generate_entity.app_config.additional_features.show_retrieve_source:
merged_resources = [r for r in self._task_state.metadata.retriever_resources or [] if r]
existing_ids = {(r.dataset_id, r.document_id) for r in merged_resources if r.dataset_id and r.document_id}
receipt_ids = {r.knowledge_fs_citation.id for r in merged_resources if r.knowledge_fs_citation}
# Add new unique resources from the event
for resource in event.retriever_resources or []:
@@ -199,13 +200,20 @@ class MessageCycleManager:
continue
is_duplicate = (
resource.dataset_id
and resource.document_id
and (resource.dataset_id, resource.document_id) in existing_ids
resource.knowledge_fs_citation.id in receipt_ids
if resource.knowledge_fs_citation
else (
resource.knowledge_fs_citation is None
and resource.dataset_id
and resource.document_id
and (resource.dataset_id, resource.document_id) in existing_ids
)
)
if not is_duplicate:
merged_resources.append(resource)
if resource.knowledge_fs_citation:
receipt_ids.add(resource.knowledge_fs_citation.id)
for i, resource in enumerate(merged_resources, 1):
resource.position = i

View File

@@ -38,6 +38,11 @@ class DatasourcePluginProviderController(ABC):
):
raise ToolProviderCredentialValidationError("Invalid credentials")
def validate_credentials(self, user_id: str, credentials: dict[str, Any]) -> None:
"""Validate credential shape and value against this installed provider declaration."""
self.validate_credentials_format(credentials)
self._validate_credentials(user_id, credentials)
@property
def provider_type(self) -> DatasourceProviderType:
"""

View File

@@ -1,10 +1,14 @@
from __future__ import annotations
import base64
import enum
import re
import string
from datetime import datetime
from enum import StrEnum
from typing import Any, TypedDict
from typing import Any, Literal, TypedDict, override
from pydantic import BaseModel, Field, ValidationInfo, field_validator
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator
from yarl import URL
from configs import dify_config
@@ -330,24 +334,139 @@ class WebsiteCrawlMessage(BaseModel):
result: WebSiteInfo = WebSiteInfo(status="", web_info_list=[], total=0, completed=0)
class DatasourceMessage(ToolInvokeMessage):
pass
#########################
# Online drive file
#########################
class OnlineDriveChecksum(TypedDict):
"""Optional full-file checksum supplied by an online-drive provider."""
algorithm: Literal["md5", "sha256"]
value: str
class OnlineDriveRemoteMetadata(TypedDict, total=False):
"""Bounded wire metadata used as a hint for online-drive change detection."""
version_id: str
etag: str
checksum: OnlineDriveChecksum
modified_time: str
_ONLINE_DRIVE_TIMESTAMP_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$")
def _online_drive_token(value: Any) -> str | None:
if (
not isinstance(value, str)
or not value.strip()
or len(value) > 1024
or any(ord(character) < 32 or ord(character) == 127 for character in value)
):
return None
return value
def _online_drive_timestamp(value: Any) -> str | None:
if not isinstance(value, str) or len(value) > 64 or not _ONLINE_DRIVE_TIMESTAMP_PATTERN.fullmatch(value):
return None
try:
datetime.fromisoformat(value)
except ValueError:
return None
return value
def _bounded_online_drive_remote_metadata(value: Any) -> OnlineDriveRemoteMetadata | None:
"""Keep only the optional, non-secret fields understood by KnowledgeFS."""
if not isinstance(value, dict):
return None
result: OnlineDriveRemoteMetadata = {}
version_id = _online_drive_token(value.get("version_id"))
if version_id and version_id != "null":
result["version_id"] = version_id
etag = _online_drive_token(value.get("etag"))
if etag and not etag.lower().startswith("w/"):
result["etag"] = etag
modified_time = _online_drive_timestamp(value.get("modified_time"))
if modified_time:
result["modified_time"] = modified_time
checksum = value.get("checksum")
if isinstance(checksum, dict):
algorithm, digest = checksum.get("algorithm"), checksum.get("value")
normalized_algorithm: Literal["md5", "sha256"] | None = None
if algorithm == "md5":
normalized_algorithm = "md5"
elif algorithm == "sha256":
normalized_algorithm = "sha256"
digest_length = 32 if normalized_algorithm == "md5" else 64 if normalized_algorithm == "sha256" else None
if (
normalized_algorithm is not None
and digest_length is not None
and isinstance(digest, str)
and len(digest) == digest_length
and all(character in string.hexdigits for character in digest)
):
result["checksum"] = {"algorithm": normalized_algorithm, "value": digest.lower()}
return result or None
class DatasourceMessage(ToolInvokeMessage):
pass
class OnlineDriveDownloadMessage(DatasourceMessage):
"""Datasource invoke message with a bounded online-drive download receipt."""
@field_validator("meta", mode="before")
@classmethod
def bounded_remote_metadata(cls, value: Any) -> Any:
if not isinstance(value, dict) or "remote_metadata" not in value:
return value
result = dict(value)
remote_metadata = _bounded_online_drive_remote_metadata(value.get("remote_metadata"))
if remote_metadata:
result["remote_metadata"] = remote_metadata
else:
result.pop("remote_metadata", None)
return result
@field_serializer("message")
@override
def serialize_message(self, v: Any) -> Any:
"""Keep online-drive blob chunks JSON-safe across the Dify inner API bridge."""
if isinstance(v, self.BlobChunkMessage):
return {
"id": v.id,
"sequence": v.sequence,
"total_length": v.total_length,
"blob": base64.b64encode(v.blob).decode("utf-8"),
"end": v.end,
}
return super().serialize_message(v)
class OnlineDriveFile(BaseModel):
"""
Online drive file
"""
"""Online drive file."""
id: str = Field(..., description="The file ID")
name: str = Field(..., description="The file name")
size: int = Field(..., description="The file size")
type: str = Field(..., description="The file type: folder or file")
remote_metadata: OnlineDriveRemoteMetadata | None = Field(
None,
description="Optional provider version_id, etag, checksum and modified_time; not credentials",
)
@field_validator("remote_metadata", mode="before")
@classmethod
def bounded_remote_metadata(cls, value: Any) -> OnlineDriveRemoteMetadata | None:
"""Keep the optional wire extension bounded without rejecting legacy file listings."""
return _bounded_online_drive_remote_metadata(value)
class OnlineDriveFileBucket(BaseModel):

View File

@@ -0,0 +1 @@
"""KnowledgeFS contracts at the workflow/application boundary."""

View File

@@ -0,0 +1,90 @@
"""Errors and image-reference values shared across KnowledgeFS capability boundaries."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from core.knowledge_fs.retrieval_contracts import KnowledgeFSPublicFailureResponse
QUERY_IMAGE_MAX_COUNT = 4
QUERY_IMAGE_MAX_TOTAL_BYTES = 32 * 1024 * 1024
class KnowledgeFSAppAdmissionError(RuntimeError):
"""The app is not explicitly bound to an enabled KnowledgeFS channel."""
class KnowledgeFSAppBindingNotEnabledError(KnowledgeFSAppAdmissionError):
"""The requested app binding is missing, revoked, or outside the caller scope."""
class KnowledgeFSAppChannelDisabledError(KnowledgeFSAppAdmissionError):
"""The requested app caller channel is disabled for the control-space."""
class KnowledgeFSAppSpaceUnavailableError(KnowledgeFSAppAdmissionError):
"""The bound control-space is not active and provisioned for product traffic."""
class KnowledgeFSAppAuthorizationNotReadyError(KnowledgeFSAppAdmissionError):
"""Required local authorization policy or revision state is unavailable."""
class KnowledgeFSAppBindingManagementError(RuntimeError):
"""An app binding cannot be created or changed safely."""
class KnowledgeFSProductRemoteError(RuntimeError):
"""KnowledgeFS could not provide an authoritative product response."""
def __init__(
self,
message: str,
*,
failure: KnowledgeFSPublicFailureResponse | None = None,
) -> None:
super().__init__(message)
self.failure = failure
class KnowledgeFSProductResourceNotFoundError(KnowledgeFSProductRemoteError):
"""KnowledgeFS authoritatively reported that an authorized child resource is absent."""
class KnowledgeFSOperationUnavailableError(RuntimeError):
"""The Dify/KFS/Capability operation manifests are not yet aligned."""
class KnowledgeFSProductRequestRejectedError(RuntimeError):
"""A bounded product request was rejected locally or by authoritative KFS validation."""
def __init__(
self,
*,
status_code: Literal[400, 403, 409, 413, 422, 429, 503, 504],
violations: list[dict[str, str | int | float | list[str]]] | None = None,
failure: KnowledgeFSPublicFailureResponse | None = None,
) -> None:
super().__init__(f"KnowledgeFS rejected the product request with HTTP {status_code}")
self.status_code = status_code
self.violations = violations
self.failure = failure
class KnowledgeFSQueryImageError(ValueError):
"""A safe validation error raised before query model or retrieval work starts."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@dataclass(frozen=True)
class KnowledgeFSWorkflowQueryImageReference:
"""One workflow-authorized Dify file reference safe to forward through KnowledgeFS."""
upload_file_id: str
access_grant: str = field(repr=False)
byte_size: int
mime_type: str

View File

@@ -0,0 +1,24 @@
"""Shared KnowledgeFS contracts consumed by workflow nodes and application services."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class KnowledgeResourceRef(BaseModel):
"""A typed app configuration reference to one Dify-owned KnowledgeFS control-space."""
kind: Literal["knowledge_fs"]
control_space_id: str = Field(min_length=1, max_length=1_000)
model_config = ConfigDict(extra="forbid", frozen=True)
@field_validator("control_space_id")
@classmethod
def normalize_control_space_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("KnowledgeFS control-space reference is required")
return normalized

View File

@@ -0,0 +1,621 @@
"""Shared KnowledgeFS contracts consumed by workflow nodes and application services."""
from __future__ import annotations
import math
import re
from datetime import UTC, datetime
from typing import Annotated, ClassVar, Literal
from uuid import UUID
from pydantic import (
AliasChoices,
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
from fields.base import ResponseModel
from models.knowledge_fs import (
KnowledgeFSAppSpaceJoinType,
)
class KnowledgeFSAppBindingPayload(BaseModel):
app_id: str = Field(min_length=1)
caller_kind: KnowledgeFSAppSpaceJoinType
model_config = ConfigDict(extra="forbid")
class KnowledgeFSMetadataFieldResponse(ResponseModel):
count: int = Field(ge=0)
created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt"))
id: str
name: str
row_version: int = Field(ge=0, validation_alias=AliasChoices("row_version", "rowVersion"))
type: Literal["string", "number", "time"]
updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt"))
class KnowledgeFSMetadataFieldListResponse(ResponseModel):
data: list[KnowledgeFSMetadataFieldResponse] = Field(validation_alias=AliasChoices("data", "items"))
next_cursor: str | None = Field(default=None, validation_alias=AliasChoices("next_cursor", "nextCursor"))
KnowledgeFSPublicErrorCode = Literal[
"KNOWLEDGE_SPACE_SETTINGS_COMPILATION_IN_PROGRESS",
"KNOWLEDGE_SPACE_SETTINGS_REVISION_CONFLICT",
"KNOWLEDGE_SPACE_SETTINGS_MIGRATION_REQUIRED",
"DOCUMENT_COMPILATION_PROFILE_CHANGED",
"DOCUMENT_COMPILATION_FAILED",
"DOCUMENT_COMPILATION_RETRYABLE",
"DOCUMENT_COMPILATION_LEASE_LOST",
"DOCUMENT_DISABLED",
"DOCUMENT_PARSER_INPUT_INVALID",
"DOCUMENT_PARSER_NOT_CONFIGURED",
"DOCUMENT_PARSER_RATE_LIMITED",
"DOCUMENT_PARSER_RESPONSE_INVALID",
"DOCUMENT_PARSER_TIMEOUT",
"DOCUMENT_PARSER_UNAVAILABLE",
"DOCUMENT_PARSER_UNSUPPORTED_TYPE",
"DOCUMENT_PDF_RENDER_FAILED",
"EMBEDDING_DIMENSION_INVALID",
"EMBEDDING_DIMENSION_UNSUPPORTED",
"EXECUTION_ATTEMPTS_EXHAUSTED",
"KNOWLEDGE_FS_ACCESS_DENIED",
"KNOWLEDGE_FS_CONFLICT",
"LOGICAL_DOCUMENT_CAS_CONFLICT",
"DURABLE_DELETION_IDEMPOTENCY_CONFLICT",
"SOURCE_VERSION_CONFLICT",
"KNOWLEDGE_FS_INTERNAL_ERROR",
"KNOWLEDGE_FS_INVALID_REQUEST",
"KNOWLEDGE_FS_NOT_FOUND",
"KNOWLEDGE_FS_RATE_LIMITED",
"KNOWLEDGE_FS_TIMEOUT",
"KNOWLEDGE_FS_UNAVAILABLE",
"KNOWLEDGE_SPACE_MANIFEST_NOT_FOUND",
"KNOWLEDGE_SPACE_MODEL_CONFIGURATION_REQUIRED",
"MODEL_CAPABILITY_MISMATCH",
"MODEL_CONFIGURATION_STALE",
"MODEL_CREDENTIAL_INVALID",
"MODEL_CREDENTIAL_VALIDATION_UNAVAILABLE",
"MODEL_IDENTITY_MISMATCH",
"MODEL_PREFLIGHT_CANCELED",
"MODEL_PREFLIGHT_FAILED",
"MODEL_PREFLIGHT_TIMEOUT",
"MODEL_PREFLIGHT_UNAVAILABLE",
"MODEL_PROFILE_ACTIVATION_INCOMPLETE",
"MODEL_PROFILE_ACTIVATION_PERMISSION_REQUIRED",
"MODEL_RUNTIME_FAILED",
"MODEL_RUNTIME_CONTEXT_LIMIT",
"MODEL_RUNTIME_OUTPUT_LIMIT",
"MODEL_RUNTIME_RESPONSE_INVALID",
"MODEL_RUNTIME_TIMEOUT",
"MODEL_RUNTIME_UNAVAILABLE",
"MODEL_SELECTION_NOT_FOUND",
"RESEARCH_TASK_CAPABILITY_REVOKED",
"RESEARCH_TASK_DISPATCH_DEAD",
"RESEARCH_TASK_EXECUTION_ATTEMPTS_EXHAUSTED",
"RESEARCH_TASK_FAILED",
"RESEARCH_TASK_PERMISSION_SNAPSHOT_INVALID",
"RESEARCH_TASK_RUNTIME_SNAPSHOT_INVALID",
"RETRIEVAL_DELETION_IN_PROGRESS",
"RETRIEVAL_EXECUTION_LEASE_LOST",
"SOURCE_BULK_ACTION_FAILED",
"SOURCE_CONNECTION_UNAVAILABLE",
"SOURCE_CRAWL_PAGE_NOT_FOUND",
"SOURCE_CRAWL_PROVIDER_UNAVAILABLE",
"SOURCE_CRAWL_RESULT_LIMIT_EXCEEDED",
"SOURCE_CREDENTIAL_CONFIG_INVALID",
"SOURCE_CREDENTIAL_MUTATION_FAILED",
"SOURCE_CREDENTIAL_TEST_FAILED",
"SOURCE_CREDENTIAL_UNAVAILABLE",
"SOURCE_DOCUMENT_COMPILATION_FAILED",
"SOURCE_DOCUMENT_MATERIALIZATION_FAILED",
"SOURCE_DOCUMENT_REPLACEMENT_SAGA_REQUIRED",
"SOURCE_IMPORT_PARTIAL_FAILURE",
"SOURCE_ONLINE_DOCUMENT_CONFIG_INVALID",
"SOURCE_ONLINE_DOCUMENT_IMPORT_FAILED",
"SOURCE_ONLINE_DOCUMENT_PAGE_FETCH_FAILED",
"SOURCE_ONLINE_DOCUMENT_REQUEST_FAILED",
"SOURCE_ONLINE_DOCUMENT_UNAVAILABLE",
"SOURCE_ONLINE_DRIVE_CONFIG_INVALID",
"SOURCE_ONLINE_DRIVE_FILE_DOWNLOAD_FAILED",
"SOURCE_ONLINE_DRIVE_IMPORT_FAILED",
"SOURCE_ONLINE_DRIVE_REQUEST_FAILED",
"SOURCE_ONLINE_DRIVE_UNAVAILABLE",
"SOURCE_OPERATION_FAILED",
"SOURCE_SECRET_INTEGRITY_FAILED",
"SOURCE_SECRET_REF_CONFLICT",
"SOURCE_SYNC_FAILED",
"SOURCE_PROVIDER_REJECTED",
"SOURCE_PROVIDER_TIMEOUT",
"SOURCE_PROVIDER_UNAVAILABLE",
"SOURCE_SYNC_SELECTION_MISMATCH",
"SOURCE_WEBSITE_CRAWL_CONFIG_INVALID",
"SOURCE_WEBSITE_CRAWL_FAILED",
"SOURCE_WORKFLOW_FAILED",
"SOURCE_WORKFLOW_CONTENT_MISSING",
"SOURCE_WORKFLOW_CONTENT_TOO_LARGE",
"SOURCE_WORKFLOW_EXTERNAL_TIMEOUT",
"UPLOAD_INITIALIZATION_FAILED",
"UPLOAD_INTEGRITY_MISMATCH",
]
class KnowledgeFSPublicFailureResponse(ResponseModel):
_SAFE_MESSAGE_BY_CATEGORY: ClassVar[dict[str, str]] = {
"authorization": "You do not have permission to perform this Agent Knowledge Base operation.",
"canceled": "The Agent Knowledge Base operation was canceled.",
"configuration": "The Agent Knowledge Base operation requires a configuration change before it can continue.",
"conflict": "The Agent Knowledge Base operation conflicts with the current resource state.",
"dependency": "A service required by Agent Knowledge Base is temporarily unavailable.",
"internal": (
"Agent Knowledge Base could not complete the operation. Try again, or contact an administrator "
"with the error reference."
),
"not_found": "The requested Agent Knowledge Base resource was not found.",
"rate_limit": "Too many Agent Knowledge Base operations were requested. Try again later.",
"timeout": "The Agent Knowledge Base operation timed out. Try again later.",
"validation": "The Agent Knowledge Base request is invalid.",
}
_SAFE_MESSAGE_BY_CODE: ClassVar[dict[str, str]] = {
"KNOWLEDGE_SPACE_SETTINGS_COMPILATION_IN_PROGRESS": (
"Documents are being processed. Wait for processing to finish or cancel the tasks before changing models."
),
"KNOWLEDGE_SPACE_SETTINGS_REVISION_CONFLICT": (
"Knowledge space settings have changed. "
"Reload the latest settings and review your changes before saving again."
),
"KNOWLEDGE_SPACE_SETTINGS_MIGRATION_REQUIRED": (
"This model change requires an index rebuild. Refresh settings and use the model migration workflow."
),
"DOCUMENT_COMPILATION_PROFILE_CHANGED": (
"The model configuration changed or is unavailable. "
"Check the knowledge space models before retrying document processing."
),
"RETRIEVAL_DELETION_IN_PROGRESS": "This Agent Knowledge Base is being deleted and cannot be searched.",
"RETRIEVAL_EXECUTION_LEASE_LOST": (
"The retrieval execution expired before it could finish. Run the query again."
),
"SOURCE_SYNC_SELECTION_MISMATCH": ("The source document inventory does not match the configured selection."),
}
_SAFE_PARAMETER_KEYS: ClassVar[frozenset[str]] = frozenset(
{
"attempt",
"currentRowVersion",
"expectedRowVersion",
"documentCount",
"fileSizeBytes",
"limit",
"maxFileSizeBytes",
"maxItems",
"modelType",
"providerKind",
"retryAfterSeconds",
"status",
}
)
action: (
Literal[
"configure_model",
"configure_parser",
"configure_source",
"contact_admin",
"reupload",
"retry",
]
| None
) = None
category: Literal[
"authorization",
"canceled",
"configuration",
"conflict",
"dependency",
"internal",
"not_found",
"rate_limit",
"timeout",
"validation",
]
code: KnowledgeFSPublicErrorCode
message: str = Field(min_length=1, max_length=1_024)
parameters: dict[str, str | int | float | bool] | None = Field(default=None, max_length=8)
retry_policy: Literal["automatic", "manual", "after_configuration", "never"] = Field(
validation_alias=AliasChoices("retry_policy", "retryPolicy"),
serialization_alias="retryPolicy",
)
stage: str | None = Field(default=None, min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
trace_id: str | None = Field(
default=None,
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9._:-]{1,128}$",
validation_alias=AliasChoices("trace_id", "traceId"),
serialization_alias="traceId",
)
model_config = ConfigDict(extra="forbid")
@field_validator("parameters")
@classmethod
def validate_public_parameters(
cls, parameters: dict[str, str | int | float | bool] | None
) -> dict[str, str | int | float | bool] | None:
if parameters is None:
return None
for key, value in parameters.items():
if key not in cls._SAFE_PARAMETER_KEYS or re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", key) is None:
raise ValueError("KnowledgeFS public failure parameter key is invalid")
if isinstance(value, str) and len(value) > 256:
raise ValueError("KnowledgeFS public failure parameter value is too long")
if isinstance(value, float) and not math.isfinite(value):
raise ValueError("KnowledgeFS public failure parameter must be finite")
return parameters
@model_validator(mode="after")
def replace_untrusted_message_with_bff_fallback(self) -> KnowledgeFSPublicFailureResponse:
self.message = self._SAFE_MESSAGE_BY_CODE.get(self.code, self._SAFE_MESSAGE_BY_CATEGORY[self.category])
return self
class KnowledgeFSQueryImageReference(BaseModel):
upload_file_id: str = Field(min_length=1, alias="uploadFileId")
model_config = ConfigDict(extra="forbid", serialize_by_alias=True, validate_by_alias=True, validate_by_name=True)
@field_validator("upload_file_id")
@classmethod
def validate_upload_file_id(cls, value: str) -> str:
try:
UUID(value)
except ValueError as exc:
raise ValueError("uploadFileId must be a UUID") from exc
return value
class KnowledgeFSRetrievalQueryImageReference(KnowledgeFSQueryImageReference):
"""Transient workflow file authorization forwarded only by retrieval-test requests."""
access_grant: str | None = Field(
default=None,
min_length=1,
max_length=2_048,
alias="accessGrant",
exclude_if=lambda value: value is None,
repr=False,
)
KnowledgeFSRetrievalFilterValue = Annotated[str, Field(min_length=1, max_length=512)]
KnowledgeFSRetrievalCustomMetadataOperator = Literal[
"contains",
"not contains",
"start with",
"end with",
"is",
"is not",
"empty",
"not empty",
"in",
"not in",
"=",
"≠",
">",
"<",
"≥",
"≤",
"before",
"after",
]
KnowledgeFSRetrievalReservedMetadataNames = frozenset(
{"displayName", "provenance", "retrievalCount", "sourceName", "system"}
)
class KnowledgeFSRetrievalCustomMetadataCondition(BaseModel):
name: str = Field(min_length=1, max_length=255, pattern=r"^[a-z][a-z0-9_]*$")
field_type: Literal["string", "number", "time"] = Field(
validation_alias=AliasChoices("field_type", "fieldType"),
serialization_alias="fieldType",
)
comparison_operator: KnowledgeFSRetrievalCustomMetadataOperator = Field(
validation_alias=AliasChoices("comparison_operator", "comparisonOperator"),
serialization_alias="comparisonOperator",
)
value: str | int | float | None = Field(default=None, exclude_if=lambda value: value is None)
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if value in KnowledgeFSRetrievalReservedMetadataNames:
raise ValueError("Retrieval custom metadata field name must be non-reserved")
return value
@field_validator("value")
@classmethod
def validate_value(cls, value: str | int | float | None) -> str | int | float | None:
if isinstance(value, bool):
raise ValueError("Retrieval custom metadata values must not be booleans")
if isinstance(value, str) and len(value) > 512:
raise ValueError("Retrieval custom metadata string values must contain at most 512 characters")
if isinstance(value, float) and not math.isfinite(value):
raise ValueError("Retrieval custom metadata numeric values must be finite")
return value
@model_validator(mode="after")
def validate_operator_and_value(self) -> KnowledgeFSRetrievalCustomMetadataCondition:
operators = {
"number": {"=", "≠", ">", "<", "≥", "≤", "empty", "not empty"},
"string": {
"contains",
"not contains",
"start with",
"end with",
"is",
"is not",
"empty",
"not empty",
"in",
"not in",
},
"time": {"is", "before", "after", "empty", "not empty"},
}
if self.comparison_operator not in operators[self.field_type]:
raise ValueError(f"Retrieval custom metadata operator is invalid for {self.field_type}")
if self.comparison_operator in {"empty", "not empty"}:
return self
if self.value is None:
raise ValueError("Retrieval custom metadata condition value is required")
if self.field_type == "number" and (isinstance(self.value, bool) or not isinstance(self.value, int | float)):
raise ValueError("Retrieval custom metadata number conditions require a numeric value")
if self.field_type == "string" and not isinstance(self.value, str):
raise ValueError("Retrieval custom metadata string conditions require a string value")
if self.field_type == "time":
if isinstance(self.value, bool) or not isinstance(self.value, str | int | float):
raise ValueError("Retrieval custom metadata time conditions require a timestamp value")
if isinstance(self.value, str):
try:
datetime.fromisoformat(self.value)
except ValueError as exc:
raise ValueError("Retrieval custom metadata time conditions require a valid timestamp") from exc
else:
try:
datetime.fromtimestamp(self.value, tz=UTC)
except (OverflowError, OSError, ValueError) as exc:
raise ValueError("Retrieval custom metadata time conditions require a valid timestamp") from exc
return self
class KnowledgeFSRetrievalCustomMetadataFilter(BaseModel):
logical_operator: Literal["and", "or"] = Field(
default="and",
validation_alias=AliasChoices("logical_operator", "logicalOperator"),
serialization_alias="logicalOperator",
)
conditions: list[KnowledgeFSRetrievalCustomMetadataCondition] = Field(default_factory=list, max_length=50)
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
class KnowledgeFSRetrievalMetadataFilters(BaseModel):
created_after: str | None = Field(
default=None,
max_length=64,
validation_alias=AliasChoices("created_after", "createdAfter"),
serialization_alias="createdAfter",
)
created_before: str | None = Field(
default=None,
max_length=64,
validation_alias=AliasChoices("created_before", "createdBefore"),
serialization_alias="createdBefore",
)
custom_metadata: KnowledgeFSRetrievalCustomMetadataFilter | None = Field(
default=None,
validation_alias=AliasChoices("custom_metadata", "customMetadata"),
serialization_alias="customMetadata",
)
document_types: list[KnowledgeFSRetrievalFilterValue] | None = Field(
default=None,
max_length=100,
validation_alias=AliasChoices("document_types", "documentTypes"),
serialization_alias="documentTypes",
)
entities: list[KnowledgeFSRetrievalFilterValue] | None = Field(default=None, max_length=100)
freshness_statuses: list[KnowledgeFSRetrievalFilterValue] | None = Field(
default=None,
max_length=100,
validation_alias=AliasChoices("freshness_statuses", "freshnessStatuses"),
serialization_alias="freshnessStatuses",
)
languages: list[KnowledgeFSRetrievalFilterValue] | None = Field(default=None, max_length=100)
node_kinds: list[Literal["chunk", "section", "table", "image", "summary"]] | None = Field(
default=None,
max_length=100,
validation_alias=AliasChoices("node_kinds", "nodeKinds"),
serialization_alias="nodeKinds",
)
source_ids: list[KnowledgeFSRetrievalFilterValue] | None = Field(
default=None,
max_length=100,
validation_alias=AliasChoices("source_ids", "sourceIds"),
serialization_alias="sourceIds",
)
tags: list[KnowledgeFSRetrievalFilterValue] | None = Field(default=None, max_length=100)
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
@field_validator("created_after", "created_before")
@classmethod
def validate_date_filter(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
try:
datetime.fromisoformat(normalized)
except ValueError as exc:
raise ValueError("Retrieval date filters must be valid ISO date strings") from exc
return normalized
@field_validator(
"document_types",
"entities",
"freshness_statuses",
"languages",
"source_ids",
"tags",
)
@classmethod
def normalize_string_filters(cls, values: list[str] | None) -> list[str] | None:
if values is None:
return None
normalized = [value.strip() for value in values]
if any(not value for value in normalized):
raise ValueError("Retrieval filter values must be non-empty")
return list(dict.fromkeys(normalized))
@field_validator("node_kinds")
@classmethod
def deduplicate_node_kinds(
cls,
values: list[Literal["chunk", "section", "table", "image", "summary"]] | None,
) -> list[Literal["chunk", "section", "table", "image", "summary"]] | None:
return None if values is None else list(dict.fromkeys(values))
class KnowledgeFSRetrievalTestPayload(BaseModel):
query: str = Field(default="", max_length=16_000)
query_id: UUID | None = Field(
default=None,
validation_alias=AliasChoices("query_id", "queryId"),
serialization_alias="queryId",
)
query_images: list[KnowledgeFSRetrievalQueryImageReference] = Field(
default_factory=list,
max_length=4,
alias="queryImages",
exclude_if=lambda value: not value,
)
mode: Literal["deep", "fast", "research"] | None = None
include_text: bool = Field(
default=False,
validation_alias=AliasChoices("include_text", "includeText"),
serialization_alias="includeText",
)
filters: KnowledgeFSRetrievalMetadataFilters | None = None
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
@field_validator("query")
@classmethod
def normalize_query(cls, value: str) -> str:
return value.strip()
@model_validator(mode="after")
def validate_query_modality(self) -> KnowledgeFSRetrievalTestPayload:
if not self.query and not self.query_images:
raise ValueError("At least one of query or queryImages is required")
if len({image.upload_file_id for image in self.query_images}) != len(self.query_images):
raise ValueError("queryImages must not contain duplicate uploadFileId values")
return self
class KnowledgeFSRetrievalCitationResponse(ResponseModel):
artifact_hash: str = Field(
min_length=1,
max_length=128,
validation_alias=AliasChoices("artifact_hash", "artifactHash"),
)
document_asset_id: str = Field(
min_length=1,
max_length=512,
validation_alias=AliasChoices("document_asset_id", "documentAssetId"),
)
document_title: str | None = Field(
default=None,
max_length=512,
validation_alias=AliasChoices("document_title", "documentTitle"),
)
document_version: int = Field(
gt=0,
validation_alias=AliasChoices("document_version", "documentVersion"),
)
knowledge_space_name: str | None = Field(
default=None,
max_length=160,
validation_alias=AliasChoices("knowledge_space_name", "knowledgeSpaceName"),
)
logical_document_id: str | None = Field(
default=None,
min_length=1,
max_length=512,
validation_alias=AliasChoices("logical_document_id", "logicalDocumentId"),
)
logical_document_revision: int | None = Field(
default=None,
gt=0,
validation_alias=AliasChoices("logical_document_revision", "logicalDocumentRevision"),
)
section_path: list[str] = Field(
max_length=64,
validation_alias=AliasChoices("section_path", "sectionPath"),
)
page_number: int | None = Field(
default=None,
ge=0,
validation_alias=AliasChoices("page_number", "pageNumber"),
)
start_offset: int | None = Field(
default=None,
ge=0,
validation_alias=AliasChoices("start_offset", "startOffset"),
)
end_offset: int | None = Field(
default=None,
ge=0,
validation_alias=AliasChoices("end_offset", "endOffset"),
)
class KnowledgeFSRetrievalTestItemResponse(ResponseModel):
citation: KnowledgeFSRetrievalCitationResponse
node_id: str = Field(min_length=1, max_length=512, validation_alias=AliasChoices("node_id", "nodeId"))
projection_ids: list[str] = Field(
max_length=128,
validation_alias=AliasChoices("projection_ids", "projectionIds"),
)
# The final score is profile-defined (for example rerank relevance or RRF fusion), so the
# KnowledgeFS contract intentionally guarantees only a finite number rather than [0, 1].
score: float = Field(allow_inf_nan=False)
sources: list[Literal["dense", "fts", "pageindex", "visual"]] = Field(max_length=4)
text: str | None = Field(default=None, max_length=8_192)
class KnowledgeFSRetrievalTestMetricsResponse(ResponseModel):
total_ms: float = Field(ge=0, validation_alias=AliasChoices("total_ms", "totalMs"))
degradation_flags: list[str] = Field(
default_factory=list,
max_length=32,
validation_alias=AliasChoices("degradation_flags", "degradationFlags"),
)
class KnowledgeFSRetrievalTestResponse(ResponseModel):
# AnswerTrace recorded for this run in the space's retrieval history; a failed-retrieval capture
# attaches to it so one record stays per retrieval.
answer_trace_id: str | None = Field(default=None, validation_alias=AliasChoices("answer_trace_id", "answerTraceId"))
items: list[KnowledgeFSRetrievalTestItemResponse] = Field(max_length=100)
metrics: KnowledgeFSRetrievalTestMetricsResponse
mode: Literal["deep", "fast", "research"]
trace_id: str = Field(min_length=1, max_length=512, validation_alias=AliasChoices("trace_id", "traceId"))

View File

@@ -0,0 +1,110 @@
"""Datasource backward invocation through Dify's tenant-bound runtime.
This module is the internal service boundary used by trusted callers such as
KnowledgeFS. It resolves installed provider declarations and Dify-owned
credential references before reaching ``PluginDatasourceManager``; callers
must never provide raw datasource credentials or a plugin-daemon API key.
"""
from collections.abc import Generator
from typing import Any, cast
from pydantic import BaseModel
from core.datasource.datasource_manager import DatasourceManager
from core.datasource.entities.datasource_entities import (
OnlineDriveBrowseFilesRequest,
OnlineDriveDownloadFileRequest,
)
from core.datasource.online_document.online_document_plugin import OnlineDocumentDatasourcePlugin
from core.datasource.online_drive.online_drive_plugin import OnlineDriveDatasourcePlugin
from core.datasource.website_crawl.website_crawl_plugin import WebsiteCrawlDatasourcePlugin
from core.plugin.backwards_invocation.base import BaseBackwardsInvocation
from core.plugin.entities.request import RequestInvokeDatasource
from models.account import Tenant
from models.provider_ids import DatasourceProviderID
from services.datasource_provider_service import DatasourceProviderService
class PluginDatasourceBackwardsInvocation(BaseBackwardsInvocation):
"""Resolve and invoke a datasource without exposing credential material to the caller."""
@classmethod
def invoke(
cls,
*,
user_id: str,
tenant: Tenant,
payload: RequestInvokeDatasource,
) -> Generator[BaseModel | dict[str, Any], None, None]:
"""Yield datasource messages for one validated inner-runtime request."""
provider_id = DatasourceProviderID(payload.provider)
canonical_provider_id = str(provider_id)
controller = DatasourceManager.get_datasource_plugin_provider(
provider_id=canonical_provider_id,
tenant_id=tenant.id,
datasource_type=payload.datasource_type,
)
if controller.entity.provider_type != payload.datasource_type:
raise ValueError("Datasource provider type mismatch")
# Resolving the datasource from the installed declaration prevents a caller
# from dispatching an arbitrary datasource name under a valid plugin ID.
runtime = controller.get_datasource(payload.datasource)
credentials = DatasourceProviderService().get_datasource_credentials(
tenant_id=tenant.id,
provider=provider_id.provider_name,
plugin_id=provider_id.plugin_id,
credential_id=payload.credential_id,
)
if controller.need_credentials and not credentials:
raise ValueError("Datasource credential not found")
if payload.operation == "validate_credentials":
controller.validate_credentials(user_id=user_id, credentials=credentials)
yield {"result": True}
return
runtime.runtime.credentials = credentials
provider_type = runtime.datasource_provider_type()
match payload.operation:
case "get_website_crawl":
website = cast(WebsiteCrawlDatasourcePlugin, runtime)
yield from website.get_website_crawl(
user_id=user_id,
datasource_parameters=payload.datasource_parameters,
provider_type=provider_type,
)
case "get_online_document_pages":
document = cast(OnlineDocumentDatasourcePlugin, runtime)
yield from document.get_online_document_pages(
user_id=user_id,
datasource_parameters=payload.datasource_parameters,
provider_type=provider_type,
)
case "get_online_document_page_content":
if payload.page is None:
raise ValueError("Online-document page input is required")
document = cast(OnlineDocumentDatasourcePlugin, runtime)
yield from document.get_online_document_page_content(
user_id=user_id,
datasource_parameters=payload.page,
provider_type=provider_type,
)
case "online_drive_browse_files":
drive = cast(OnlineDriveDatasourcePlugin, runtime)
yield from drive.online_drive_browse_files(
user_id=user_id,
request=OnlineDriveBrowseFilesRequest.model_validate(payload.request),
provider_type=provider_type,
)
case "online_drive_download_file":
drive = cast(OnlineDriveDatasourcePlugin, runtime)
yield from drive.online_drive_download_file(
user_id=user_id,
request=OnlineDriveDownloadFileRequest.model_validate(payload.request),
provider_type=provider_type,
)
case _:
raise ValueError(f"Unsupported datasource operation: {payload.operation}")

View File

@@ -1,8 +1,12 @@
import tempfile
from binascii import hexlify, unhexlify
from collections.abc import Generator
from collections.abc import Generator, Mapping
from enum import Enum
from operator import itemgetter
from typing import Any
from pydantic import BaseModel
from core.base.tts.audio_mime import get_model_audio_mime_type, inspect_audio_stream
from core.credit_usage import CreditUsageCreatedBy
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
@@ -10,15 +14,22 @@ from core.model_context import with_credit_usage_created_by
from core.model_manager import ModelManager
from core.plugin.backwards_invocation.base import BaseBackwardsInvocation
from core.plugin.entities.request import (
InvokableModelCatalogItem,
InvokableModelCatalogPage,
InvokableModelTokenLimits,
RequestInvokeLLM,
RequestInvokeLLMWithStructuredOutput,
RequestInvokeModeration,
RequestInvokeMultimodalEmbedding,
RequestInvokeRerank,
RequestInvokeSpeech2Text,
RequestInvokeSummary,
RequestInvokeTextEmbedding,
RequestInvokeTTS,
RequestListModels,
)
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from core.plugin.plugin_service import PluginService
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.utils.model_invocation_utils import ModelInvocationUtils
from graphon.model_runtime.entities.llm_entities import (
@@ -35,9 +46,62 @@ from graphon.model_runtime.entities.message_entities import (
)
from graphon.model_runtime.entities.model_entities import ModelType
from models.account import Tenant
from models.provider_ids import ModelProviderID
def _json_compatible(value: Any) -> Any:
"""Convert model-runtime metadata into stable JSON-compatible values."""
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(_json_compatible(key)): _json_compatible(child) for key, child in value.items()}
if isinstance(value, list | tuple | set):
return [_json_compatible(child) for child in value]
return value
class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
@classmethod
def _model_token_limits(
cls, *, tenant_id: str, user_id: str, provider: str, model: str
) -> InvokableModelTokenLimits | None:
"""Resolve the selected model only; listing a catalog must not cause N schema RPCs.
Custom-model settings (including the configured max-token rule) are resolved by the same
tenant-bound runtime as invocation. Never infer an output maximum from a rule's default.
"""
instance = cls._get_bound_model_instance(
tenant_id=tenant_id, user_id=user_id, provider=provider, model_type=ModelType.LLM, model=model
)
schema = instance.get_model_schema()
if schema is None:
return None
def positive_limit(value: Any) -> int | None:
if isinstance(value, bool) or not isinstance(value, int | float):
return None
if not 0 < value <= 1_000_000_000 or value != int(value):
return None
return int(value)
output_rules = [
rule
for rule in schema.parameter_rules
if rule.name in {"max_tokens", "max_output_tokens", "max_completion_tokens", "maxtoken"}
or rule.use_template == "max_tokens"
]
bounded_rules = [(rule, positive_limit(rule.max)) for rule in output_rules]
bounded_rules = [(rule, limit) for rule, limit in bounded_rules if limit is not None]
# Multiple declared aliases must not let the caller bypass the tighter bound.
selected = min(bounded_rules, key=itemgetter(1)) if bounded_rules else None
return InvokableModelTokenLimits(
context_tokens=positive_limit(schema.model_properties.get("context_size")),
max_output_tokens=selected[1] if selected else None,
output_parameter=selected[0].name if selected else None,
)
@staticmethod
@with_credit_usage_created_by(CreditUsageCreatedBy.PLUGIN_API)
def _get_bound_model_instance(
@@ -178,7 +242,30 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
)
# invoke model
response = model_instance.invoke_text_embedding(texts=payload.texts)
response = model_instance.invoke_text_embedding(texts=payload.texts, input_type=payload.input_type)
return response
@classmethod
def invoke_multimodal_embedding(
cls,
user_id: str,
tenant: Tenant,
payload: RequestInvokeMultimodalEmbedding,
):
"""Invoke multimodal embedding through the tenant-bound model instance."""
model_instance = cls._get_bound_model_instance(
tenant_id=tenant.id,
user_id=user_id,
provider=payload.provider,
model_type=payload.model_type,
model=payload.model,
)
response = model_instance.invoke_multimodal_embedding(
multimodel_documents=[document.model_dump(exclude_none=True) for document in payload.documents],
input_type=payload.input_type,
)
return response
@@ -205,6 +292,75 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
return response
@classmethod
def list_models(
cls,
tenant_id: str,
user_id: str,
payload: RequestListModels,
) -> InvokableModelCatalogPage:
"""List only models that are active for the tenant's Dify configuration."""
provider_manager = create_plugin_provider_manager(tenant_id=tenant_id, user_id=user_id)
active_models = provider_manager.get_configurations(tenant_id).get_models(
model_type=payload.model_type,
only_active=True,
)
installed_identities: dict[str, str] = {}
for plugin in PluginService.list(tenant_id):
existing = installed_identities.get(plugin.plugin_id)
if existing is not None and existing != plugin.plugin_unique_identifier:
raise ValueError(f"Ambiguous installed identity for model plugin {plugin.plugin_id}")
installed_identities[plugin.plugin_id] = plugin.plugin_unique_identifier
requested_provider = str(ModelProviderID(payload.provider)) if payload.provider else None
matched_models = [
model
for model in active_models
if (requested_provider is None or model.provider.provider == requested_provider)
and (payload.model is None or model.model == payload.model)
]
matched_models.sort(key=lambda model: (model.provider.provider, model.model))
page_models = matched_models[payload.offset : payload.offset + payload.limit]
items: list[InvokableModelCatalogItem] = []
for model in page_models:
provider_id = ModelProviderID(model.provider.provider)
unique_identifier = installed_identities.get(provider_id.plugin_id)
if unique_identifier is None:
raise ValueError(f"Installed identity not found for active model plugin {provider_id.plugin_id}")
items.append(
InvokableModelCatalogItem(
plugin_id=provider_id.plugin_id,
plugin_unique_identifier=unique_identifier,
provider=provider_id.provider_name,
model=model.model,
model_type=model.model_type,
capabilities={
"deprecated": model.deprecated,
"features": _json_compatible(model.features or []),
"fetchFrom": _json_compatible(model.fetch_from),
"modelProperties": _json_compatible(model.model_properties),
"modelType": model.model_type.value,
"status": _json_compatible(model.status),
},
token_limits=cls._model_token_limits(
tenant_id=tenant_id,
user_id=user_id,
provider=model.provider.provider,
model=model.model,
)
if payload.model and model.model_type == ModelType.LLM
else None,
)
)
next_offset = payload.offset + len(page_models)
return InvokableModelCatalogPage(
items=items,
next_offset=next_offset if next_offset < len(matched_models) else None,
)
@classmethod
def invoke_tts(cls, user_id: str, tenant: Tenant, payload: RequestInvokeTTS):
"""

View File

@@ -6,6 +6,11 @@ from typing import Any, Literal
from flask import Response
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from core.datasource.entities.datasource_entities import (
DatasourceProviderType,
GetOnlineDocumentPageContentRequest,
)
from core.entities.embedding_type import EmbeddingInputType
from core.entities.provider_entities import BasicProviderConfig
from core.plugin.utils.http_parser import deserialize_response
from core.workflow.file_reference import is_canonical_file_reference
@@ -54,6 +59,61 @@ class RequestInvokeTool(BaseModel):
credential_id: str | None = None
DatasourceInvocationOperation = Literal[
"get_online_document_page_content",
"get_online_document_pages",
"get_website_crawl",
"online_drive_browse_files",
"online_drive_download_file",
"validate_credentials",
]
class RequestInvokeDatasource(BaseModel):
"""Invoke one installed datasource using a Dify-owned credential reference.
Raw credentials are intentionally not part of this contract. ``tenant_id`` and
``user_id`` are consumed by the inner-API request context, while the remaining
fields select an installed provider declaration and an operation-specific input.
"""
tenant_id: str = Field(min_length=1, max_length=512)
user_id: str = Field(min_length=1, max_length=512)
provider: str = Field(min_length=1, max_length=768)
datasource: str = Field(min_length=1, max_length=256)
datasource_type: DatasourceProviderType
credential_id: str = Field(min_length=1, max_length=512)
operation: DatasourceInvocationOperation
datasource_parameters: dict[str, Any] = Field(default_factory=dict)
page: GetOnlineDocumentPageContentRequest | None = None
request: dict[str, Any] | None = None
model_config = ConfigDict(extra="forbid")
@model_validator(mode="after")
def validate_operation_payload(self) -> "RequestInvokeDatasource":
expected_type = {
"get_online_document_page_content": DatasourceProviderType.ONLINE_DOCUMENT,
"get_online_document_pages": DatasourceProviderType.ONLINE_DOCUMENT,
"get_website_crawl": DatasourceProviderType.WEBSITE_CRAWL,
"online_drive_browse_files": DatasourceProviderType.ONLINE_DRIVE,
"online_drive_download_file": DatasourceProviderType.ONLINE_DRIVE,
"validate_credentials": self.datasource_type,
}[self.operation]
if self.datasource_type != expected_type:
raise ValueError(f"{self.operation} requires datasource_type {expected_type.value}")
page_required = self.operation == "get_online_document_page_content"
if page_required != (self.page is not None):
raise ValueError("page is required only for get_online_document_page_content")
request_required = self.operation in {"online_drive_browse_files", "online_drive_download_file"}
if request_required != (self.request is not None):
raise ValueError("request is required only for online-drive operations")
return self
class BaseRequestInvokeModel(BaseModel):
provider: str
model: str
@@ -115,6 +175,25 @@ class RequestInvokeTextEmbedding(BaseRequestInvokeModel):
model_type: ModelType = ModelType.TEXT_EMBEDDING
texts: list[str]
input_type: EmbeddingInputType = EmbeddingInputType.DOCUMENT
class MultimodalEmbeddingDocument(BaseModel):
"""A document accepted by a multimodal text-embedding model."""
content: str
content_type: str
file_id: str | None = None
model_config = ConfigDict(extra="forbid")
class RequestInvokeMultimodalEmbedding(BaseRequestInvokeModel):
"""Request to invoke a multimodal text-embedding model."""
model_type: ModelType = ModelType.TEXT_EMBEDDING
documents: list[MultimodalEmbeddingDocument] = Field(min_length=1)
input_type: EmbeddingInputType = EmbeddingInputType.DOCUMENT
class RequestInvokeRerank(BaseRequestInvokeModel):
@@ -125,8 +204,49 @@ class RequestInvokeRerank(BaseRequestInvokeModel):
model_type: ModelType = ModelType.RERANK
query: str
docs: list[str]
score_threshold: float
top_n: int
score_threshold: float | None = None
top_n: int | None = None
class RequestListModels(BaseModel):
"""Tenant-scoped query for models that Dify can invoke."""
model_type: Literal[ModelType.LLM, ModelType.TEXT_EMBEDDING, ModelType.RERANK]
provider: str | None = None
model: str | None = None
offset: int = Field(default=0, ge=0)
limit: int = Field(default=50, ge=1, le=100)
model_config = ConfigDict(protected_namespaces=())
class InvokableModelTokenLimits(BaseModel):
"""Provider-declared limits, not completion defaults or credentials."""
context_tokens: int | None = Field(default=None, gt=0, le=1_000_000_000)
max_output_tokens: int | None = Field(default=None, gt=0, le=1_000_000_000)
output_parameter: str | None = Field(default=None, min_length=1, max_length=128)
class InvokableModelCatalogItem(BaseModel):
"""Installed identity and active Dify capability metadata for one model."""
plugin_id: str
plugin_unique_identifier: str
provider: str
model: str
model_type: ModelType
capabilities: dict[str, Any] = Field(default_factory=dict)
token_limits: InvokableModelTokenLimits | None = None
model_config = ConfigDict(protected_namespaces=())
class InvokableModelCatalogPage(BaseModel):
"""Offset page returned by the internal model catalog endpoint."""
items: list[InvokableModelCatalogItem] = Field(default_factory=list)
next_offset: int | None = None
class RequestInvokeTTS(BaseRequestInvokeModel):

View File

@@ -8,6 +8,7 @@ from core.datasource.entities.datasource_entities import (
OnlineDriveBrowseFilesRequest,
OnlineDriveBrowseFilesResponse,
OnlineDriveDownloadFileRequest,
OnlineDriveDownloadMessage,
WebsiteCrawlMessage,
)
from core.plugin.entities.plugin_daemon import (
@@ -295,7 +296,7 @@ class PluginDatasourceManager(BasePluginClient):
response = self._request_with_plugin_daemon_response_stream(
"POST",
f"plugin/{tenant_id}/dispatch/datasource/online_drive_download_file",
DatasourceMessage,
OnlineDriveDownloadMessage,
data={
"user_id": user_id,
"data": {

View File

@@ -10,7 +10,6 @@ from core.rag.rerank.entity.weight import KeywordSetting, VectorSetting, Weights
from core.rag.rerank.rerank_base import BaseRerankRunner
from core.rag.rerank.rerank_factory import RerankRunnerFactory
from core.rag.rerank.rerank_type import RerankMode
from extensions.otel import trace_span
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError
@@ -53,7 +52,6 @@ class DataPostProcessor:
)
self.reorder_runner = self._get_reorder_runner(reorder_enabled)
@trace_span()
def invoke(
self,
query: str,

View File

@@ -1,10 +1,12 @@
import concurrent.futures
import functools
import logging
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from typing import Any, NotRequired, TypedDict
from flask import Flask, current_app
from opentelemetry import context as otel_context
from sqlalchemy import select
from sqlalchemy.orm import Session, load_only
@@ -24,7 +26,7 @@ from core.rag.rerank.rerank_type import RerankMode
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from core.tools.signature import sign_upload_file_preview_url
from extensions.ext_database import db
from extensions.otel import propagate_context, trace_span
from extensions.otel import trace_span
from graphon.model_runtime.entities.model_entities import ModelType
from models.dataset import (
ChildChunk,
@@ -90,6 +92,20 @@ default_retrieval_model: DefaultRetrievalModelDict = {
logger = logging.getLogger(__name__)
def _propagate_otel_context[**P, R](func: Callable[P, R]) -> Callable[P, R]:
captured_context = otel_context.get_current()
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
token = otel_context.attach(captured_context)
try:
return func(*args, **kwargs)
finally:
otel_context.detach(token)
return wrapper
class RetrievalService:
# Cache precompiled regular expressions to avoid repeated compilation
@classmethod
@@ -123,7 +139,7 @@ class RetrievalService:
if query:
futures.append(
executor.submit(
propagate_context(retrieval_service._retrieve),
_propagate_otel_context(retrieval_service._retrieve),
flask_app=current_app._get_current_object(), # type: ignore
retrieval_method=retrieval_method,
dataset=dataset,
@@ -143,7 +159,7 @@ class RetrievalService:
for attachment_id in attachment_ids:
futures.append(
executor.submit(
propagate_context(retrieval_service._retrieve),
_propagate_otel_context(retrieval_service._retrieve),
flask_app=current_app._get_current_object(), # type: ignore
retrieval_method=retrieval_method,
dataset=dataset,
@@ -804,7 +820,7 @@ class RetrievalService:
if retrieval_method == RetrievalMethod.KEYWORD_SEARCH and query:
futures.append(
executor.submit(
propagate_context(self.keyword_search),
_propagate_otel_context(self.keyword_search),
flask_app=current_app._get_current_object(), # type: ignore
dataset_id=dataset.id,
query=query,
@@ -818,7 +834,7 @@ class RetrievalService:
if query:
futures.append(
executor.submit(
propagate_context(self.embedding_search),
_propagate_otel_context(self.embedding_search),
flask_app=current_app._get_current_object(), # type: ignore
dataset_id=dataset.id,
query=query,
@@ -835,7 +851,7 @@ class RetrievalService:
if attachment_id:
futures.append(
executor.submit(
propagate_context(self.embedding_search),
_propagate_otel_context(self.embedding_search),
flask_app=current_app._get_current_object(), # type: ignore
dataset_id=dataset.id,
query=attachment_id,
@@ -852,7 +868,7 @@ class RetrievalService:
if RetrievalMethod.is_support_fulltext_search(retrieval_method) and query:
futures.append(
executor.submit(
propagate_context(self.full_text_index_search),
_propagate_otel_context(self.full_text_index_search),
flask_app=current_app._get_current_object(), # type: ignore
dataset_id=dataset.id,
query=query,

View File

@@ -1,14 +1,19 @@
from typing import Any
from dify_agent.protocol.knowledge_fs import KnowledgeFsCitation
from pydantic import BaseModel
class RetrievalSourceMetadata(BaseModel):
knowledge_fs_citation: KnowledgeFsCitation | None = None
position: int | None = None
dataset_id: str | None = None
dataset_name: str | None = None
document_id: str | None = None
document_asset_id: str | None = None
document_name: str | None = None
document_revision: int | None = None
document_version: int | None = None
data_source_type: str | None = None
segment_id: str | None = None
retriever_from: str | None = None

View File

@@ -58,7 +58,7 @@ Here is the chat example between human and assistant, inside <example></example>
User:{{"input_text": ["I want to know which company’s email address test@example.com is?"], "metadata_fields": ["filename", "email", "phone", "address"]}}
Assistant:{{"metadata_map": [{{"metadata_field_name": "email", "metadata_field_value": "test@example.com", "comparison_operator": "="}}]}}
User:{{"input_text": "What are the movies with a score of more than 9 in 2024?", "metadata_fields": ["name", "year", "rating", "country"]}}
Assistant:{{"metadata_map": [{{"metadata_field_name": "year", "metadata_field_value": "2024", "comparison_operator": "="}, {{"metadata_field_name": "rating", "metadata_field_value": "9", "comparison_operator": ">"}}]}}
Assistant:{{"metadata_map": [{{"metadata_field_name": "year", "metadata_field_value": "2024", "comparison_operator": "="}}, {{"metadata_field_name": "rating", "metadata_field_value": "9", "comparison_operator": ">"}}]}}
</example>
### User Input
{{"input_text" : "{input_text}", "metadata_fields" : {metadata_fields}}}

View File

@@ -10,6 +10,7 @@ class RBACResourceScope(StrEnum):
APP = "app"
DATASET = "dataset"
KNOWLEDGE_FS = "knowledge_space"
AGENT = "agent"
WORKSPACE = "workspace"
@@ -71,6 +72,14 @@ class RBACPermission(StrEnum):
DATASET_EXTERNAL_CONNECT = "dataset_external_connect"
DATASET_IMPORT_EXPORT_DSL = "dataset_import_export_dsl"
KNOWLEDGE_FS_READ = "knowledge_space_read"
KNOWLEDGE_FS_CREATE = "knowledge_space_create"
KNOWLEDGE_FS_EDIT = "knowledge_space_edit"
KNOWLEDGE_FS_DELETE = "knowledge_space_delete"
KNOWLEDGE_FS_ACCESS_CONFIG = "knowledge_space_access_config"
KNOWLEDGE_FS_DOCUMENT_WRITE = "knowledge_space_document_write"
KNOWLEDGE_FS_QUERY = "knowledge_space_query"
WORKSPACE_MEMBER_MANAGE = "workspace_member_manage"
WORKSPACE_ROLE_MANAGE = "workspace_role_manage"
API_EXTENSION_MANAGE = "api_extension_manage"

View File

@@ -58,6 +58,7 @@ class Tool(ABC):
if self.runtime and self.runtime.runtime_parameters:
tool_parameters.update(self.runtime.runtime_parameters)
# try parse tool parameters into the correct type
tool_parameters = self._transform_tool_parameters_type(tool_parameters)
result = self._invoke(

View File

@@ -2,7 +2,7 @@ from typing import Any
from pydantic import BaseModel, Field
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.plugin.entities.plugin_daemon import CredentialType
from core.tools.entities.tool_entities import ToolInvokeFrom
@@ -20,6 +20,7 @@ class ToolRuntime(BaseModel):
tool_id: str | None = None
invoke_from: InvokeFrom | None = None
tool_invoke_from: ToolInvokeFrom | None = None
dify_run_context: DifyRunContext | None = Field(default=None, exclude=True, repr=False)
credentials: dict[str, Any] = Field(default_factory=dict)
credential_type: CredentialType = Field(default=CredentialType.API_KEY)
runtime_parameters: dict[str, Any] = Field(default_factory=dict)

View File

@@ -1,4 +1,5 @@
- audio
- code
- knowledge_fs
- time
- webscraper

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#155EEF"/>
<path d="M17 17h20c5.5 0 10 4.5 10 10v20H27c-5.5 0-10-4.5-10-10V17Z" fill="white" fill-opacity=".96"/>
<path d="M26 27h12M26 34h12M26 41h7" stroke="#155EEF" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@@ -0,0 +1,9 @@
from typing import Any, override
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class KnowledgeFSProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]) -> None:
_ = (user_id, credentials)

View File

@@ -0,0 +1,12 @@
identity:
author: Dify
name: knowledge_fs
label:
en_US: Agent Knowledge Base
zh_Hans: Agent 知识库
description:
en_US: Run explicitly bound Agent Knowledge Base operations from an Agent or Workflow.
zh_Hans: 从 Agent 或工作流执行显式绑定的 Agent 知识库操作。
icon: icon.svg
tags:
- rag

View File

@@ -0,0 +1,60 @@
from collections.abc import Generator
from typing import Any, override
from pydantic import ValidationError
from sqlalchemy.orm import Session, sessionmaker
from core.tools.builtin_tool.tool import BuiltinTool
from core.tools.entities.tool_entities import ToolInvokeFrom, ToolInvokeMessage
from core.tools.errors import ToolInvokeError
from models.knowledge_fs import KnowledgeFSAppSpaceJoinType
from services.knowledge_fs.app_execution_capability import KnowledgeResourceRef
from services.knowledge_fs.product_dto import KnowledgeFSResearchTaskCreatePayload
from services.knowledge_fs.runtime import create_knowledge_fs_runtime
class KnowledgeFSCreateResearchTaskTool(BuiltinTool):
@override
def _invoke(
self,
session: Session,
user_id: str,
tool_parameters: dict[str, Any],
conversation_id: str | None = None,
app_id: str | None = None,
message_id: str | None = None,
) -> Generator[ToolInvokeMessage, None, None]:
_ = (user_id, conversation_id, app_id, message_id)
run_context = self.runtime.dify_run_context
if run_context is None or self.runtime.tenant_id != run_context.tenant_id:
raise ToolInvokeError("KnowledgeFS requires a trusted Dify run context")
match self.runtime.tool_invoke_from:
case ToolInvokeFrom.AGENT:
caller_kind = KnowledgeFSAppSpaceJoinType.AGENT
case ToolInvokeFrom.WORKFLOW:
caller_kind = KnowledgeFSAppSpaceJoinType.WORKFLOW
case _:
raise ToolInvokeError("KnowledgeFS is only available to Agent and Workflow callers")
try:
resource = KnowledgeResourceRef.model_validate(tool_parameters.get("resource"))
payload_data: dict[str, object] = {
"query": tool_parameters.get("query"),
}
mode = tool_parameters.get("mode")
if mode:
payload_data["mode"] = mode
payload = KnowledgeFSResearchTaskCreatePayload.model_validate(payload_data)
runtime = create_knowledge_fs_runtime(sessionmaker(bind=session.get_bind(), expire_on_commit=False))
response = runtime.app_capabilities.create_research_task(
run_context=run_context,
caller_kind=caller_kind,
resource=resource,
payload=payload,
)
except ToolInvokeError:
raise
except (ValidationError, RuntimeError, ValueError) as exc:
raise ToolInvokeError(str(exc)) from exc
yield self.create_json_message(response.model_dump(mode="json", by_alias=True))

View File

@@ -0,0 +1,74 @@
identity:
name: create_research_task
author: Dify
label:
en_US: Create Agent Knowledge Base Research Task
zh_Hans: 创建 Agent 知识库研究任务
description:
human:
en_US: Create a Research task in an explicitly bound Agent Knowledge Base.
zh_Hans: 在显式绑定的 Agent 知识库中创建研究任务。
llm: Create a durable research task using an explicitly configured Agent Knowledge Base.
parameters:
- name: resource
type: object
required: true
label:
en_US: Agent Knowledge Base
zh_Hans: Agent 知识库
human_description:
en_US: A reference to an Agent Knowledge Base configured by the app author.
zh_Hans: 由应用作者配置的 Agent 知识库引用。
form: form
input_schema:
type: object
additionalProperties: false
properties:
kind:
type: string
const: knowledge_fs
control_space_id:
type: string
minLength: 1
required:
- kind
- control_space_id
- name: query
type: string
required: true
label:
en_US: Research query
zh_Hans: 研究查询
human_description:
en_US: The question the Research task should investigate.
zh_Hans: 研究任务需要调查的问题。
llm_description: The question to investigate with the Agent Knowledge Base.
form: llm
- name: mode
type: select
required: false
label:
en_US: Mode
zh_Hans: 模式
human_description:
en_US: Optional Agent Knowledge Base retrieval mode.
zh_Hans: 可选的 Agent 知识库检索模式。
llm_description: Optional retrieval mode. Use auto unless the task needs a specific mode.
form: llm
options:
- value: auto
label:
en_US: Auto
zh_Hans: 自动
- value: fast
label:
en_US: Fast
zh_Hans: 快速
- value: deep
label:
en_US: Deep
zh_Hans: 深度
- value: research
label:
en_US: Research
zh_Hans: 研究

View File

@@ -39,6 +39,7 @@ from core.workflow.node_runtime import (
DifyToolFileManager,
DifyToolNodeRuntime,
build_dify_llm_file_saver,
build_knowledge_fs_node_dependencies,
)
from core.workflow.nodes.agent.message_transformer import AgentMessageTransformer
from core.workflow.nodes.agent.plugin_strategy_adapter import (
@@ -53,6 +54,10 @@ from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapt
from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder
from core.workflow.nodes.human_input.callback import DifyHITLCallback
from core.workflow.nodes.human_input.entities import HumanInputNodeData as DifyHumanInputNodeData
from core.workflow.nodes.knowledge_retrieval_v2.automatic_metadata_filter import (
KnowledgeFSAutomaticMetadataFilterExtractor,
)
from core.workflow.nodes.knowledge_retrieval_v2.entities import KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE
from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector
from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer
from graphon.entities.base_node_data import BaseNodeData
@@ -491,6 +496,13 @@ class DifyNodeFactory(NodeFactory):
"runtime": self._tool_runtime,
},
BuiltinNodeTypes.AGENT: lambda: self._build_agent_node_init_kwargs(node_class=node_class),
KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE: lambda: {
**build_knowledge_fs_node_dependencies(),
"metadata_filter_extractor": KnowledgeFSAutomaticMetadataFilterExtractor(
credentials_provider=self._llm_credentials_provider,
model_factory=self._llm_model_factory,
),
},
}
node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})()
constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True)

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, overload, override
from flask import current_app
from pydantic import JsonValue
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
@@ -560,6 +561,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
self._run_context.invoke_from,
variable_pool,
)
tool_runtime.runtime.dify_run_context = self._run_context
except ToolNodeError:
raise
except Exception as exc:
@@ -1010,3 +1012,9 @@ def build_dify_llm_file_saver(
file_reference_factory=DifyFileReferenceFactory(run_context),
http_client=http_client,
)
def build_knowledge_fs_node_dependencies() -> dict[str, object]:
"""Resolve the retrieval capabilities registered by the application composition root."""
factory = cast(Callable[[], dict[str, object]], current_app.extensions["knowledge_fs_node_dependencies"])
return factory()

View File

@@ -24,6 +24,7 @@ from clients.agent_backend import (
AgentBackendValidationError,
)
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.app.knowledge_citations import knowledge_sources_from_stream_event
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.session_binding import default_session_binding
@@ -37,7 +38,7 @@ from graphon.enums import (
WorkflowNodeExecutionStatus,
)
from graphon.graph_events import NodeRunPauseRequestedEvent
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
from graphon.node_events import NodeEventBase, NodeRunResult, RunRetrieverResourceEvent, StreamCompletedEvent
from graphon.nodes.base.node import Node
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig, WorkflowOutputRoutes
@@ -360,6 +361,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
process_data=process_data,
metadata=metadata,
)
if metadata.get("knowledge_fs_sources"):
yield RunRetrieverResourceEvent(retriever_resources=metadata["knowledge_fs_sources"], context="")
# None means no post-exit snapshot was produced; leave the previously stored session snapshot untouched.
if (
isinstance(terminal_event, AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent)
@@ -827,6 +830,18 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
if event.event_kind:
agent_backend["last_stream_event_kind"] = event.event_kind
if isinstance(event.data, Mapping):
sources = knowledge_sources_from_stream_event(event.data)
if sources:
current = metadata.setdefault("knowledge_fs_sources", [])
seen = {source["knowledge_fs_citation"]["id"] for source in current}
for source in sources:
if (
source.knowledge_fs_citation
and source.knowledge_fs_citation.id not in seen
and len(current) < 200
):
current.append(source.model_dump(mode="json"))
seen.add(source.knowledge_fs_citation.id)
usage = event.data.get("usage") or event.data.get("model_usage")
if isinstance(usage, Mapping):
agent_backend["usage"] = dict(usage)

View File

@@ -49,7 +49,13 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
)
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured"
reserved_status["knowledge"] = (
"legacy_rebind_required"
if agent_soul.knowledge.sets
else "supported_by_knowledge_fs_cli"
if agent_soul.knowledge.spaces
else "not_configured"
)
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
reserved_status["env"] = "supported_by_shell_bootstrap"

View File

@@ -36,6 +36,7 @@ from dify_agent.layers.shell import (
DifyShellSecretRefConfig,
)
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from dify_agent.protocol.knowledge_fs import KnowledgeFsError
from pydantic import BaseModel, ValidationError
from sqlalchemy.exc import OperationalError
@@ -48,6 +49,7 @@ from clients.agent_backend import (
)
from configs import dify_config
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.app.knowledge_runtime_config import build_knowledge_fs_layer_config
from core.app.llm.model_access import resolve_model_context_window
from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
@@ -220,7 +222,10 @@ class WorkflowAgentRuntimeRequestBuilder:
runtime_config_skills=runtime_config_skills,
)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
knowledge_config = build_knowledge_layer_config(agent_soul)
try:
knowledge_config = build_knowledge_fs_layer_config(agent_soul, run_context=context.dify_context)
except KnowledgeFsError as exc:
raise WorkflowAgentRuntimeRequestBuildError(exc.code, exc.message) from exc
context_window_tokens = resolve_model_context_window(
run_context=context.dify_context,
provider_name=agent_soul.model.model_provider,
@@ -261,6 +266,7 @@ class WorkflowAgentRuntimeRequestBuilder:
agent_config_version_kind="snapshot",
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
trace_id=context.dify_context.trace_session_id,
),
backend_binding_ref=context.backend_binding_ref,
agent_soul_prompt=soul_prompt or None,
@@ -271,7 +277,7 @@ class WorkflowAgentRuntimeRequestBuilder:
),
tools=tool_layers.plugin_tools,
core_tools=tool_layers.core_tools,
knowledge=knowledge_config,
knowledge_fs=knowledge_config,
config_layer_config=config_layer_config,
ask_human_config=build_ask_human_layer_config(agent_soul),
include_shell=dify_config.AGENT_SHELL_ENABLED,

View File

@@ -58,7 +58,7 @@ Here is the chat example between human and assistant, inside <example></example>
User:{{"input_text": ["I want to know which company’s email address test@example.com is?"], "metadata_fields": ["filename", "email", "phone", "address"]}}
Assistant:{{"metadata_map": [{{"metadata_field_name": "email", "metadata_field_value": "test@example.com", "comparison_operator": "="}}]}}
User:{{"input_text": "What are the movies with a score of more than 9 in 2024?", "metadata_fields": ["name", "year", "rating", "country"]}}
Assistant:{{"metadata_map": [{{"metadata_field_name": "year", "metadata_field_value": "2024", "comparison_operator": "="}, {{"metadata_field_name": "rating", "metadata_field_value": "9", "comparison_operator": ">"}}]}}
Assistant:{{"metadata_map": [{{"metadata_field_name": "year", "metadata_field_value": "2024", "comparison_operator": "="}}, {{"metadata_field_name": "rating", "metadata_field_value": "9", "comparison_operator": ">"}}]}}
</example>
### User Input
{{"input_text" : "{input_text}", "metadata_fields" : {metadata_fields}}}

View File

@@ -0,0 +1,5 @@
"""KnowledgeFS-native workflow retrieval node."""
from .knowledge_retrieval_v2_node import KnowledgeRetrievalV2Node
__all__ = ["KnowledgeRetrievalV2Node"]

View File

@@ -0,0 +1,377 @@
"""LLM-driven custom metadata condition extraction for the KnowledgeFS retrieval node.
Mirrors the legacy Knowledge Retrieval node's automatic metadata filtering: the query is
shown to an LLM together with the metadata catalog of the selected Spaces, and the returned
``metadata_map`` is turned into typed KnowledgeFS custom metadata conditions.
"""
from __future__ import annotations
import json
import logging
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol
from pydantic import ValidationError
from core.app.entities.app_invoke_entities import DifyRunContext, ModelConfigWithCredentialsEntity
from core.app.llm.model_access import DifyModelFactory, fetch_model_config
from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy
from core.knowledge_fs.retrieval_contracts import (
KnowledgeFSMetadataFieldResponse,
KnowledgeFSRetrievalCustomMetadataCondition,
)
from core.prompt.advanced_prompt_transform import AdvancedPromptTransform
from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate
from core.prompt.simple_prompt_transform import ModelMode
from core.rag.retrieval.template_prompts import (
METADATA_FILTER_ASSISTANT_PROMPT_1,
METADATA_FILTER_ASSISTANT_PROMPT_2,
METADATA_FILTER_COMPLETION_PROMPT,
METADATA_FILTER_SYSTEM_PROMPT,
METADATA_FILTER_USER_PROMPT_1,
METADATA_FILTER_USER_PROMPT_2,
METADATA_FILTER_USER_PROMPT_3,
)
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from graphon.model_runtime.entities.message_entities import (
PromptMessage,
PromptMessageRole,
TextPromptMessageContent,
)
from graphon.nodes.llm.entities import ModelConfig
from graphon.nodes.llm.protocols import CredentialsProvider
from libs.json_in_md_parser import parse_and_check_json_markdown
from .exc import KnowledgeFSRetrievalConfigurationError
logger = logging.getLogger(__name__)
MetadataFieldType = Literal["string", "number", "time"]
# The extraction prompt is shared with the legacy node and lets the model answer with generic
# operators ("=", ">=", ...). KnowledgeFS validates operators per field type, so every
# extracted operator is normalised to the vocabulary of the catalog field it targets.
_OPERATOR_ALIASES: Mapping[MetadataFieldType, Mapping[str, str]] = {
"number": {
"=": "=",
"==": "=",
"is": "=",
"≠": "≠",
"!=": "≠",
"is not": "≠",
">": ">",
"<": "<",
"≥": "≥",
">=": "≥",
"≤": "≤",
"<=": "≤",
"empty": "empty",
"not empty": "not empty",
},
"string": {
"contains": "contains",
"not contains": "not contains",
"start with": "start with",
"end with": "end with",
"is": "is",
"=": "is",
"==": "is",
"is not": "is not",
"≠": "is not",
"!=": "is not",
"empty": "empty",
"not empty": "not empty",
"in": "in",
"not in": "not in",
},
"time": {
"is": "is",
"=": "is",
"==": "is",
"before": "before",
"<": "before",
"≤": "before",
"<=": "before",
"after": "after",
">": "after",
"≥": "after",
">=": "after",
"empty": "empty",
"not empty": "not empty",
},
}
@dataclass(frozen=True)
class KnowledgeFSMetadataFieldRef:
name: str
type: MetadataFieldType
@dataclass(frozen=True)
class KnowledgeFSMetadataExtraction:
"""Raw LLM extraction output plus the usage it cost."""
metadata_map: list[Mapping[str, Any]]
usage: LLMUsage
model: str
provider: str
@dataclass(frozen=True)
class KnowledgeFSAutomaticMetadataFilterOutcome:
conditions: list[KnowledgeFSRetrievalCustomMetadataCondition]
usage: LLMUsage
applied: bool
reason: str | None = None
field_names: list[str] = field(default_factory=list)
extracted_count: int = 0
model: str | None = None
provider: str | None = None
def as_metrics(self) -> dict[str, Any]:
metrics: dict[str, Any] = {
"applied": self.applied,
"condition_count": len(self.conditions),
"extracted_count": self.extracted_count,
"field_names": list(self.field_names),
"mode": "automatic",
}
if self.reason is not None:
metrics["reason"] = self.reason
if self.model is not None:
metrics["model"] = self.model
if self.provider is not None:
metrics["provider"] = self.provider
return metrics
class KnowledgeFSMetadataFilterExtractor(Protocol):
def extract(
self,
*,
run_context: DifyRunContext,
model_config: ModelConfig,
query: str,
field_names: Sequence[str],
) -> KnowledgeFSMetadataExtraction: ...
def intersect_metadata_fields(
catalogs: Sequence[Sequence[KnowledgeFSMetadataFieldResponse]],
) -> list[KnowledgeFSMetadataFieldRef]:
"""Keep fields that every selected Space declares with the same name and type.
This mirrors the editor's catalog intersection so the LLM only sees fields the shared
retrieval payload can legally filter on in every Space.
"""
if not catalogs:
return []
first, *rest = catalogs
remaining_types = [{item.name: item.type for item in group} for group in rest]
fields: list[KnowledgeFSMetadataFieldRef] = []
seen: set[str] = set()
for item in first:
if item.name in seen:
continue
if all(types.get(item.name) == item.type for types in remaining_types):
seen.add(item.name)
fields.append(KnowledgeFSMetadataFieldRef(name=item.name, type=item.type))
return fields
def build_metadata_filter_prompt(
*,
model_config: ModelConfigWithCredentialsEntity,
mode: str,
field_names: Sequence[str],
query: str,
) -> tuple[list[PromptMessage], list[str]]:
model_mode = ModelMode(mode)
metadata_fields = json.dumps(list(field_names), ensure_ascii=False)
prompt_template: list[ChatModelMessage] | CompletionModelPromptTemplate
if model_mode == ModelMode.CHAT:
prompt_template = [
ChatModelMessage(role=PromptMessageRole.SYSTEM, text=METADATA_FILTER_SYSTEM_PROMPT),
ChatModelMessage(role=PromptMessageRole.USER, text=METADATA_FILTER_USER_PROMPT_1),
ChatModelMessage(role=PromptMessageRole.ASSISTANT, text=METADATA_FILTER_ASSISTANT_PROMPT_1),
ChatModelMessage(role=PromptMessageRole.USER, text=METADATA_FILTER_USER_PROMPT_2),
ChatModelMessage(role=PromptMessageRole.ASSISTANT, text=METADATA_FILTER_ASSISTANT_PROMPT_2),
ChatModelMessage(
role=PromptMessageRole.USER,
text=METADATA_FILTER_USER_PROMPT_3.format(input_text=query, metadata_fields=metadata_fields),
),
]
elif model_mode == ModelMode.COMPLETION:
prompt_template = CompletionModelPromptTemplate(
text=METADATA_FILTER_COMPLETION_PROMPT.format(input_text=query, metadata_fields=metadata_fields)
)
else:
raise ValueError(f"Model mode {model_mode} not support.")
prompt_messages = AdvancedPromptTransform().get_prompt(
prompt_template=prompt_template,
inputs={},
query=query,
files=[],
context=None,
memory_config=None,
memory=None,
model_config=model_config,
)
return prompt_messages, list(model_config.stop)
def llm_result_text(result: LLMResult) -> str:
content = result.message.content
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(item.data for item in content if isinstance(item, TextPromptMessageContent))
return ""
def parse_metadata_map(text: str) -> list[Mapping[str, Any]]:
parsed = parse_and_check_json_markdown(text, [])
if not isinstance(parsed, Mapping):
return []
metadata_map = parsed.get("metadata_map")
if not isinstance(metadata_map, list):
return []
return [item for item in metadata_map if isinstance(item, Mapping)]
def _coerce_value(field_type: MetadataFieldType, value: object) -> str | int | float | None:
if value is None or isinstance(value, bool):
return None
if field_type == "number":
if isinstance(value, (int, float)):
return value
text = str(value).strip()
try:
return int(text)
except ValueError:
pass
try:
number = float(text)
except ValueError:
return None
return number if math.isfinite(number) else None
if field_type == "time":
if isinstance(value, (int, float)):
return value
return str(value).strip()
return value if isinstance(value, str) else str(value)
def to_custom_metadata_conditions(
metadata_map: Sequence[Mapping[str, Any]],
catalog: Sequence[KnowledgeFSMetadataFieldRef],
) -> list[KnowledgeFSRetrievalCustomMetadataCondition]:
"""Convert LLM output into KnowledgeFS conditions, dropping anything outside the catalog contract."""
field_types = {item.name: item.type for item in catalog}
conditions: list[KnowledgeFSRetrievalCustomMetadataCondition] = []
for item in metadata_map:
raw_name = item.get("metadata_field_name")
raw_operator = item.get("comparison_operator")
if not isinstance(raw_name, str) or not isinstance(raw_operator, str):
continue
name = raw_name.strip()
field_type = field_types.get(name)
if field_type is None:
continue
operator = _OPERATOR_ALIASES[field_type].get(raw_operator.strip().lower())
if operator is None:
continue
value = None
if operator not in {"empty", "not empty"}:
value = _coerce_value(field_type, item.get("metadata_field_value"))
try:
condition = KnowledgeFSRetrievalCustomMetadataCondition.model_validate(
{
"name": name,
"field_type": field_type,
"comparison_operator": operator,
"value": value,
}
)
except ValidationError:
logger.info("Dropping automatic metadata condition outside the KnowledgeFS contract: %s", name)
continue
conditions.append(condition)
return conditions
class KnowledgeFSAutomaticMetadataFilterExtractor:
"""Runs the shared metadata extraction prompt against the node's configured LLM."""
def __init__(
self,
*,
credentials_provider: CredentialsProvider,
model_factory: DifyModelFactory,
) -> None:
self._credentials_provider = credentials_provider
self._model_factory = model_factory
def extract(
self,
*,
run_context: DifyRunContext,
model_config: ModelConfig,
query: str,
field_names: Sequence[str],
) -> KnowledgeFSMetadataExtraction:
try:
model_instance, model_config_entity = fetch_model_config(
node_data_model=model_config,
credentials_provider=self._credentials_provider,
model_factory=self._model_factory,
)
except ValueError as exc:
raise KnowledgeFSRetrievalConfigurationError(
"Agent Knowledge Base metadata filtering model is not configured or is unavailable"
) from exc
prompt_messages, stop = build_metadata_filter_prompt(
model_config=model_config_entity,
mode=model_config.mode,
field_names=field_names,
query=query,
)
result = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters=model_config_entity.parameters,
stop=stop,
stream=False,
request_metadata={
"app_id": run_context.app_id,
"app_type": run_context.app_type or CreditUsageAppType.UNKNOWN,
"created_by": CreditUsageCreatedBy.KNOWLEDGE_RETRIEVAL,
},
)
return KnowledgeFSMetadataExtraction(
metadata_map=parse_metadata_map(llm_result_text(result)),
usage=result.usage or LLMUsage.empty_usage(),
model=model_instance.model_name,
provider=model_instance.provider,
)
__all__ = [
"KnowledgeFSAutomaticMetadataFilterExtractor",
"KnowledgeFSAutomaticMetadataFilterOutcome",
"KnowledgeFSMetadataExtraction",
"KnowledgeFSMetadataFieldRef",
"KnowledgeFSMetadataFilterExtractor",
"build_metadata_filter_prompt",
"intersect_metadata_fields",
"llm_result_text",
"parse_metadata_map",
"to_custom_metadata_conditions",
]

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from core.knowledge_fs.retrieval_contracts import KnowledgeFSRetrievalMetadataFilters
from core.rag.entities import RerankingModelConfig, SupportedComparisonOperator
from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import NodeType
from graphon.nodes.llm.entities import ModelConfig
KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE = "knowledge-retrieval-v2"
ControlSpaceId = Annotated[str, Field(min_length=1, max_length=1_000)]
VariableSelectorPart = Annotated[str, Field(min_length=1, max_length=255)]
class KnowledgeRetrievalV2MetadataCondition(BaseModel):
id: str = Field(min_length=1, max_length=255)
metadata_id: str | None = Field(default=None, min_length=1, max_length=512)
metadata_type: Literal["string", "number", "time"]
name: str = Field(min_length=1, max_length=255, pattern=r"^[a-z][a-z0-9_]*$")
comparison_operator: SupportedComparisonOperator
value: str | int | float | None = None
class KnowledgeRetrievalV2MetadataFilteringConditions(BaseModel):
logical_operator: Literal["and", "or"] = "and"
conditions: list[KnowledgeRetrievalV2MetadataCondition] = Field(default_factory=list, max_length=50)
class KnowledgeRetrievalV2NodeData(BaseNodeData):
"""Bounded, KnowledgeFS-native workflow node configuration."""
type: NodeType = KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE
control_space_ids: list[ControlSpaceId] = Field(min_length=1, max_length=10)
query_variable_selector: list[VariableSelectorPart] | None = Field(default=None, max_length=10)
query_attachment_selector: list[VariableSelectorPart] | None = Field(default=None, max_length=10)
mode: Literal["deep", "fast", "research"] | None = None
reranking_model: RerankingModelConfig | None = None
score_threshold: float | None = Field(default=None, ge=0, le=1)
top_n: int = Field(default=10, ge=1, le=100)
metadata_filtering_mode: Literal["disabled", "automatic", "manual"] = "disabled"
metadata_model_config: ModelConfig | None = None
metadata_filtering_conditions: KnowledgeRetrievalV2MetadataFilteringConditions | None = None
metadata_filters: KnowledgeFSRetrievalMetadataFilters | None = None
@field_validator("control_space_ids")
@classmethod
def normalize_control_space_ids(cls, values: list[str]) -> list[str]:
normalized = [value.strip() for value in values]
if any(not value for value in normalized):
raise ValueError("Agent Knowledge Base IDs must be non-empty")
return list(dict.fromkeys(normalized))
@field_validator("query_variable_selector")
@classmethod
def normalize_query_variable_selector(cls, values: list[str] | None) -> list[str] | None:
if values is None:
return None
normalized = [value.strip() for value in values]
if not normalized:
return None
if len(normalized) < 2:
raise ValueError("Agent Knowledge Base query variable selector must contain at least two parts")
if any(not value for value in normalized):
raise ValueError("Agent Knowledge Base query variable selector must be non-empty")
return normalized
@model_validator(mode="after")
def require_query_text_or_image(self) -> KnowledgeRetrievalV2NodeData:
# Either modality can drive retrieval on its own; a node that binds neither can never run.
if not self.query_variable_selector and not self.query_attachment_selector:
raise ValueError("Agent Knowledge Retrieval requires a query text or a query image variable")
return self
@field_validator("query_attachment_selector")
@classmethod
def normalize_query_attachment_selector(cls, values: list[str] | None) -> list[str] | None:
if values is None:
return None
normalized = [value.strip() for value in values]
if not normalized:
return None
if len(normalized) < 2:
raise ValueError("Agent Knowledge Base query attachment selector must contain at least two parts")
if any(not value for value in normalized):
raise ValueError("Agent Knowledge Base query attachment selector must be non-empty")
return normalized
__all__ = [
"KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE",
"KnowledgeRetrievalV2MetadataCondition",
"KnowledgeRetrievalV2MetadataFilteringConditions",
"KnowledgeRetrievalV2NodeData",
]

View File

@@ -0,0 +1,18 @@
class KnowledgeFSRetrievalV2NodeError(RuntimeError):
"""Base deterministic KnowledgeFS v2 node failure."""
class KnowledgeFSRetrievalConfigurationError(KnowledgeFSRetrievalV2NodeError):
"""The node configuration or selected query variable is invalid."""
class KnowledgeFSRetrievalBindingError(KnowledgeFSRetrievalV2NodeError):
"""The app is not authorized for one selected KnowledgeFS Space."""
class KnowledgeFSRetrievalUnavailableError(KnowledgeFSRetrievalV2NodeError):
"""KnowledgeFS could not provide a complete multi-space result."""
class KnowledgeFSRetrievalContractError(KnowledgeFSRetrievalV2NodeError):
"""KnowledgeFS returned a response outside the pinned product contract."""

View File

@@ -0,0 +1,960 @@
"""KnowledgeFS-native evidence retrieval for Workflow and Chatflow."""
from __future__ import annotations
import logging
import math
import time
import uuid
from collections.abc import Generator, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, Protocol, override
from pydantic import ValidationError
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, UserFrom
from core.app.llm.model_access import build_dify_model_access
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError
from core.knowledge_fs.errors import (
QUERY_IMAGE_MAX_COUNT,
QUERY_IMAGE_MAX_TOTAL_BYTES,
KnowledgeFSAppAdmissionError,
KnowledgeFSAppAuthorizationNotReadyError,
KnowledgeFSAppBindingManagementError,
KnowledgeFSAppChannelDisabledError,
KnowledgeFSAppSpaceUnavailableError,
KnowledgeFSOperationUnavailableError,
KnowledgeFSProductRemoteError,
KnowledgeFSProductRequestRejectedError,
KnowledgeFSQueryImageError,
KnowledgeFSWorkflowQueryImageReference,
)
from core.knowledge_fs.resource import (
KnowledgeResourceRef,
)
from core.knowledge_fs.retrieval_contracts import (
KnowledgeFSAppBindingPayload,
KnowledgeFSMetadataFieldListResponse,
KnowledgeFSMetadataFieldResponse,
KnowledgeFSRetrievalCustomMetadataCondition,
KnowledgeFSRetrievalCustomMetadataFilter,
KnowledgeFSRetrievalMetadataFilters,
KnowledgeFSRetrievalQueryImageReference,
KnowledgeFSRetrievalTestItemResponse,
KnowledgeFSRetrievalTestPayload,
KnowledgeFSRetrievalTestResponse,
)
from core.model_manager import ModelInstance, ModelManager
from graphon.entities import GraphInitParams
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from graphon.file import File
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.entities.rerank_entities import RerankResult
from graphon.node_events import NodeRunResult
from graphon.nodes.base.node import Node
from graphon.variables import ArrayFileSegment, FileSegment, StringSegment
from graphon.variables.segments import ArrayObjectSegment, ObjectSegment
from graphon.variables.template_resolution import convert_template
from models.knowledge_fs import KnowledgeFSAppSpaceJoinType
from .automatic_metadata_filter import (
KnowledgeFSAutomaticMetadataFilterExtractor,
KnowledgeFSAutomaticMetadataFilterOutcome,
KnowledgeFSMetadataFilterExtractor,
intersect_metadata_fields,
to_custom_metadata_conditions,
)
from .entities import KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE, KnowledgeRetrievalV2NodeData
from .exc import (
KnowledgeFSRetrievalBindingError,
KnowledgeFSRetrievalConfigurationError,
KnowledgeFSRetrievalContractError,
KnowledgeFSRetrievalUnavailableError,
KnowledgeFSRetrievalV2NodeError,
)
if TYPE_CHECKING:
from graphon.runtime import GraphRuntimeState
logger = logging.getLogger(__name__)
MAX_WORKFLOW_RERANK_CANDIDATES = 100
WORKFLOW_RERANK_POOL_MULTIPLIER = 4
METADATA_CATALOG_PAGE_SIZE = 100
METADATA_CATALOG_MAX_PAGES = 10
def _normalize_metadata_filter_scalar(value: object) -> str | int | float | None:
if value is None or isinstance(value, (str, float)):
return value
if isinstance(value, int) and not isinstance(value, bool):
return value
return str(value)
class QueryImageIssuer(Protocol):
def __call__(self, *, app_id: str, file: File, tenant_id: str) -> KnowledgeFSWorkflowQueryImageReference: ...
class FailedRetrievalDispatcher(Protocol):
def __call__(
self,
*,
tenant_id: str,
app_id: str,
control_space_id: str,
query: str,
mode: Literal["fast", "deep", "research"],
retrieval_trace_id: str,
) -> object: ...
class _RetrievalCapability(Protocol):
def run_retrieval(
self,
*,
run_context: DifyRunContext,
caller_kind: KnowledgeFSAppSpaceJoinType,
resource: KnowledgeResourceRef,
payload: KnowledgeFSRetrievalTestPayload,
) -> KnowledgeFSRetrievalTestResponse: ...
def list_metadata_fields(
self,
*,
run_context: DifyRunContext,
caller_kind: KnowledgeFSAppSpaceJoinType,
resource: KnowledgeResourceRef,
cursor: str | None = None,
limit: int = 100,
) -> KnowledgeFSMetadataFieldListResponse: ...
class _BindingCapability(Protocol):
def upsert(
self,
*,
tenant_id: str,
actor_account_id: str,
control_space_id: str,
payload: KnowledgeFSAppBindingPayload,
) -> object: ...
class _RerankModelManager(Protocol):
def get_default_model_instance(self, tenant_id: str, model_type: ModelType) -> ModelInstance: ...
def get_model_instance(
self,
tenant_id: str,
provider: str,
model_type: ModelType,
model: str,
) -> ModelInstance: ...
@dataclass(frozen=True)
class _WorkflowRerankCandidate:
control_space_id: str
item: KnowledgeFSRetrievalTestItemResponse
item_index: int
space_index: int
class KnowledgeRetrievalV2Node(Node[KnowledgeRetrievalV2NodeData]):
node_type = KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE
def __init__(
self,
node_id: str,
data: KnowledgeRetrievalV2NodeData,
*,
graph_init_params: GraphInitParams,
graph_runtime_state: GraphRuntimeState,
capability_service: _RetrievalCapability | None = None,
binding_service: _BindingCapability | None = None,
rerank_model_manager: _RerankModelManager | None = None,
metadata_filter_extractor: KnowledgeFSMetadataFilterExtractor | None = None,
max_concurrency: int = 4,
query_image_issuer: QueryImageIssuer | None = None,
failed_retrieval_dispatcher: FailedRetrievalDispatcher | None = None,
) -> None:
super().__init__(
node_id=node_id,
data=data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
)
if max_concurrency < 1 or max_concurrency > 10:
raise ValueError("Agent Knowledge Retrieval concurrency must be between 1 and 10")
self._capability_service = capability_service
self._binding_service = binding_service
self._rerank_model_manager = rerank_model_manager
self._metadata_filter_extractor = metadata_filter_extractor
self._max_concurrency = max_concurrency
self._query_image_issuer = query_image_issuer
self._failed_retrieval_dispatcher = failed_retrieval_dispatcher
@classmethod
@override
def version(cls) -> str:
return "1"
@override
def _run(self) -> NodeRunResult:
started_at = time.perf_counter()
usage = LLMUsage.empty_usage()
try:
query = self._resolve_query()
run_context = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
query_images, query_image_inputs = self._resolve_query_images(run_context)
if not query and not query_images:
raise KnowledgeFSRetrievalConfigurationError(
"Agent Knowledge Retrieval requires a query text or at least one query image"
)
self._ensure_draft_bindings(run_context)
filters, automatic_outcome = self._resolved_retrieval_filters(run_context=run_context, query=query)
if automatic_outcome is not None:
usage = automatic_outcome.usage
responses = self._retrieve_all_spaces(
run_context=run_context,
query=query,
query_images=query_images,
filters=filters,
)
result_items, rerank_metrics = self._merge_items(
responses,
query=query,
tenant_id=run_context.tenant_id,
)
if not result_items and all(not response.items for _, response in responses):
self._enqueue_failed_retrieval_captures(
run_context=run_context,
query=query,
responses=responses,
)
metrics = self._aggregate_metrics(
responses,
rerank_metrics=rerank_metrics,
started_at=started_at,
automatic_outcome=automatic_outcome,
)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs={"query": query, **({"query_images": query_image_inputs} if query_image_inputs else {})},
process_data={"knowledge_fs": metrics},
outputs={
"result": ArrayObjectSegment(value=result_items),
"metrics": ObjectSegment(value=metrics),
},
metadata=self._usage_metadata(usage),
llm_usage=usage,
)
except KnowledgeFSRetrievalV2NodeError as exc:
logger.warning("KnowledgeFS v2 retrieval failed: %s", exc)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs={},
error=str(exc),
error_type=type(exc).__name__,
metadata=self._usage_metadata(usage),
llm_usage=usage,
)
@staticmethod
def _usage_metadata(usage: LLMUsage) -> dict[WorkflowNodeExecutionMetadataKey, Any]:
if usage.total_tokens <= 0:
return {}
return {
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: usage.currency,
}
def _resolve_query(self) -> str:
"""Return the bound query text, or an empty string when the node relies on query images."""
selector = self._node_data.query_variable_selector
if not selector:
return ""
variable = self.graph_runtime_state.variable_pool.get(selector)
if variable is None:
return ""
if not isinstance(variable, StringSegment):
raise KnowledgeFSRetrievalConfigurationError("Agent Knowledge Base query variable must be a string")
query = variable.value.strip()
if len(query) > 16_000:
raise KnowledgeFSRetrievalConfigurationError(
"Agent Knowledge Base query variable must contain at most 16000 characters"
)
return query
def _resolve_query_images(
self,
run_context: DifyRunContext,
) -> tuple[list[KnowledgeFSRetrievalQueryImageReference], list[dict[str, object]]]:
selector = self._node_data.query_attachment_selector
if not selector:
return [], []
variable = self.graph_runtime_state.variable_pool.get(selector)
if isinstance(variable, FileSegment):
files = [variable.value]
elif isinstance(variable, ArrayFileSegment):
files = list(variable.value)
else:
raise KnowledgeFSRetrievalConfigurationError(
"Agent Knowledge Base query attachment variable must be a file or array of files"
)
if len(files) > QUERY_IMAGE_MAX_COUNT:
raise KnowledgeFSRetrievalConfigurationError(
f"Agent Knowledge Base query attachments must contain at most {QUERY_IMAGE_MAX_COUNT} images"
)
references: list[KnowledgeFSRetrievalQueryImageReference] = []
inputs: list[dict[str, object]] = []
total_bytes = 0
seen_ids: set[str] = set()
try:
for file in files:
reference = self._issue_query_image_reference(
app_id=run_context.app_id,
file=file,
tenant_id=run_context.tenant_id,
)
if reference.upload_file_id in seen_ids:
raise KnowledgeFSQueryImageError(
"QUERY_IMAGE_REFERENCE_DUPLICATE",
"Workflow query images must not contain duplicate files",
)
seen_ids.add(reference.upload_file_id)
total_bytes += reference.byte_size
if total_bytes > QUERY_IMAGE_MAX_TOTAL_BYTES:
raise KnowledgeFSQueryImageError(
"QUERY_IMAGE_TOTAL_TOO_LARGE",
f"Workflow query images exceed aggregate max bytes {QUERY_IMAGE_MAX_TOTAL_BYTES}",
)
references.append(
KnowledgeFSRetrievalQueryImageReference(
accessGrant=reference.access_grant,
uploadFileId=reference.upload_file_id,
)
)
inputs.append(
{
"filename": file.filename or reference.upload_file_id,
"mime_type": reference.mime_type,
"size": reference.byte_size,
}
)
except KnowledgeFSQueryImageError as exc:
raise KnowledgeFSRetrievalConfigurationError(str(exc)) from exc
return references, inputs
def _issue_query_image_reference(
self, *, app_id: str, file: File, tenant_id: str
) -> KnowledgeFSWorkflowQueryImageReference:
if self._query_image_issuer is None:
raise KnowledgeFSRetrievalConfigurationError("Workflow query-image issuer is not configured")
return self._query_image_issuer(app_id=app_id, file=file, tenant_id=tenant_id)
def _service(self) -> _RetrievalCapability:
if self._capability_service is None:
raise KnowledgeFSRetrievalConfigurationError("Agent Knowledge Retrieval capability is not configured")
return self._capability_service
def _ensure_draft_bindings(self, run_context: DifyRunContext) -> None:
if not run_context.invoke_from.runs_as_account() or run_context.user_from is not UserFrom.ACCOUNT:
return
binding_service = self._binding_service
if binding_service is None:
return
try:
for control_space_id in self._node_data.control_space_ids:
binding_service.upsert(
tenant_id=run_context.tenant_id,
actor_account_id=run_context.user_id,
control_space_id=control_space_id,
payload=KnowledgeFSAppBindingPayload(
app_id=run_context.app_id,
caller_kind=KnowledgeFSAppSpaceJoinType.WORKFLOW,
),
)
except (KnowledgeFSAppBindingManagementError, RuntimeError, ValueError) as exc:
raise KnowledgeFSRetrievalBindingError(
"Agent Knowledge Base draft binding could not be enabled for this workflow"
) from exc
def _retrieve_all_spaces(
self,
*,
run_context: DifyRunContext,
query: str,
query_images: Sequence[KnowledgeFSRetrievalQueryImageReference],
filters: KnowledgeFSRetrievalMetadataFilters | None,
) -> list[tuple[str, KnowledgeFSRetrievalTestResponse]]:
service = self._service()
payload = KnowledgeFSRetrievalTestPayload(
query=query,
query_id=uuid.uuid4(),
queryImages=list(query_images),
mode=self._node_data.mode,
include_text=True,
filters=filters,
)
def retrieve(control_space_id: str) -> KnowledgeFSRetrievalTestResponse:
with self._map_space_errors(control_space_id, action="retrieval"):
return service.run_retrieval(
run_context=run_context,
caller_kind=KnowledgeFSAppSpaceJoinType.WORKFLOW,
resource=KnowledgeResourceRef(
kind="knowledge_fs",
control_space_id=control_space_id,
),
payload=payload,
)
worker_count = min(self._max_concurrency, len(self._node_data.control_space_ids))
with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="knowledge-fs-retrieval") as executor:
futures = [executor.submit(retrieve, space_id) for space_id in self._node_data.control_space_ids]
return [
(space_id, future.result())
for space_id, future in zip(self._node_data.control_space_ids, futures, strict=True)
]
@staticmethod
@contextmanager
def _map_space_errors(control_space_id: str, *, action: str) -> Generator[None]:
try:
yield
except KnowledgeFSAppChannelDisabledError as exc:
raise KnowledgeFSRetrievalBindingError(
"[knowledge_fs_workflow_access_disabled] "
f"Workflow access is disabled for Agent Knowledge Base {control_space_id}; "
"ask a workspace owner to enable the Workflow channel"
) from exc
except KnowledgeFSAppSpaceUnavailableError as exc:
raise KnowledgeFSRetrievalBindingError(
"[knowledge_fs_space_unavailable] "
f"Agent Knowledge Base {control_space_id} is not ready for workflow {action}; "
"select an active, provisioned Agent Knowledge Base"
) from exc
except KnowledgeFSAppAuthorizationNotReadyError as exc:
raise KnowledgeFSRetrievalBindingError(
"[knowledge_fs_authorization_not_ready] "
f"Agent Knowledge Base {control_space_id} permissions are not ready; "
"ask a workspace owner to finish Agent Knowledge Base permission setup"
) from exc
except KnowledgeFSAppAdmissionError as exc:
raise KnowledgeFSRetrievalBindingError(
f"[knowledge_fs_binding_not_enabled] Agent Knowledge Base {control_space_id} "
"is not bound to this workflow"
) from exc
except ValidationError as exc:
raise KnowledgeFSRetrievalContractError(
f"Agent Knowledge Base {control_space_id} returned an invalid {action} response"
) from exc
except KnowledgeFSProductRequestRejectedError as exc:
raise KnowledgeFSRetrievalConfigurationError(
f"Agent Knowledge Base {control_space_id} rejected the {action} request"
) from exc
except (KnowledgeFSOperationUnavailableError, KnowledgeFSProductRemoteError) as exc:
raise KnowledgeFSRetrievalUnavailableError(
f"Agent Knowledge Base {control_space_id} is unavailable"
) from exc
except RuntimeError as exc:
raise KnowledgeFSRetrievalUnavailableError(
f"Agent Knowledge Base {control_space_id} {action} failed"
) from exc
def _resolved_retrieval_filters(
self,
*,
run_context: DifyRunContext,
query: str,
) -> tuple[KnowledgeFSRetrievalMetadataFilters | None, KnowledgeFSAutomaticMetadataFilterOutcome | None]:
filters = self._node_data.metadata_filters.model_copy(deep=True) if self._node_data.metadata_filters else None
mode = self._node_data.metadata_filtering_mode
if mode == "automatic":
if not query:
# Condition extraction is text-only; an image-only query has nothing to extract from.
return filters, KnowledgeFSAutomaticMetadataFilterOutcome(
conditions=[],
usage=LLMUsage.empty_usage(),
applied=False,
reason="empty_query",
)
outcome = self._resolve_automatic_metadata_filter(run_context=run_context, query=query)
if not outcome.conditions:
return filters, outcome
configured = self._node_data.metadata_filtering_conditions
filters = filters or KnowledgeFSRetrievalMetadataFilters()
filters.custom_metadata = KnowledgeFSRetrievalCustomMetadataFilter(
logical_operator=configured.logical_operator if configured else "or",
conditions=outcome.conditions,
)
return filters, outcome
if mode != "manual":
return filters, None
return self._resolve_manual_retrieval_filters(filters), None
def _resolve_automatic_metadata_filter(
self,
*,
run_context: DifyRunContext,
query: str,
) -> KnowledgeFSAutomaticMetadataFilterOutcome:
model_config = self._node_data.metadata_model_config
if model_config is None:
raise KnowledgeFSRetrievalConfigurationError(
"Agent Knowledge Base automatic metadata filtering requires a metadata filtering model"
)
catalog = intersect_metadata_fields(
[
self._list_space_metadata_fields(run_context=run_context, control_space_id=control_space_id)
for control_space_id in self._node_data.control_space_ids
]
)
field_names = [item.name for item in catalog]
if not catalog:
return KnowledgeFSAutomaticMetadataFilterOutcome(
conditions=[],
usage=LLMUsage.empty_usage(),
applied=False,
reason="no_shared_metadata_fields",
)
try:
extraction = self._metadata_filter_extractor_for(run_context).extract(
run_context=run_context,
model_config=model_config,
query=query,
field_names=field_names,
)
except KnowledgeFSRetrievalV2NodeError:
raise
except Exception:
# Match the legacy node: an LLM or parsing failure degrades to unfiltered retrieval
# instead of failing the whole node.
logger.warning("KnowledgeFS automatic metadata filter extraction failed", exc_info=True)
return KnowledgeFSAutomaticMetadataFilterOutcome(
conditions=[],
usage=LLMUsage.empty_usage(),
applied=False,
reason="extraction_failed",
field_names=field_names,
model=model_config.name,
provider=model_config.provider,
)
conditions = to_custom_metadata_conditions(extraction.metadata_map, catalog)
return KnowledgeFSAutomaticMetadataFilterOutcome(
conditions=conditions,
usage=extraction.usage,
applied=bool(conditions),
reason=None if conditions else "no_conditions_extracted",
field_names=field_names,
extracted_count=len(extraction.metadata_map),
model=extraction.model,
provider=extraction.provider,
)
def _metadata_filter_extractor_for(self, run_context: DifyRunContext) -> KnowledgeFSMetadataFilterExtractor:
if self._metadata_filter_extractor is not None:
return self._metadata_filter_extractor
credentials_provider, model_factory = build_dify_model_access(run_context)
return KnowledgeFSAutomaticMetadataFilterExtractor(
credentials_provider=credentials_provider,
model_factory=model_factory,
)
def _list_space_metadata_fields(
self,
*,
run_context: DifyRunContext,
control_space_id: str,
) -> list[KnowledgeFSMetadataFieldResponse]:
service = self._service()
resource = KnowledgeResourceRef(kind="knowledge_fs", control_space_id=control_space_id)
fields: list[KnowledgeFSMetadataFieldResponse] = []
cursor: str | None = None
for _ in range(METADATA_CATALOG_MAX_PAGES):
with self._map_space_errors(control_space_id, action="metadata catalog"):
page = service.list_metadata_fields(
run_context=run_context,
caller_kind=KnowledgeFSAppSpaceJoinType.WORKFLOW,
resource=resource,
cursor=cursor,
limit=METADATA_CATALOG_PAGE_SIZE,
)
fields.extend(page.data)
cursor = page.next_cursor
if not cursor:
return fields
raise KnowledgeFSRetrievalContractError(
f"Agent Knowledge Base {control_space_id} metadata catalog exceeds the supported page count"
)
def _resolve_manual_retrieval_filters(
self,
filters: KnowledgeFSRetrievalMetadataFilters | None,
) -> KnowledgeFSRetrievalMetadataFilters | None:
configured = self._node_data.metadata_filtering_conditions
if not configured or not configured.conditions:
return filters
resolved_conditions: list[KnowledgeFSRetrievalCustomMetadataCondition] = []
for condition in configured.conditions:
value = condition.value
if isinstance(value, str):
segment_group = convert_template(self.graph_runtime_state.variable_pool, value)
if len(segment_group.value) == 1:
value = _normalize_metadata_filter_scalar(segment_group.value[0].to_object())
else:
value = segment_group.text
if value is None and condition.comparison_operator not in {"empty", "not empty"}:
continue
resolved_conditions.append(
KnowledgeFSRetrievalCustomMetadataCondition(
name=condition.name,
field_type=condition.metadata_type,
comparison_operator=condition.comparison_operator,
value=value,
)
)
if not resolved_conditions:
return filters
filters = filters or KnowledgeFSRetrievalMetadataFilters()
filters.custom_metadata = KnowledgeFSRetrievalCustomMetadataFilter(
logical_operator=configured.logical_operator,
conditions=resolved_conditions,
)
return filters
def _merge_items(
self,
responses: Sequence[tuple[str, KnowledgeFSRetrievalTestResponse]],
*,
query: str,
tenant_id: str,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
candidates = self._balanced_candidate_pool(responses)
if not candidates:
return [], {
"applied": False,
"candidate_count": 0,
"duration_ms": 0.0,
"output_count": 0,
"score_threshold": self._node_data.score_threshold,
"source": "custom" if self._node_data.reranking_model else "system-default",
"top_k": self._node_data.top_n,
}
if not query:
# Rerankers score documents against text. An image-only query keeps each space's own
# profile-defined scores and merges the balanced pool on those instead.
return self._merge_without_rerank(candidates)
model_manager = self._rerank_model_manager or ModelManager.for_tenant(tenant_id=tenant_id)
selection = self._node_data.reranking_model
try:
if selection is None:
model_instance = model_manager.get_default_model_instance(tenant_id, ModelType.RERANK)
source = "system-default"
else:
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
provider=selection.provider,
model_type=ModelType.RERANK,
model=selection.model,
)
source = "custom"
except (ModelCurrentlyNotSupportError, ProviderTokenNotInitError, ValueError) as exc:
raise KnowledgeFSRetrievalConfigurationError(
"Workflow rerank model is not configured or is unavailable"
) from exc
documents = [self._rerank_text(candidate.item) for candidate in candidates]
rerank_started_at = time.perf_counter()
try:
result = model_instance.invoke_rerank(
query=query,
docs=documents,
score_threshold=self._node_data.score_threshold,
top_n=min(self._node_data.top_n, len(candidates)),
)
except Exception as exc:
raise KnowledgeFSRetrievalUnavailableError("Workflow rerank model invocation failed") from exc
duration_ms = round(max(0.0, (time.perf_counter() - rerank_started_at) * 1_000), 3)
ranked = self._validate_rerank_result(result=result, candidates=candidates)
threshold = self._node_data.score_threshold
if threshold is not None:
ranked = [row for row in ranked if row[0] >= threshold]
ranked.sort(
key=lambda row: (
-row[0],
row[1].space_index,
row[1].item_index,
row[1].item.node_id,
)
)
ranked = ranked[: self._node_data.top_n]
output = [
self._output_item(
control_space_id=candidate.control_space_id,
item=candidate.item,
score=score,
)
for score, candidate in ranked
]
return output, {
"applied": True,
"candidate_count": len(candidates),
"duration_ms": duration_ms,
"model": model_instance.model_name,
"output_count": len(output),
"provider": model_instance.provider,
"score_threshold": threshold,
"source": source,
"top_k": self._node_data.top_n,
}
def _merge_without_rerank(
self,
candidates: Sequence[_WorkflowRerankCandidate],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
threshold = self._node_data.score_threshold
ranked = [
(candidate.item.score, candidate)
for candidate in candidates
if threshold is None or candidate.item.score >= threshold
]
ranked.sort(
key=lambda row: (
-row[0],
row[1].space_index,
row[1].item_index,
row[1].item.node_id,
)
)
ranked = ranked[: self._node_data.top_n]
output = [
self._output_item(
control_space_id=candidate.control_space_id,
item=candidate.item,
score=score,
)
for score, candidate in ranked
]
return output, {
"applied": False,
"candidate_count": len(candidates),
"duration_ms": 0.0,
"output_count": len(output),
"reason": "image_only_query",
"score_threshold": threshold,
"source": "custom" if self._node_data.reranking_model else "system-default",
"top_k": self._node_data.top_n,
}
def _balanced_candidate_pool(
self,
responses: Sequence[tuple[str, KnowledgeFSRetrievalTestResponse]],
) -> list[_WorkflowRerankCandidate]:
limit = min(
MAX_WORKFLOW_RERANK_CANDIDATES,
max(self._node_data.top_n, self._node_data.top_n * WORKFLOW_RERANK_POOL_MULTIPLIER),
)
candidates: list[_WorkflowRerankCandidate] = []
max_items = max((len(response.items) for _, response in responses), default=0)
for item_index in range(max_items):
for space_index, (control_space_id, response) in enumerate(responses):
if item_index >= len(response.items):
continue
candidates.append(
_WorkflowRerankCandidate(
control_space_id=control_space_id,
item=response.items[item_index],
item_index=item_index,
space_index=space_index,
)
)
if len(candidates) >= limit:
return candidates
return candidates
@staticmethod
def _rerank_text(item: KnowledgeFSRetrievalTestItemResponse) -> str:
text = (item.text or "").strip()
if text:
return text
section_path = " / ".join(part.strip() for part in item.citation.section_path if part.strip())
return section_path or item.node_id
@staticmethod
def _validate_rerank_result(
*,
result: RerankResult,
candidates: Sequence[_WorkflowRerankCandidate],
) -> list[tuple[float, _WorkflowRerankCandidate]]:
ranked: list[tuple[float, _WorkflowRerankCandidate]] = []
seen_indices: set[int] = set()
for document in result.docs:
if document.index in seen_indices or document.index < 0 or document.index >= len(candidates):
raise KnowledgeFSRetrievalContractError("Workflow rerank model returned invalid document indices")
if not math.isfinite(document.score):
raise KnowledgeFSRetrievalContractError("Workflow rerank model returned a non-finite score")
seen_indices.add(document.index)
ranked.append((document.score, candidates[document.index]))
return ranked
def _enqueue_failed_retrieval_captures(
self,
*,
run_context: DifyRunContext,
query: str,
responses: Sequence[tuple[str, KnowledgeFSRetrievalTestResponse]],
) -> None:
"""Best-effort quality capture after every selected space returned no evidence."""
if not query or self._failed_retrieval_dispatcher is None:
# Failed-query triage is text-based; an image-only miss has no query to capture.
return
for control_space_id, response in responses:
try:
self._failed_retrieval_dispatcher(
tenant_id=run_context.tenant_id,
app_id=run_context.app_id,
control_space_id=control_space_id,
query=query,
mode=response.mode,
# Prefer the recorded history trace so the capture attaches to it instead of
# creating a second record; older gateways only return the transport trace.
retrieval_trace_id=response.answer_trace_id or response.trace_id,
)
except Exception:
# The helper owns broker failures in production. Keep a second boundary here so a
# custom/instrumented dispatcher can never turn a successful empty retrieval into
# a failed Workflow node.
logger.exception(
"KnowledgeFS empty-retrieval quality capture dispatch failed",
extra={
"app_id": run_context.app_id,
"control_space_id": control_space_id,
"retrieval_trace_id": response.trace_id,
"tenant_id": run_context.tenant_id,
},
)
@staticmethod
def _output_item(
*,
control_space_id: str,
item: KnowledgeFSRetrievalTestItemResponse,
score: float,
) -> dict[str, Any]:
citation = item.citation
title = citation.document_title or citation.document_asset_id
content = item.text or ""
citation_metadata = {
"artifact_hash": citation.artifact_hash,
"document_id": citation.logical_document_id or citation.document_asset_id,
"document_asset_id": citation.document_asset_id,
"document_version": citation.document_version,
"logical_document_id": citation.logical_document_id,
"logical_document_revision": citation.logical_document_revision,
"section_path": list(citation.section_path),
**({"page_number": citation.page_number} if citation.page_number is not None else {}),
**({"start_offset": citation.start_offset} if citation.start_offset is not None else {}),
**({"end_offset": citation.end_offset} if citation.end_offset is not None else {}),
}
return {
"content": content,
"title": title,
"metadata": {
"citation": citation_metadata,
"node_id": item.node_id,
"projection_ids": list(item.projection_ids),
"score": score,
"space_score": item.score,
"sources": list(item.sources),
"space_id": control_space_id,
# LLM context extraction currently recognizes the legacy Knowledge
# metadata contract. Preserve the native KnowledgeFS citation above
# while providing the compatibility fields needed to emit chat
# retriever resources.
"_source": "knowledge",
"dataset_id": control_space_id,
"dataset_name": citation.knowledge_space_name or control_space_id,
"document_id": citation.logical_document_id or citation.document_asset_id,
"document_asset_id": citation.document_asset_id,
"document_name": title,
"document_revision": citation.logical_document_revision,
"document_version": citation.document_version,
"data_source_type": "knowledge_fs",
"segment_id": item.node_id,
"retriever_from": "workflow",
"segment_word_count": len(content),
"segment_index_node_hash": citation.artifact_hash,
"page": citation.page_number,
},
}
def _aggregate_metrics(
self,
responses: Sequence[tuple[str, KnowledgeFSRetrievalTestResponse]],
*,
rerank_metrics: Mapping[str, Any],
started_at: float,
automatic_outcome: KnowledgeFSAutomaticMetadataFilterOutcome | None = None,
) -> dict[str, Any]:
effective_modes = list(dict.fromkeys(response.mode for _, response in responses))
degradation_flags = list(
dict.fromkeys(flag for _, response in responses for flag in response.metrics.degradation_flags)
)
per_space = [
{
"candidate_count": len(response.items),
"control_space_id": control_space_id,
"degradation_flags": list(response.metrics.degradation_flags),
"mode": response.mode,
"total_ms": response.metrics.total_ms,
"trace_id": response.trace_id,
}
for control_space_id, response in responses
]
metrics: dict[str, Any] = {
"candidate_counts": {control_space_id: len(response.items) for control_space_id, response in responses},
"degradation_flags": degradation_flags,
"effective_modes": effective_modes,
"mode": effective_modes[0] if len(effective_modes) == 1 else "mixed",
"per_space": per_space,
"requested_mode": self._node_data.mode or "space-default",
"total_ms": round(max(0.0, (time.perf_counter() - started_at) * 1_000), 3),
"workflow_rerank": dict(rerank_metrics),
}
if automatic_outcome is not None:
metrics["metadata_filtering"] = automatic_outcome.as_metrics()
return metrics
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,
graph_config: Mapping[str, Any],
node_id: str,
node_data: KnowledgeRetrievalV2NodeData,
) -> Mapping[str, Sequence[str]]:
_ = graph_config
mapping: dict[str, Sequence[str]] = {}
if node_data.query_variable_selector:
mapping[f"{node_id}.query"] = node_data.query_variable_selector
if node_data.query_attachment_selector:
mapping[f"{node_id}.queryAttachment"] = node_data.query_attachment_selector
return mapping
__all__ = ["KnowledgeRetrievalV2Node"]

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import sqlalchemy as sa
from sqlalchemy.orm import Session
from models.knowledge_fs import KnowledgeFSControlSpace, KnowledgeFSControlSpaceState
from .entities import KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE, KnowledgeRetrievalV2NodeData
def collect_control_space_ids(graph: Mapping[str, Any]) -> tuple[str, ...]:
nodes = graph.get("nodes", [])
if not isinstance(nodes, list):
return ()
ordered_ids: list[str] = []
seen: set[str] = set()
for node in nodes:
if not isinstance(node, Mapping):
continue
data = node.get("data")
if not isinstance(data, Mapping) or data.get("type") != KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE:
continue
node_data = KnowledgeRetrievalV2NodeData.model_validate(data)
for control_space_id in node_data.control_space_ids:
if control_space_id not in seen:
seen.add(control_space_id)
ordered_ids.append(control_space_id)
return tuple(ordered_ids)
def missing_control_space_ids(
*,
session: Session,
tenant_id: str,
graph: Mapping[str, Any],
) -> tuple[str, ...]:
requested = collect_control_space_ids(graph)
if not requested:
return ()
existing = set(
session.scalars(
sa.select(KnowledgeFSControlSpace.id).where(
KnowledgeFSControlSpace.tenant_id == tenant_id,
KnowledgeFSControlSpace.id.in_(requested),
KnowledgeFSControlSpace.state == KnowledgeFSControlSpaceState.ACTIVE,
)
)
)
return tuple(control_space_id for control_space_id in requested if control_space_id not in existing)
def validate_control_space_references(
*,
session: Session,
tenant_id: str,
graph: Mapping[str, Any],
) -> None:
missing = missing_control_space_ids(session=session, tenant_id=tenant_id, graph=graph)
if missing:
raise ValueError("Agent Knowledge Bases are missing or inactive in this workspace: " + ", ".join(missing))
__all__ = [
"collect_control_space_ids",
"missing_control_space_ids",
"validate_control_space_references",
]

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

@@ -0,0 +1 @@
"""Development tooling for Dify API contracts and checks."""

View File

@@ -0,0 +1,362 @@
"""Enforce focused KnowledgeFS coverage without hiding integration-critical glue.
The primary threshold aggregates statement and branch coverage for every Dify
module owned by the KnowledgeFS integration. Large pre-existing Dify modules
that only contain narrow integration hooks are checked with changed-line
coverage instead, so unrelated legacy code cannot dilute or inflate the gate.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict, cast
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
NON_CORE_COVERAGE_ALLOWLIST = frozenset(
{
"api/dev/check_knowledge_fs_coverage.py",
"api/dev/generate_knowledge_fs_contract.py",
"api/dev/knowledge_fs_product_contract.py",
"api/migrations/versions/2026_07_21_1200-a4e7c2f91b30_add_knowledge_fs_control_plane.py",
"api/migrations/versions/2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py",
"api/migrations/versions/2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py",
"api/migrations/versions/2026_07_21_1500-d4f6e8a1c305_add_knowledge_fs_remote_freeze_evidence.py",
"api/migrations/versions/2026_07_21_1600-e5a7c9b2d416_add_knowledge_fs_cleanup_completion.py",
"api/migrations/versions/2026_08_10_1200-7c1e9a4b2d60_add_knowledge_fs_staged_uploads.py",
"api/migrations/versions/2026_08_13_1200-9d4e6f8a1b2c_add_knowledge_fs_space_tag_bindings.py",
"api/migrations/versions/2026_08_17_1200-4f8b2c7d9e10_add_knowledge_fs_icon_background.py",
"api/migrations/versions/2026_08_17_1200-f3a8c1d7e920_add_knowledge_fs_upgrade_jobs.py",
"api/migrations/versions/2026_08_18_1200-e6b4a2c9d731_limit_active_knowledge_fs_upgrades.py",
"api/migrations/versions/2026_08_20_1200-c4d8e2f7a1b6_merge_agent_drive_and_knowledge_fs_heads.py",
"api/migrations/versions/2026_09_17_1200-f7c8d9e0a1b2_merge_knowledge_fs_and_email_heads.py",
"api/migrations/versions/2026_09_22_1200-a9c3e7f1b620_merge_knowledge_fs_and_sandbox_usage_heads.py",
}
)
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
logger = logging.getLogger(__name__)
class CoverageSummary(TypedDict):
"""Coverage.py counts required by the aggregate gate."""
covered_lines: int
num_statements: int
covered_branches: int
num_branches: int
class CoverageFile(TypedDict):
"""Per-file coverage data emitted by ``coverage json``."""
executed_lines: list[int]
missing_lines: list[int]
summary: CoverageSummary
class CoverageReport(TypedDict):
"""Relevant top-level shape of a coverage.py JSON report."""
files: dict[str, CoverageFile]
@dataclass(frozen=True, slots=True)
class CoverageTotals:
"""Covered and measurable units for one gate surface."""
covered: int
total: int
@property
def percent(self) -> float:
return 100.0 if self.total == 0 else self.covered * 100 / self.total
class CoverageGateError(RuntimeError):
"""Raised when coverage input is incomplete or below its threshold."""
def main() -> None:
"""Validate focused module coverage and changed integration glue."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser()
parser.add_argument("--coverage-json", type=Path, required=True)
parser.add_argument("--glue-manifest", type=Path, required=True)
parser.add_argument("--workspace-root", type=Path, default=WORKSPACE_ROOT)
parser.add_argument("--base", default="")
parser.add_argument("--minimum", type=float, default=90.0)
parser.add_argument("--glue-minimum", type=float, default=90.0)
args = parser.parse_args()
workspace_root = args.workspace_root.resolve()
report = load_coverage_report(args.coverage_json)
core_totals = validate_core_coverage(report, workspace_root=workspace_root, minimum=args.minimum)
base = resolve_diff_base(workspace_root, args.base)
glue_paths = load_glue_coverage_paths(args.glue_manifest, workspace_root=workspace_root)
changed_lines = collect_changed_glue_lines(workspace_root, base, glue_paths=glue_paths)
glue_totals = validate_changed_glue_coverage(
report,
changed_lines=changed_lines,
minimum=args.glue_minimum,
)
logger.info(
"Dify KnowledgeFS coverage passed: core lines+branches %.2f%% (%d/%d); changed glue lines %.2f%% (%d/%d)",
core_totals.percent,
core_totals.covered,
core_totals.total,
glue_totals.percent,
glue_totals.covered,
glue_totals.total,
)
def load_coverage_report(path: Path) -> CoverageReport:
"""Load the detailed JSON report used by both coverage checks."""
if not path.is_file():
raise CoverageGateError(f"coverage JSON does not exist: {path}")
document = json.loads(path.read_text())
if not isinstance(document, dict) or not isinstance(document.get("files"), dict):
raise CoverageGateError(f"coverage JSON has no files object: {path}")
return cast(CoverageReport, document)
def load_glue_coverage_paths(path: Path, *, workspace_root: Path) -> tuple[str, ...]:
"""Load the workflow's authoritative NUL-delimited integration touchpoints."""
if not path.is_file():
raise CoverageGateError(f"KnowledgeFS glue manifest does not exist: {path}")
try:
paths = tuple(item.decode() for item in path.read_bytes().split(b"\0") if item)
except UnicodeDecodeError as error:
raise CoverageGateError(f"KnowledgeFS glue manifest is not UTF-8: {path}") from error
if not paths:
raise CoverageGateError("Dify KnowledgeFS glue coverage target set is empty")
if len(paths) != len(set(paths)):
raise CoverageGateError("Dify KnowledgeFS glue coverage manifest contains duplicate paths")
invalid_paths = [
candidate
for candidate in paths
if not candidate.startswith("api/")
or not candidate.endswith(".py")
or not (workspace_root / candidate).is_file()
]
if invalid_paths:
raise CoverageGateError(f"Dify KnowledgeFS glue coverage paths are invalid: {', '.join(invalid_paths)}")
return paths
def is_core_coverage_path(path: str) -> bool:
"""Return whether a repository-relative path belongs to the focused aggregate."""
if not path.endswith(".py"):
return False
if path in {
"api/commands/knowledge_fs.py",
"api/configs/extra/knowledge_fs_config.py",
"api/controllers/openapi/knowledge_fs.py",
"api/events/event_handlers/sync_knowledge_fs_bindings_when_app_published_workflow_updated.py",
"api/extensions/ext_knowledge_fs_observability.py",
"api/services/dataset_knowledge_fs_upgrade_file_lease.py",
"api/services/dataset_knowledge_fs_upgrade_service.py",
"api/services/knowledge_fs_capability.py",
}:
return True
if path.startswith(
(
"api/controllers/console/knowledge_fs/",
"api/controllers/inner_api/knowledge_fs/",
"api/controllers/service_api/knowledge_fs/",
"api/core/knowledge_fs/",
"api/core/tools/builtin_tool/providers/knowledge_fs/",
"api/services/knowledge_fs/",
)
):
return True
filename = path.rsplit("/", maxsplit=1)[-1]
return (
path.startswith("api/models/")
and filename.startswith("knowledge_fs")
or path.startswith("api/repositories/")
and "knowledge_fs" in filename
or path.startswith("api/tasks/")
and "knowledge_fs" in filename
)
def discover_core_coverage_paths(workspace_root: Path) -> tuple[str, ...]:
"""Classify every KnowledgeFS-named production file or fail closed."""
named_paths = discover_knowledge_fs_production_paths(workspace_root)
core_paths = {path for path in named_paths if is_core_coverage_path(path)}
unclassified_paths = set(named_paths) - core_paths - NON_CORE_COVERAGE_ALLOWLIST
if unclassified_paths:
raise CoverageGateError(
"unclassified Dify KnowledgeFS production files must join the core coverage scope or explicit allowlist: "
+ ", ".join(sorted(unclassified_paths))
)
if not core_paths:
raise CoverageGateError("Dify KnowledgeFS core coverage target set is empty")
return tuple(sorted(core_paths))
def discover_knowledge_fs_production_paths(workspace_root: Path) -> tuple[str, ...]:
"""Mirror the workflow's dynamic KnowledgeFS filename discovery."""
api_root = workspace_root / "api"
if not api_root.is_dir():
raise CoverageGateError(f"Dify API directory does not exist: {api_root}")
paths: set[str] = set()
for directory, child_directories, filenames in os.walk(api_root):
current_directory = Path(directory)
if current_directory == api_root:
child_directories[:] = [name for name in child_directories if name not in {".venv", "storage", "tests"}]
child_directories[:] = [name for name in child_directories if name != "__pycache__"]
for filename in filenames:
path = (current_directory / filename).relative_to(workspace_root).as_posix()
if filename.endswith(".py") and "knowledge_fs" in path:
paths.add(path)
if not paths:
raise CoverageGateError("Dify KnowledgeFS production target set is empty")
return tuple(sorted(paths))
def validate_core_coverage(
report: CoverageReport,
*,
workspace_root: Path,
minimum: float,
) -> CoverageTotals:
"""Require the exact combined line-and-branch percentage for all core files."""
paths = discover_core_coverage_paths(workspace_root)
missing_paths = [path for path in paths if path not in report["files"]]
if missing_paths:
raise CoverageGateError(f"coverage report is missing core files: {', '.join(missing_paths)}")
covered = 0
total = 0
for path in paths:
summary = report["files"][path]["summary"]
covered += summary["covered_lines"] + summary["covered_branches"]
total += summary["num_statements"] + summary["num_branches"]
if total == 0:
raise CoverageGateError("Dify KnowledgeFS core coverage has no measurable statements or branches")
totals = CoverageTotals(covered=covered, total=total)
_require_minimum(totals, minimum=minimum, label="Dify KnowledgeFS core line-and-branch coverage")
return totals
def resolve_diff_base(workspace_root: Path, preferred: str) -> str:
"""Resolve an explicit event base, falling back to the previous commit for manual runs."""
base = preferred.strip()
if not base or set(base) == {"0"}:
base = "HEAD^"
result = subprocess.run(
["git", "cat-file", "-e", f"{base}^{{commit}}"],
cwd=workspace_root,
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "commit is unavailable"
raise CoverageGateError(f"cannot resolve coverage diff base {base}: {detail}")
return base
def collect_changed_glue_lines(
workspace_root: Path,
base: str,
*,
glue_paths: tuple[str, ...],
) -> dict[str, set[int]]:
"""Return added line numbers in the narrow Dify modules touched by this integration."""
result = subprocess.run(
[
"git",
"diff",
"--no-ext-diff",
"--no-color",
"--unified=0",
base,
"--",
*glue_paths,
],
cwd=workspace_root,
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "git diff failed"
raise CoverageGateError(f"cannot collect KnowledgeFS glue diff from {base}: {detail}")
return parse_added_lines(result.stdout)
def parse_added_lines(diff: str) -> dict[str, set[int]]:
"""Parse repository paths and added-side line numbers from a zero-context Git diff."""
changed_lines: dict[str, set[int]] = {}
current_path: str | None = None
current_line: int | None = None
for raw_line in diff.splitlines():
if raw_line.startswith("diff --git "):
current_line = None
continue
if raw_line.startswith("+++ "):
candidate = raw_line[4:]
current_path = candidate[2:] if candidate.startswith("b/") else None
if current_path is not None:
changed_lines.setdefault(current_path, set())
current_line = None
continue
if raw_line.startswith("@@ "):
match = HUNK_HEADER.match(raw_line)
current_line = int(match.group(1)) if match is not None else None
continue
if current_path is None or current_line is None:
continue
if raw_line.startswith("+"):
changed_lines[current_path].add(current_line)
current_line += 1
elif raw_line.startswith("-") or raw_line.startswith("\\"):
continue
else:
current_line += 1
return changed_lines
def validate_changed_glue_coverage(
report: CoverageReport,
*,
changed_lines: dict[str, set[int]],
minimum: float,
) -> CoverageTotals:
"""Require added executable glue lines to be exercised by the focused unit suite."""
covered = 0
total = 0
for path, lines in sorted(changed_lines.items()):
if not lines:
continue
file_coverage = report["files"].get(path)
if file_coverage is None:
raise CoverageGateError(f"coverage report is missing changed glue file: {path}")
executed_lines = set(file_coverage["executed_lines"])
executable_lines = executed_lines | set(file_coverage["missing_lines"])
changed_executable_lines = lines & executable_lines
covered += len(changed_executable_lines & executed_lines)
total += len(changed_executable_lines)
totals = CoverageTotals(covered=covered, total=total)
_require_minimum(totals, minimum=minimum, label="Dify KnowledgeFS changed-glue line coverage")
return totals
def _require_minimum(totals: CoverageTotals, *, minimum: float, label: str) -> None:
if totals.percent + 1e-12 < minimum:
raise CoverageGateError(f"{label} is {totals.percent:.2f}%; minimum {minimum:.2f}%")
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,8 @@
"""Validate Dify Console KnowledgeFS declarations against a pinned OpenAPI document.
"""Verify Dify against checked-in contracts from an exact standalone KnowledgeFS commit.
The OpenAPI document is exported only during explicit development validation. Runtime declarations live with Dify
product policy; this module validates their transport metadata without generating a complete operation catalog.
CI uses only api/knowledge-fs-contract: no private source checkout, Node runtime, or network
is needed for --check. Intentional updates export from a clean --knowledge-fs-root checkout,
record its full commit/tree provenance, and pin every artifact and Dify operation manifest.
"""
from __future__ import annotations
@@ -12,29 +13,39 @@ import json
import subprocess
import sys
import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any, Literal, TypedDict
from typing import Any, Literal, TypedDict, cast
import jwt
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
from jwt.algorithms import RSAAlgorithm
API_ROOT = Path(__file__).resolve().parents[1]
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from dev.knowledge_fs_product_contract import (
capability_operation_runtime_contracts,
parse_capability_operation_policy,
parse_product_operation_gap_manifest,
parse_product_operation_manifest,
product_operation_runtime_contracts,
validate_product_operation_contracts,
)
WORKSPACE_ROOT = API_ROOT.parent
LOCK_PATH = API_ROOT / "knowledge-fs-contract.lock.json"
DEFAULT_REPOSITORY = WORKSPACE_ROOT.parent / "knowledge-fs"
PIN_RELATIVE_PATH = Path("api/knowledge-fs-contract")
OPENAPI_FILENAME = "knowledge-fs.openapi.json"
CAPABILITY_POLICY_FILENAME = "dify-capability-v2-operations.json"
CAPABILITY_V2_AUTH_MANIFEST_RELATIVE_PATH = Path("contracts/dify-capability-v2-auth-profile.json")
CAPABILITY_V2_AUTH_TEST_VECTOR_RELATIVE_PATH = Path("contracts/dify-capability-v2-test-vector.json")
PROVENANCE_FILENAME = "source-provenance.json"
LOCK_RELATIVE_PATH = Path("api/knowledge-fs-contract.lock.json")
PRODUCT_OPERATIONS_RELATIVE_PATH = Path("api/knowledge-fs-product-operations.json")
PRODUCT_OPERATION_GAPS_RELATIVE_PATH = Path("api/knowledge-fs-product-operation-gaps.json")
OPENAPI_METHODS = ("delete", "get", "head", "options", "patch", "post", "put", "trace")
PROXY_METHODS = frozenset({"delete", "get", "patch", "post", "put"})
CONSOLE_PROXY_ERROR_SCHEMA_NAME = "ConsoleProxyError"
CONSOLE_PROXY_ERROR_SCHEMA: dict[str, Any] = {
"type": "object",
"required": ["code", "message", "status"],
"properties": {
"code": {"type": "string"},
"message": {"type": "string"},
"status": {"type": "integer"},
},
}
LOCK_SCHEMA_VERSION = 6
class ContractDeclaration(TypedDict):
@@ -49,7 +60,21 @@ class ContractDeclaration(TypedDict):
request_headers: tuple[str, ...]
response_headers: tuple[str, ...]
response_media_types: tuple[str, ...]
error_status_map: tuple[tuple[int, int], ...]
class ContractLock(TypedDict):
"""Content-addressed contract inputs that must move together."""
schemaVersion: int
sourceCommit: str
sourceTree: str
sourceProvenanceSha256: str
capabilityPolicySha256: str
openapiSha256: str
capabilityV2AuthManifestSha256: str
capabilityV2AuthTestVectorSha256: str
productOperationManifestSha256: str
productOperationGapManifestSha256: str
type DeclarationField = Literal[
@@ -76,66 +101,433 @@ DECLARATION_FIELDS: tuple[DeclarationField, ...] = (
def main() -> None:
"""Update or verify the pin and validate Console declarations against its OpenAPI document."""
"""Update an exact source pin or verify the self-contained Dify contract boundary."""
parser = argparse.ArgumentParser()
mode = parser.add_mutually_exclusive_group()
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--check", action="store_true")
mode.add_argument("--update-lock", action="store_true")
parser.add_argument("--repository", type=Path, default=DEFAULT_REPOSITORY)
parser.add_argument("--output-openapi", type=Path)
parser.add_argument("--workspace-root", type=Path, default=WORKSPACE_ROOT)
parser.add_argument("--knowledge-fs-root", type=Path, help="Clean standalone source checkout (updates only)")
args = parser.parse_args()
if args.check and args.knowledge_fs_root is not None:
parser.error("--check reads pinned artifacts only; --knowledge-fs-root is for --update-lock")
if args.update_lock and args.knowledge_fs_root is None:
parser.error("--update-lock requires --knowledge-fs-root pointing to a clean committed checkout")
repository = args.repository.resolve()
lock = json.loads(LOCK_PATH.read_text())
tracked_changes = run("git", "status", "--porcelain", "--untracked-files=no", cwd=repository).strip()
if tracked_changes:
raise RuntimeError("KnowledgeFS checkout must not contain tracked changes during contract export")
commit = run("git", "rev-parse", "HEAD", cwd=repository).strip()
if not args.update_lock and commit != lock["commit"]:
raise RuntimeError(
f"KnowledgeFS checkout mismatch: expected {lock['commit']}, received {commit}. "
"Use the pinned commit or pass --update-lock intentionally."
)
with tempfile.TemporaryDirectory(prefix="dify-knowledge-fs-contract-") as directory:
openapi_path = Path(directory) / "knowledge-fs.openapi.json"
subprocess.run(
["pnpm", "openapi:export", "--", "--output", str(openapi_path)],
cwd=repository,
check=True,
)
openapi_content = openapi_path.read_bytes()
openapi_sha256 = sha256(openapi_content)
if not args.update_lock and openapi_sha256 != lock["openapiSha256"]:
raise RuntimeError(
f"KnowledgeFS OpenAPI hash mismatch: expected {lock['openapiSha256']}, received {openapi_sha256}"
)
document: dict[str, Any] = json.loads(openapi_content)
declarations = console_contract_declarations()
validate_declarations(document, declarations)
if args.output_openapi:
filtered_document = filter_openapi_document(document, declarations)
filtered_document["x-dify-source-openapi-sha256"] = openapi_sha256
filtered_document["x-dify-console-declarations-sha256"] = contract_declarations_sha256(declarations)
args.output_openapi.parent.mkdir(parents=True, exist_ok=True)
args.output_openapi.write_text(json.dumps(filtered_document, indent=2) + "\n")
workspace_root = args.workspace_root.resolve()
pin_root = workspace_root / PIN_RELATIVE_PATH
if args.update_lock:
LOCK_PATH.write_text(
json.dumps(
{
"commit": commit,
"openapiSha256": openapi_sha256,
"repository": lock["repository"],
},
indent=2,
with tempfile.TemporaryDirectory(prefix="dify-knowledge-fs-contract-") as directory:
exported_root = Path(directory)
export_contract(args.knowledge_fs_root.resolve(), exported_root)
expected_lock = validate_pinned_contract(workspace_root, exported_root)
pin_root.mkdir(parents=True, exist_ok=True)
for path in exported_root.iterdir():
(pin_root / path.name).write_bytes(path.read_bytes())
(workspace_root / LOCK_RELATIVE_PATH).write_text(json.dumps(expected_lock, indent=2) + "\n")
return
expected_lock = validate_pinned_contract(workspace_root, pin_root)
received_lock = parse_contract_lock(load_json_object(workspace_root / LOCK_RELATIVE_PATH))
for field, expected_value in expected_lock.items():
received_value = dict(received_lock)[field]
if received_value != expected_value:
raise RuntimeError(
f"KnowledgeFS contract lock field {field} drifted: "
f"expected {expected_value!r}, received {received_value!r}. "
"Run --update-lock --knowledge-fs-root intentionally after reviewing source and contract changes."
)
+ "\n"
def export_contract(knowledge_fs_root: Path, output_root: Path) -> None:
"""Export only a clean committed source tree so provenance identifies the actual input."""
ensure_clean_knowledge_fs_worktree(knowledge_fs_root)
provenance = {
"schemaVersion": 1,
"repository": "https://github.com/langgenius/knowledge-fs",
"commit": run("git", "rev-parse", "HEAD", cwd=knowledge_fs_root).strip(),
"tree": run("git", "rev-parse", "HEAD^{tree}", cwd=knowledge_fs_root).strip(),
}
output_root.mkdir(parents=True, exist_ok=True)
for command, filename in (("openapi:export", OPENAPI_FILENAME), ("capability:export", CAPABILITY_POLICY_FILENAME)):
subprocess.run(
["pnpm", command, "--", "--output", str(output_root / filename)], cwd=knowledge_fs_root, check=True
)
for relative_path in (CAPABILITY_V2_AUTH_MANIFEST_RELATIVE_PATH, CAPABILITY_V2_AUTH_TEST_VECTOR_RELATIVE_PATH):
(output_root / relative_path.name).write_bytes((knowledge_fs_root / relative_path).read_bytes())
# Detect source changes made while the export subprocesses ran.
ensure_clean_knowledge_fs_worktree(knowledge_fs_root)
if run("git", "rev-parse", "HEAD", cwd=knowledge_fs_root).strip() != provenance["commit"]:
raise RuntimeError("KnowledgeFS source commit changed during contract export")
(output_root / PROVENANCE_FILENAME).write_text(json.dumps(provenance, indent=2) + "\n")
def ensure_clean_knowledge_fs_worktree(knowledge_fs_root: Path) -> None:
"""Require the standalone checkout's working tree and index to match its commit."""
if run("git", "status", "--porcelain", "--untracked-files=all", cwd=knowledge_fs_root).strip():
raise RuntimeError("KnowledgeFS source contains staged, unstaged or untracked changes; commit before export")
def validate_pinned_contract(workspace_root: Path, pin_root: Path) -> ContractLock:
"""Validate pinned bytes against executable Python declarations without invoking source tools."""
openapi_path = pin_root / OPENAPI_FILENAME
policy_path = pin_root / CAPABILITY_POLICY_FILENAME
auth_path = pin_root / CAPABILITY_V2_AUTH_MANIFEST_RELATIVE_PATH.name
vector_path = pin_root / CAPABILITY_V2_AUTH_TEST_VECTOR_RELATIVE_PATH.name
provenance_path = pin_root / PROVENANCE_FILENAME
product_path = workspace_root / PRODUCT_OPERATIONS_RELATIVE_PATH
gaps_path = workspace_root / PRODUCT_OPERATION_GAPS_RELATIVE_PATH
for path in (openapi_path, policy_path, auth_path, vector_path, provenance_path, product_path, gaps_path):
if not path.is_file():
raise RuntimeError(f"KnowledgeFS pinned contract input is missing: {path}")
auth_manifest = load_json_object(auth_path)
validate_capability_v2_auth_manifest(auth_manifest)
validate_capability_v2_auth_test_vector(load_json_object(vector_path), auth_manifest)
provenance = load_json_object(provenance_path)
validate_upstream_provenance(provenance)
document = load_json_object(openapi_path)
validate_product_operation_contracts(
capability_operations=capability_operation_runtime_contracts(),
capability_policy=parse_capability_operation_policy(load_json_object(policy_path)),
document=document,
gap_manifest=parse_product_operation_gap_manifest(load_json_object(gaps_path)),
manifest=parse_product_operation_manifest(load_json_object(product_path)),
product_operations=product_operation_runtime_contracts(),
)
validate_declarations(document, console_contract_declarations())
return {
"schemaVersion": LOCK_SCHEMA_VERSION,
"sourceCommit": provenance["commit"],
"sourceTree": provenance["tree"],
"sourceProvenanceSha256": sha256(provenance_path.read_bytes()),
"openapiSha256": sha256(openapi_path.read_bytes()),
"capabilityPolicySha256": sha256(policy_path.read_bytes()),
"capabilityV2AuthManifestSha256": sha256(auth_path.read_bytes()),
"capabilityV2AuthTestVectorSha256": sha256(vector_path.read_bytes()),
"productOperationManifestSha256": sha256(product_path.read_bytes()),
"productOperationGapManifestSha256": sha256(gaps_path.read_bytes()),
}
def load_json_object(path: Path) -> dict[str, Any]:
"""Load a JSON object and reject arrays/scalars at contract boundaries."""
value = json.loads(path.read_text())
if not isinstance(value, dict):
raise ValueError(f"KnowledgeFS contract file must contain a JSON object: {path}")
return cast(dict[str, Any], value)
def parse_contract_lock(value: dict[str, Any]) -> ContractLock:
"""Validate the compact, non-self-referential contract lock schema."""
expected_fields = {
"capabilityV2AuthManifestSha256",
"capabilityV2AuthTestVectorSha256",
"openapiSha256",
"productOperationGapManifestSha256",
"productOperationManifestSha256",
"schemaVersion",
"sourceCommit",
"sourceTree",
"sourceProvenanceSha256",
"capabilityPolicySha256",
}
if set(value) != expected_fields:
raise ValueError(f"KnowledgeFS contract lock fields must be exactly {sorted(expected_fields)}")
if value.get("schemaVersion") != LOCK_SCHEMA_VERSION:
raise ValueError(f"KnowledgeFS contract lock schemaVersion must be {LOCK_SCHEMA_VERSION}")
for field in (
"capabilityV2AuthManifestSha256",
"capabilityV2AuthTestVectorSha256",
"openapiSha256",
"productOperationGapManifestSha256",
"productOperationManifestSha256",
"sourceCommit",
"sourceTree",
"sourceProvenanceSha256",
"capabilityPolicySha256",
):
field_value = value.get(field)
expected_length = 40 if field in {"sourceCommit", "sourceTree"} else 64
if (
not isinstance(field_value, str)
or len(field_value) != expected_length
or any(character not in "0123456789abcdef" for character in field_value)
):
raise ValueError(f"KnowledgeFS contract lock field {field} has an invalid digest")
return cast(ContractLock, value)
def validate_required_product_operations(document: dict[str, Any], required_operation_ids: list[str]) -> None:
"""Fail when the pinned KFS OpenAPI omits an operation required by the Dify product."""
available_operation_ids = [
operation_id
for path_item in document.get("paths", {}).values()
for method in OPENAPI_METHODS
if isinstance(path_item, dict)
for operation in (path_item.get(method),)
if isinstance(operation, dict)
for operation_id in (operation.get("operationId"),)
if isinstance(operation_id, str) and operation_id
]
for operation_id in required_operation_ids:
count = available_operation_ids.count(operation_id)
if count != 1:
raise ValueError(
f"KnowledgeFS OpenAPI required product operation {operation_id} must occur exactly once; found {count}"
)
def validate_capability_v2_auth_manifest(value: dict[str, Any]) -> None:
"""Validate the active production RS256 profile consumed by both Dify and KnowledgeFS."""
expected_fields = {
"active",
"audience",
"callerProfiles",
"claimBindings",
"issuer",
"lifecycle",
"maxTtlSeconds",
"productionReady",
"profileId",
"protectedHeader",
"requiredClaims",
"resourceContract",
"runtimeAssembly",
"schemaVersion",
"signatureAlgorithms",
"tokenKind",
}
if set(value) != expected_fields:
raise ValueError(f"KnowledgeFS Capability v2 auth manifest fields must be exactly {sorted(expected_fields)}")
fixed_values = {
"active": True,
"audience": "knowledge-fs",
"issuer": "dify-control-plane",
"lifecycle": "active",
"maxTtlSeconds": 60,
"productionReady": True,
"profileId": "dify-capability-v2",
"schemaVersion": 3,
"signatureAlgorithms": ["RS256"],
"tokenKind": "jwt",
}
for field, expected in fixed_values.items():
if value.get(field) != expected:
raise ValueError(f"KnowledgeFS Capability v2 auth manifest field {field} must be {expected!r}")
if value.get("protectedHeader") != {
"algorithm": "RS256",
"keyIdClaim": "kid",
"keyIdRequired": True,
"type": "JWT",
}:
raise ValueError("KnowledgeFS Capability v2 protected header contract is invalid")
required_claims = [
"action",
"actor",
"aud",
"authz_revision",
"azp",
"caller_kind",
"cap_ver",
"content_policy_revision",
"content_scope_ids",
"control_space_id",
"exp",
"grant_id",
"iat",
"iss",
"jti",
"namespace_id",
"nbf",
"resource",
"sub",
"trace_id",
]
if value.get("requiredClaims") != required_claims:
raise ValueError("KnowledgeFS Capability v2 required claims are invalid")
if value.get("claimBindings") != {
"action": "action",
"callerKind": "caller_kind",
"controlSpace": "control_space_id",
"namespace": "namespace_id",
"resource": "resource",
"resourceParent": "resource.parent_id",
"subject": "sub",
}:
raise ValueError("KnowledgeFS Capability v2 claim bindings are invalid")
if value.get("resourceContract") != {
"fields": ["id", "parent_id", "type"],
"parentForbiddenFor": ["namespace", "knowledge_space"],
"parentRequiredFor": ["document", "job", "query", "research_task", "source", "upload_session"],
}:
raise ValueError("KnowledgeFS Capability v2 resource contract is invalid")
if value.get("callerProfiles") != {
"agent": {"authorizedParty": "dify-agent", "subjectPrefix": "dify-app:"},
"interactive": {"authorizedParty": "dify-console", "subjectPrefix": "dify-account:"},
"internal_worker": {"authorizedParty": "dify-worker", "subjectPrefix": "dify-worker:"},
"mcp": {"authorizedParty": "dify-mcp", "subjectPrefix": "dify-mcp-session:"},
"service": {"authorizedParty": "dify-service-api", "subjectPrefix": "dify-dataset-api-key:"},
"workflow": {"authorizedParty": "dify-workflow", "subjectPrefix": "dify-app:"},
}:
raise ValueError("KnowledgeFS Capability v2 caller profiles are invalid")
if value.get("runtimeAssembly") != {
"failClosed": True,
"keySelection": "kid",
"maximumPublishedKeys": 3,
"verificationKeySource": "jwks",
}:
raise ValueError("KnowledgeFS Capability v2 runtime assembly is invalid")
def validate_capability_v2_auth_test_vector(
value: dict[str, Any],
manifest: dict[str, Any],
) -> None:
"""Verify the deterministic public-key vector and every security-sensitive binding."""
expected_fields = {
"algorithm",
"audience",
"expectedClaims",
"expectedPrincipal",
"issuer",
"operation",
"profileId",
"protectedHeader",
"publicJwk",
"schemaVersion",
"testOnly",
"token",
"ttlSeconds",
}
if set(value) != expected_fields:
raise ValueError(f"KnowledgeFS Capability v2 test vector fields must be exactly {sorted(expected_fields)}")
if (
value.get("schemaVersion") != 2
or value.get("profileId") != manifest.get("profileId")
or value.get("testOnly") is not True
or value.get("algorithm") != "RS256"
or value.get("issuer") != manifest.get("issuer")
or value.get("audience") != manifest.get("audience")
or value.get("ttlSeconds") != manifest.get("maxTtlSeconds")
):
raise ValueError("KnowledgeFS Capability v2 test vector does not match the active profile")
protected_header = value.get("protectedHeader")
if not isinstance(protected_header, dict) or set(protected_header) != {"alg", "kid", "typ"}:
raise ValueError("KnowledgeFS Capability v2 test vector protected header is invalid")
kid = protected_header.get("kid")
if protected_header.get("alg") != "RS256" or protected_header.get("typ") != "JWT" or not _is_non_blank(kid):
raise ValueError("KnowledgeFS Capability v2 test vector protected header is invalid")
public_jwk = value.get("publicJwk")
if not isinstance(public_jwk, dict) or set(public_jwk) != {"alg", "e", "kid", "kty", "n", "use"}:
raise ValueError("KnowledgeFS Capability v2 test vector public JWK is invalid")
if (
public_jwk.get("alg") != "RS256"
or public_jwk.get("kid") != kid
or public_jwk.get("kty") != "RSA"
or public_jwk.get("use") != "sig"
or not _is_non_blank(public_jwk.get("e"))
or not _is_non_blank(public_jwk.get("n"))
):
raise ValueError("KnowledgeFS Capability v2 test vector public JWK is invalid")
claims = value.get("expectedClaims")
required_claims = manifest.get("requiredClaims")
if not isinstance(claims, dict) or not isinstance(required_claims, list) or set(claims) != set(required_claims):
raise ValueError("KnowledgeFS Capability v2 test vector claims do not match the active profile")
operation = value.get("operation")
if not isinstance(operation, dict) or set(operation) != {"action", "method", "operationId", "requestPath"}:
raise ValueError("KnowledgeFS Capability v2 test vector operation is invalid")
resource = claims.get("resource")
if not isinstance(resource, dict) or set(resource) != {"id", "parent_id", "type"}:
raise ValueError("KnowledgeFS Capability v2 test vector resource is invalid")
expected_operation = {
"action": "documents.read",
"method": "GET",
"operationId": "getDocument",
"requestPath": "/knowledge-spaces/space-contract-vector/documents/document-contract-vector",
}
if operation != expected_operation:
raise ValueError("KnowledgeFS Capability v2 test vector operation binding is invalid")
exact_claims = {
"action": operation["action"],
"aud": value["audience"],
"caller_kind": "interactive",
"cap_ver": 2,
"control_space_id": "control-space-contract-vector",
"iss": value["issuer"],
"namespace_id": "workspace-contract-vector",
"resource": {
"id": "document-contract-vector",
"parent_id": "space-contract-vector",
"type": "document",
},
"sub": "dify-account:account-contract-vector",
}
for field, expected in exact_claims.items():
if claims.get(field) != expected:
raise ValueError(f"KnowledgeFS Capability v2 test vector claim {field} is invalid")
if claims.get("actor") != claims["sub"] or claims.get("azp") != "dify-console":
raise ValueError("KnowledgeFS Capability v2 test vector caller binding is invalid")
issued_at = claims.get("iat")
not_before = claims.get("nbf")
expires_at = claims.get("exp")
if (
not isinstance(issued_at, int)
or isinstance(issued_at, bool)
or not_before != issued_at
or not isinstance(expires_at, int)
or isinstance(expires_at, bool)
or expires_at - issued_at != value["ttlSeconds"]
):
raise ValueError("KnowledgeFS Capability v2 test vector TTL is invalid")
expected_principal = {
"callerKind": claims["caller_kind"],
"subject": {
"scopes": ["knowledge-spaces:read"],
"subjectId": claims["sub"],
"tenantId": claims["namespace_id"],
},
}
if value.get("expectedPrincipal") != expected_principal:
raise ValueError("KnowledgeFS Capability v2 test vector principal is invalid")
token = value.get("token")
if not isinstance(token, str) or not _is_non_blank(token):
raise ValueError("KnowledgeFS Capability v2 test vector token is invalid")
try:
verification_key = RSAAlgorithm.from_jwk(public_jwk)
if not isinstance(verification_key, RSAPublicKey):
raise ValueError("Capability vector verification key is not RSA public material")
header = jwt.get_unverified_header(token)
decoded_claims = jwt.decode(
token,
verification_key,
algorithms=["RS256"],
audience=cast(str, value["audience"]),
issuer=cast(str, value["issuer"]),
options={"verify_exp": False, "verify_iat": False, "verify_nbf": False},
)
except (jwt.PyJWTError, TypeError, ValueError) as exc:
raise ValueError("KnowledgeFS Capability v2 test vector signature is invalid") from exc
if header != protected_header or decoded_claims != claims:
raise ValueError("KnowledgeFS Capability v2 test vector token content drifted")
def _is_non_blank(value: object) -> bool:
return isinstance(value, str) and bool(value.strip()) and value == value.strip()
def validate_upstream_provenance(value: dict[str, Any]) -> None:
"""Validate exact standalone source provenance covered by the schema-6 lock."""
expected_fields = {"commit", "tree", "repository", "schemaVersion"}
if set(value) != expected_fields or value.get("schemaVersion") != 1:
raise ValueError("KnowledgeFS source provenance must use schemaVersion 1 with commit, tree and repository")
if value.get("repository") != "https://github.com/langgenius/knowledge-fs":
raise ValueError("KnowledgeFS source provenance repository does not match the contract owner")
for field in ("commit", "tree"):
digest = value.get(field)
if not isinstance(digest, str) or len(digest) != 40 or any(c not in "0123456789abcdef" for c in digest):
raise ValueError(f"KnowledgeFS source provenance {field} must be a lowercase full Git SHA")
def validate_declarations(document: dict[str, Any], declarations: tuple[ContractDeclaration, ...]) -> None:
@@ -168,10 +560,11 @@ def validate_declarations(document: dict[str, Any], declarations: tuple[Contract
raise ValueError(f"KnowledgeFS OpenAPI path must be absolute: {path}")
if method not in PROXY_METHODS:
raise ValueError(f"KnowledgeFS proxy does not support {method.upper()} {path}")
expected: dict[DeclarationField, object] = {
expected: ContractDeclaration = {
"operation_id": operation_id,
"method": method.upper(),
"path": path[1:],
"required_scope": required_scope(operation),
"required_scope": required_scope(document, operation),
"response_kind": response_kind(operation),
"max_response_bytes": required_max_response_bytes(operation),
"request_headers": request_header_names(path_item, operation),
@@ -186,136 +579,12 @@ def validate_declarations(document: dict[str, Any], declarations: tuple[Contract
f"KnowledgeFS operation {operation_id} field {field} drifted: "
f"expected {expected_value!r}, received {received_value!r}"
)
validate_error_status_map(operation_id, declaration["error_status_map"])
def filter_openapi_document(
document: dict[str, Any],
declarations: tuple[ContractDeclaration, ...],
) -> dict[str, Any]:
"""Return a code-generation document containing only Console-allowlisted operations."""
filtered_document: dict[str, Any] = {
key: value for key, value in document.items() if key not in {"components", "paths"}
}
source_paths = document.get("paths", {})
filtered_paths: dict[str, Any] = {}
for declaration in declarations:
path = f"/{declaration['path']}"
method = declaration["method"].lower()
source_path_item = source_paths[path]
path_metadata = {key: value for key, value in source_path_item.items() if key not in OPENAPI_METHODS}
filtered_path_item = filtered_paths.setdefault(path, path_metadata)
filtered_operation = deepcopy(source_path_item[method])
_rewrite_proxy_error_responses(filtered_operation, declaration["error_status_map"])
filtered_path_item[method] = filtered_operation
filtered_document["paths"] = filtered_paths
source_components = document.get("components", {})
filtered_components = {key: value for key, value in source_components.items() if key != "schemas"}
source_schemas = source_components.get("schemas", {})
available_schemas = {**source_schemas, CONSOLE_PROXY_ERROR_SCHEMA_NAME: CONSOLE_PROXY_ERROR_SCHEMA}
schema_names = _referenced_schema_names(filtered_paths, available_schemas)
filtered_components["schemas"] = {
name: schema for name, schema in available_schemas.items() if name in schema_names
}
filtered_document["components"] = filtered_components
return filtered_document
def validate_error_status_map(operation_id: str, error_status_map: tuple[tuple[int, int], ...]) -> None:
"""Validate the status normalization advertised by one Console operation."""
upstream_statuses: set[int] = set()
for upstream_status, console_status in error_status_map:
if upstream_status in upstream_statuses:
raise ValueError(f"KnowledgeFS operation {operation_id} has duplicate error status: {upstream_status}")
if not 400 <= upstream_status <= 599 or not 400 <= console_status <= 599:
raise ValueError(f"KnowledgeFS operation {operation_id} has invalid error status mapping")
upstream_statuses.add(upstream_status)
def _rewrite_proxy_error_responses(
operation: dict[str, Any],
error_status_map: tuple[tuple[int, int], ...],
) -> None:
responses = operation.setdefault("responses", {})
proxy_error_response = {
"description": "Error normalized by the Dify Console KnowledgeFS proxy.",
"content": {
"application/json": {"schema": {"$ref": f"#/components/schemas/{CONSOLE_PROXY_ERROR_SCHEMA_NAME}"}}
},
}
for upstream_status, console_status in error_status_map:
existing_target = responses.get(str(console_status)) if upstream_status != console_status else None
responses.pop(str(upstream_status), None)
normalized_response: dict[str, Any] = deepcopy(proxy_error_response)
existing_schema = (
existing_target.get("content", {}).get("application/json", {}).get("schema")
if isinstance(existing_target, dict)
else None
)
if existing_schema is not None:
normalized_response["content"]["application/json"]["schema"] = {
"oneOf": [
deepcopy(existing_schema),
{"$ref": f"#/components/schemas/{CONSOLE_PROXY_ERROR_SCHEMA_NAME}"},
]
}
responses[str(console_status)] = normalized_response
def _referenced_schema_names(value: Any, schemas: dict[str, Any]) -> set[str]:
reference_prefix = "#/components/schemas/"
selected: set[str] = set()
pending: list[Any] = [value]
while pending:
current = pending.pop()
if isinstance(current, list):
pending.extend(current)
continue
if not isinstance(current, dict):
continue
reference = current.get("$ref")
if isinstance(reference, str) and reference.startswith(reference_prefix):
name = reference.removeprefix(reference_prefix)
if name not in selected:
if name not in schemas:
raise ValueError(f"KnowledgeFS OpenAPI references missing schema: {name}")
selected.add(name)
pending.append(schemas[name])
pending.extend(current.values())
return selected
def console_contract_declarations() -> tuple[ContractDeclaration, ...]:
"""Return transport declarations from the runtime Console operation registry."""
from services.knowledge_fs_operations import KNOWLEDGE_FS_CONSOLE_OPERATIONS
"""The P9 backend exposes only typed product controllers; the raw Console proxy is removed."""
return tuple(
{
"operation_id": operation.operation_id,
"method": operation.method,
"path": operation.path,
"required_scope": operation.required_scope,
"response_kind": operation.response_kind,
"max_response_bytes": operation.max_response_bytes,
"request_headers": operation.request_headers,
"response_headers": operation.response_headers,
"response_media_types": operation.response_media_types,
"error_status_map": operation.error_status_map,
}
for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS
)
def contract_declarations_sha256(declarations: tuple[ContractDeclaration, ...]) -> str:
"""Return a stable digest for the runtime Console operation declarations."""
content = json.dumps(declarations, separators=(",", ":"), sort_keys=True).encode()
return sha256(content)
return ()
def response_kind(operation: dict[str, Any]) -> str:
@@ -335,12 +604,17 @@ def response_media_types(operation: dict[str, Any]) -> tuple[str, ...]:
return tuple(sorted(media_types))
def required_scope(operation: dict[str, Any]) -> str | None:
def required_scope(document: dict[str, Any], operation: dict[str, Any]) -> str | None:
scope = operation.get("x-knowledge-fs-required-scope")
security = operation["security"] if "security" in operation else document.get("security")
if security == []:
if scope is not None:
raise ValueError(f"KnowledgeFS public operation must not declare a required scope: {scope}")
return None
if security != [{"bearerAuth": []}]:
raise ValueError(f"KnowledgeFS operation effective security must be exactly bearerAuth: {security}")
if scope in ("knowledge-spaces:read", "knowledge-spaces:write"):
return scope
if operation.get("security") == []:
return None
raise ValueError(f"KnowledgeFS operation has no supported required scope: {scope}")

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