From 14e5afa54d7f2efa099f8a04e02bd9f9ac79ed6f Mon Sep 17 00:00:00 2001 From: Jyong Date: Tue, 11 Aug 2026 05:52:22 -0400 Subject: [PATCH] Update application components and supporting logic --- api/controllers/console/datasets/datasets.py | 22 +- api/services/api_token_service.py | 35 +- .../console/datasets/test_datasets.py | 12 +- .../test_knowledge_fs_product_controllers.py | 9 +- .../services/test_api_token_service.py | 44 +++ .../test_knowledge_fs_credential_service.py | 6 + ...test_knowledge_fs_staged_upload_service.py | 46 +++ ...n-question-csv-empty-space-draft-import.md | 29 ++ .../api/src/gateway-golden-question.test.ts | 72 ++++ .../api/src/golden-question-handlers.ts | 29 +- .../knowledge-fs-detailed-test-cases.md | 345 ++++++++++++++++++ .../knowledge-fs-functional-test-report.md | 292 +++++++++++++++ .../2026-08-11/test-data/kf-qa-duplicate.txt | 5 + .../2026-08-11/test-data/kf-qa-marker.txt | 8 + .../test-data/kf-qa-reference-questions.csv | 4 + .../test-data/kf-qa-unicode-中文.md | 6 + .../test-data/kf-qa-unsupported.exe | 2 + .../external-api/external-api-modal/Form.tsx | 11 + .../__tests__/index.spec.tsx | 21 +- .../external-api/external-api-modal/index.tsx | 34 +- .../__tests__/create-knowledge-page.spec.tsx | 56 +++ .../__tests__/document-detail-page.spec.tsx | 65 +++- .../new-rag/__tests__/documents-page.spec.tsx | 155 +++++++- .../__tests__/knowledge-fs-upload.spec.ts | 10 +- .../knowledge-settings-form.spec.tsx | 81 +++- .../__tests__/knowledge-space-shell.spec.tsx | 55 ++- .../__tests__/new-knowledge-list.spec.tsx | 29 ++ .../new-rag/__tests__/quality-page.spec.tsx | 41 ++- .../__tests__/retrieval-test-page.spec.tsx | 233 +++++++++++- .../__tests__/website-crawl-preview.spec.tsx | 32 +- .../knowledge-fs-api-access-dialog.spec.tsx | 242 ++++++++++++ .../knowledge-fs-api-access-dialog.tsx | 324 ++++++++++++++++ .../new-rag/create-knowledge-page.tsx | 34 +- web/features/new-rag/create-upload-queue.tsx | 3 +- .../new-rag/document-detail-queries.ts | 2 + .../new-rag/document-metadata-card.tsx | 53 +-- .../new-rag/document-metadata-model.ts | 2 + .../new-rag/document-upload-policy.ts | 3 +- web/features/new-rag/documents-page.tsx | 96 ++++- web/features/new-rag/knowledge-fs-upload.ts | 22 +- .../new-rag/knowledge-settings-form.tsx | 62 +++- .../new-rag/knowledge-space-shell.tsx | 19 +- web/features/new-rag/new-knowledge-list.tsx | 14 + .../quality/golden-question-dialog.tsx | 43 ++- web/features/new-rag/retrieval-test-page.tsx | 85 ++++- .../new-rag/website-crawl-preview.tsx | 87 +++-- web/i18n/ar-TN/dataset.json | 2 + web/i18n/de-DE/dataset.json | 2 + web/i18n/en-US/dataset.json | 2 + web/i18n/es-ES/dataset.json | 2 + web/i18n/fa-IR/dataset.json | 2 + web/i18n/fr-FR/dataset.json | 2 + web/i18n/hi-IN/dataset.json | 2 + web/i18n/id-ID/dataset.json | 2 + web/i18n/it-IT/dataset.json | 2 + web/i18n/ja-JP/dataset.json | 2 + web/i18n/ko-KR/dataset.json | 2 + web/i18n/lo-LA/dataset.json | 2 + web/i18n/nl-NL/dataset.json | 2 + web/i18n/pl-PL/dataset.json | 2 + web/i18n/pt-BR/dataset.json | 2 + web/i18n/ro-RO/dataset.json | 2 + web/i18n/ru-RU/dataset.json | 2 + web/i18n/sl-SI/dataset.json | 2 + web/i18n/th-TH/dataset.json | 2 + web/i18n/tr-TR/dataset.json | 2 + web/i18n/uk-UA/dataset.json | 2 + web/i18n/vi-VN/dataset.json | 2 + web/i18n/zh-Hans/dataset.json | 2 + web/i18n/zh-Hant/dataset.json | 2 + web/service/datasets.ts | 2 +- 71 files changed, 2713 insertions(+), 217 deletions(-) create mode 100644 knowledge-fs/.harness/changes/2026-08-11-golden-question-csv-empty-space-draft-import.md create mode 100644 test-results/knowledge-fs/2026-08-11/knowledge-fs-detailed-test-cases.md create mode 100644 test-results/knowledge-fs/2026-08-11/knowledge-fs-functional-test-report.md create mode 100644 test-results/knowledge-fs/2026-08-11/test-data/kf-qa-duplicate.txt create mode 100644 test-results/knowledge-fs/2026-08-11/test-data/kf-qa-marker.txt create mode 100644 test-results/knowledge-fs/2026-08-11/test-data/kf-qa-reference-questions.csv create mode 100644 test-results/knowledge-fs/2026-08-11/test-data/kf-qa-unicode-中文.md create mode 100644 test-results/knowledge-fs/2026-08-11/test-data/kf-qa-unsupported.exe create mode 100644 web/features/new-rag/components/__tests__/knowledge-fs-api-access-dialog.spec.tsx create mode 100644 web/features/new-rag/components/knowledge-fs-api-access-dialog.tsx diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index ae3f74ce63d..b917c5468da 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -51,7 +51,7 @@ from models import Account, ApiToken, App, Dataset, Document, DocumentSegment, U from models.dataset import DatasetPermission, DatasetPermissionEnum, DatasetQuery from models.enums import ApiTokenType, SegmentStatus from models.provider_ids import ModelProviderID -from services.api_token_service import ApiTokenCache +from services.api_token_service import ApiTokenCache, get_effective_token_last_used_at from services.app_service import AppService from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService from services.enterprise import rbac_service as enterprise_rbac_service @@ -1093,7 +1093,25 @@ class DatasetApiKeyApi(Resource): keys = session.scalars( select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id) ).all() - return dump_response(ApiKeyList, {"data": keys}) + return dump_response( + ApiKeyList, + { + "data": [ + { + "created_at": key.created_at, + "id": key.id, + "last_used_at": get_effective_token_last_used_at( + key.token, + key.type, + key.last_used_at, + ), + "token": key.token, + "type": key.type, + } + for key in keys + ] + }, + ) @console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(400, "Maximum keys exceeded") diff --git a/api/services/api_token_service.py b/api/services/api_token_service.py index e9d887195a7..3c7ad3613f9 100644 --- a/api/services/api_token_service.py +++ b/api/services/api_token_service.py @@ -6,7 +6,7 @@ Includes Redis cache operations, database queries, and single-flight concurrency """ import logging -from datetime import datetime +from datetime import UTC, datetime from typing import Any, override from pydantic import BaseModel @@ -274,6 +274,39 @@ def record_token_usage(auth_token: str, scope: str | None) -> None: logger.warning("Failed to record token usage: %s", e) +def get_effective_token_last_used_at( + auth_token: str, + scope: str | None, + persisted_last_used_at: datetime | None, +) -> datetime | None: + """Return the newest persisted or pending usage timestamp for a token.""" + try: + value = redis_client.get(ApiTokenCache.make_active_key(auth_token, scope)) + if value is None: + return persisted_last_used_at + if isinstance(value, bytes): + value = value.decode("utf-8") + pending_last_used_at = datetime.fromisoformat(value) + except (UnicodeDecodeError, TypeError, ValueError) as e: + logger.warning("Failed to parse pending token usage: %s", e) + return persisted_last_used_at + except Exception as e: + logger.warning("Failed to read pending token usage: %s", e) + return persisted_last_used_at + + if persisted_last_used_at is None: + return pending_last_used_at + + def as_naive_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value + return value.astimezone(UTC).replace(tzinfo=None) + + if as_naive_utc(pending_last_used_at) > as_naive_utc(persisted_last_used_at): + return pending_last_used_at + return persisted_last_used_at + + # --------------------------------------------------------------------- # Database query + single-flight # --------------------------------------------------------------------- diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index 43fa8f6513d..ff79eff890f 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -1319,14 +1319,24 @@ class TestDatasetApiKeyApi: ) session = MagicMock() session.scalars.return_value.all.return_value = [mock_key_1, mock_key_2] - with app.test_request_context("/"): + pending_last_used_at = datetime.datetime(2026, 8, 11, 12, 30, 0, tzinfo=datetime.UTC) + with ( + app.test_request_context("/"), + patch( + "controllers.console.datasets.datasets.get_effective_token_last_used_at", + side_effect=[pending_last_used_at, None], + ) as mock_effective_last_used, + ): response = method(api, session, "tenant-1") assert "data" in response assert len(response["data"]) == 2 assert response["data"][0]["id"] == "key-1" assert response["data"][0]["token"] == "ds-abc" + assert response["data"][0]["last_used_at"] == int(pending_last_used_at.timestamp()) assert response["data"][1]["id"] == "key-2" assert response["data"][1]["token"] == "ds-def" + assert response["data"][1]["last_used_at"] is None + assert mock_effective_last_used.call_count == 2 def test_post_create_api_key_success(self, app: Flask): api = DatasetApiKeyApi() diff --git a/api/tests/unit_tests/controllers/test_knowledge_fs_product_controllers.py b/api/tests/unit_tests/controllers/test_knowledge_fs_product_controllers.py index eed7875004d..ed3b84c7488 100644 --- a/api/tests/unit_tests/controllers/test_knowledge_fs_product_controllers.py +++ b/api/tests/unit_tests/controllers/test_knowledge_fs_product_controllers.py @@ -949,11 +949,12 @@ def test_task_stream_capability_uses_broker( def test_service_query_admission_uses_broker(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[dict[str, object]] = [] + credential_calls: list[dict[str, object]] = [] profile = SimpleNamespace(tenant_id="tenant-1", control_space_id="control-1") class Credentials: def validate_service_credential(self, **kwargs): - _ = kwargs + credential_calls.append(kwargs) return profile class Broker: @@ -983,4 +984,10 @@ def test_service_query_admission_uses_broker(monkeypatch: pytest.MonkeyPatch) -> assert response["operation_id"] == "createQuery" assert response["request"]["knowledgeSpaceId"] == "space-1" assert response["url"] == "https://api.dify.test/v1/knowledge-fs/query-stream" + assert credential_calls == [ + { + "raw_credential": "kfs_test_credential_value_123456", + "required_action": "queries.create", + } + ] assert calls == [{"profile": profile, "operation_id": "createQuery"}] diff --git a/api/tests/unit_tests/services/test_api_token_service.py b/api/tests/unit_tests/services/test_api_token_service.py index b3e5af87087..3f7ae699088 100644 --- a/api/tests/unit_tests/services/test_api_token_service.py +++ b/api/tests/unit_tests/services/test_api_token_service.py @@ -92,6 +92,50 @@ class TestRecordTokenUsage: api_token_service_module.record_token_usage("token-123", "app") +class TestGetEffectiveTokenLastUsedAt: + def test_should_surface_pending_usage_before_the_batch_flush(self): + pending = datetime(2026, 8, 11, 12, 30, 0) + + with patch.object(api_token_service_module, "redis_client") as mock_redis: + mock_redis.get.return_value = pending.isoformat().encode() + result = api_token_service_module.get_effective_token_last_used_at( + "token-123", + "dataset", + None, + ) + + assert result == pending + mock_redis.get.assert_called_once_with(ApiTokenCache.make_active_key("token-123", "dataset")) + + def test_should_keep_a_newer_persisted_usage_timestamp(self): + pending = datetime(2026, 8, 11, 12, 30, 0) + persisted = datetime(2026, 8, 11, 12, 31, 0) + + with patch.object(api_token_service_module, "redis_client") as mock_redis: + mock_redis.get.return_value = pending.isoformat() + result = api_token_service_module.get_effective_token_last_used_at( + "token-123", + "dataset", + persisted, + ) + + assert result == persisted + + @pytest.mark.parametrize("value", [None, b"not-a-timestamp"]) + def test_should_fall_back_to_the_persisted_timestamp(self, value): + persisted = datetime(2026, 8, 11, 12, 30, 0) + + with patch.object(api_token_service_module, "redis_client") as mock_redis: + mock_redis.get.return_value = value + result = api_token_service_module.get_effective_token_last_used_at( + "token-123", + "dataset", + persisted, + ) + + assert result == persisted + + class TestFetchTokenWithSingleFlight: def test_should_return_cached_token_when_lock_acquired_and_cache_filled(self): auth_token = "token-123" diff --git a/api/tests/unit_tests/services/test_knowledge_fs_credential_service.py b/api/tests/unit_tests/services/test_knowledge_fs_credential_service.py index 6581e024200..ddba708bf54 100644 --- a/api/tests/unit_tests/services/test_knowledge_fs_credential_service.py +++ b/api/tests/unit_tests/services/test_knowledge_fs_credential_service.py @@ -244,6 +244,12 @@ def test_cached_credential_validation_expires_denies_and_accepts_without_databas with pytest.raises(KnowledgeFSCredentialValidationError, match="Invalid"): service.validate_service_credential(raw_credential="not-kfs", required_action="documents.list") + with pytest.raises(KnowledgeFSCredentialValidationError, match="Invalid"): + service.validate_service_credential( + raw_credential="dataset-legacy-api-key", + required_action="queries.create", + ) + expired = KnowledgeFSServiceCredentialProfile( "tenant-1", "control-1", diff --git a/api/tests/unit_tests/services/test_knowledge_fs_staged_upload_service.py b/api/tests/unit_tests/services/test_knowledge_fs_staged_upload_service.py index f96efc83309..e196cfd457f 100644 --- a/api/tests/unit_tests/services/test_knowledge_fs_staged_upload_service.py +++ b/api/tests/unit_tests/services/test_knowledge_fs_staged_upload_service.py @@ -21,6 +21,7 @@ from models.knowledge_fs import ( KnowledgeFSStagedUploadStatus, ) from models.model import Account, UploadFile +from services import file_service as file_service_module from services.errors.file import FileTooLargeError, UnsupportedFileTypeError from services.knowledge_fs import staged_upload_service as staged_upload_module from services.knowledge_fs.data_facade import KnowledgeFSDataFacade @@ -276,6 +277,51 @@ def test_stage_persists_workspace_owned_upload( assert persisted.checksum_sha256_base64 == b64encode(sha256(_BODY).digest()).decode() +@pytest.mark.parametrize( + ("file_name", "content_type", "body"), + [ + ("notes.txt", "text/plain", b"KnowledgeFS notes"), + ("guide.md", "text/markdown", b"# KnowledgeFS guide"), + ], +) +def test_stage_accepts_supported_text_files_with_the_real_file_service( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + file_name: str, + content_type: str, + body: bytes, +) -> None: + backend = FakeStorage() + monkeypatch.setattr(file_service_module, "storage", backend) + monkeypatch.setattr(staged_upload_module, "storage", backend) + monkeypatch.setattr(file_service_module.file_helpers, "get_signed_file_url", lambda **_: "signed") + account = Account(name="KnowledgeFS tester", email="knowledge-fs@example.com") + account.id = _ACCOUNT_ID + service = KnowledgeFSStagedUploadService( + sqlite_session_factory, + facade=cast(KnowledgeFSDataFacade, MagicMock()), + ) + + response = service.stage( + tenant_id=_TENANT_ID, + account=account, + file_name=file_name, + content_type=content_type, + body=body, + file_size_limit_mb=15, + ) + + assert response.file_name == file_name + assert response.content_type == content_type + assert response.size_bytes == len(body) + assert response.status == "uploaded" + assert list(backend.objects.values()) == [body] + with sqlite_session_factory() as session: + persisted = session.get(KnowledgeFSStagedUpload, response.id) + assert persisted is not None + assert persisted.upload_file_id + + def test_stage_rejects_empty_and_maps_file_service_errors( sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/knowledge-fs/.harness/changes/2026-08-11-golden-question-csv-empty-space-draft-import.md b/knowledge-fs/.harness/changes/2026-08-11-golden-question-csv-empty-space-draft-import.md new file mode 100644 index 00000000000..fd1d14cd679 --- /dev/null +++ b/knowledge-fs/.harness/changes/2026-08-11-golden-question-csv-empty-space-draft-import.md @@ -0,0 +1,29 @@ +# Golden-question CSV import fallback for empty spaces + +## What changed + +- Bulk golden-question import now treats unavailable evidence matching as an unmatched batch and + creates every valid row as a draft. +- Single-question evidence matching keeps its existing `503` behavior so callers can explain that + matching is temporarily unavailable. +- Added a gateway regression test covering a valid two-row Unicode CSV payload when the space has + no active embedding profile. + +## Why + +The Quality UI promises that unmatched CSV rows are saved as drafts. Empty knowledge spaces do not +have an active embedding profile, so evidence matching can be unavailable rather than returning an +empty candidate list. Rejecting the entire batch in that state contradicted the import contract and +left zero rows persisted. + +## Verification + +- `pnpm exec vitest run src/gateway-golden-question.test.ts` from `packages/api`: 5 tests passed. +- The Dify web CSV parser and Quality component regressions also passed in their owning workspace. + +## Risks and follow-up + +- Import intentionally distinguishes capability unavailability from unexpected matcher failures; + unexpected errors still fail the batch without partial writes. +- Rows imported during matcher unavailability remain drafts until a later explicit evidence-match + workflow activates them. diff --git a/knowledge-fs/packages/api/src/gateway-golden-question.test.ts b/knowledge-fs/packages/api/src/gateway-golden-question.test.ts index 98fc05e3208..53e65218928 100644 --- a/knowledge-fs/packages/api/src/gateway-golden-question.test.ts +++ b/knowledge-fs/packages/api/src/gateway-golden-question.test.ts @@ -1,6 +1,7 @@ import { createNodePlatformAdapter } from "@knowledge/adapters/node"; import { describe, expect, it } from "vitest"; +import { GoldenQuestionEvidenceMatchingUnavailableError } from "./golden-question-evidence-matcher"; import { createGoldenEvidenceFixtures } from "./golden-question-test-fixtures"; import { createInMemoryFailedQueryRepository, @@ -327,6 +328,77 @@ describe("golden question gateway", () => { ], }); }); + + it("imports valid CSV rows as drafts when an empty space cannot match evidence", async () => { + const knowledgeSpaceId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2c42"; + const generatedIds = [ + "018f0d60-7a49-7cc2-9c1b-5b36f18f3a41", + "018f0d60-7a49-7cc2-9c1b-5b36f18f3a42", + ]; + const goldenQuestions = createInMemoryGoldenQuestionRepository({ + generateId: () => generatedIds.shift() as string, + maxListLimit: 10, + maxQuestions: 10, + }); + const app = createKnowledgeGateway({ + adapter: createNodePlatformAdapter({ env: {} }), + auth: createTestAuthVerifier(), + goldenQuestionEvidenceMatcher: { + match: async () => { + throw new GoldenQuestionEvidenceMatchingUnavailableError( + "Golden question evidence matching requires an active embedding profile", + ); + }, + }, + goldenQuestions, + knowledgeSpaces: createInMemoryKnowledgeSpaceRepository({ + generateId: () => knowledgeSpaceId, + maxListLimit: 10, + maxSpaces: 10, + }), + }); + await createSpace(app, knowledgeSpaceId); + + const response = await app.request( + `/knowledge-spaces/${knowledgeSpaceId}/golden-questions/bulk-import`, + { + body: JSON.stringify({ + rows: [ + { + evidence: "退款期为 30 天", + question: "退款期多久?", + tags: ["billing", "政策"], + }, + { + evidence: "在设置中启用 SSO", + question: "如何启用 SSO?", + tags: ["enterprise", "security"], + }, + ], + }), + headers: { ...bearer(writeToken), "content-type": "application/json" }, + method: "POST", + }, + ); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ + activeCount: 0, + draftCount: 2, + items: [ + { rowIndex: 0, status: "draft" }, + { rowIndex: 1, status: "draft" }, + ], + }); + await expect( + goldenQuestions.listTrusted({ knowledgeSpaceId, limit: 10 }), + ).resolves.toMatchObject({ + items: [ + { question: "退款期多久?", tags: ["billing", "政策"] }, + { question: "如何启用 SSO?", tags: ["enterprise", "security"] }, + ], + }); + }); }); async function createSpace( diff --git a/knowledge-fs/packages/api/src/golden-question-handlers.ts b/knowledge-fs/packages/api/src/golden-question-handlers.ts index a7f7f83978c..5342469b136 100644 --- a/knowledge-fs/packages/api/src/golden-question-handlers.ts +++ b/knowledge-fs/packages/api/src/golden-question-handlers.ts @@ -191,18 +191,25 @@ export function registerGoldenQuestionHandlers({ knowledgeSpaceId, now, }); - if (!evidenceMatcher) { - return context.json({ error: "Golden question evidence matching is unavailable" }, 503); - } const body = context.req.valid("json"); - const matches = await evidenceMatcher.match({ - evidenceTexts: body.rows.map((row) => row.evidence), - knowledgeSpaceId, - minimumSimilarity: body.minimumSimilarity, - permissionScope: permission.candidateGrants, - tenantId: permission.tenantId, - topK: 1, - }); + let matches: readonly { + readonly candidates: readonly GoldenQuestionEvidenceCandidate[]; + readonly matched: boolean; + }[] = []; + if (evidenceMatcher) { + try { + matches = await evidenceMatcher.match({ + evidenceTexts: body.rows.map((row) => row.evidence), + knowledgeSpaceId, + minimumSimilarity: body.minimumSimilarity, + permissionScope: permission.candidateGrants, + tenantId: permission.tenantId, + topK: 1, + }); + } catch (error) { + if (!(error instanceof GoldenQuestionEvidenceMatchingUnavailableError)) throw error; + } + } const matchedAt = now(); const prepared = body.rows.map((row, rowIndex) => { const match = matches[rowIndex]; diff --git a/test-results/knowledge-fs/2026-08-11/knowledge-fs-detailed-test-cases.md b/test-results/knowledge-fs/2026-08-11/knowledge-fs-detailed-test-cases.md new file mode 100644 index 00000000000..f2c9f3ceb9b --- /dev/null +++ b/test-results/knowledge-fs/2026-08-11/knowledge-fs-detailed-test-cases.md @@ -0,0 +1,345 @@ +# KnowledgeFS 详细功能测试用例 + +## 使用说明 + +### 通用前置条件 + +1. Chrome 已登录 `https://new-rag.dify.dev`。 +2. 当前用户对 workspace 至少有 owner 权限;权限矩阵用例需额外的 editor 和 viewer 账号。 +3. 新版入口为 `/datasets?view=new`。 +4. 破坏性用例仅在 `KF-QA-*` 专用空间执行,不使用业务数据。 +5. 合成文件上传、临时开启/关闭 QA API Access 已获用户确认;新建持久凭据、撤销密钥和永久删除仍需在执行前单独确认。 + +### 测试数据 + +- QA 空间:`KF-QA-20260811-066145` +- 唯一检索标记:`KF_QA_MARKER_20260811_X9Z` +- 网站:`https://example.com` +- 本地合成文件:`test-data/` 目录 + +### 结果符号 + +- `PASS`:本轮实测符合预期 +- `FAIL`:本轮稳定复现缺陷 +- `ENV`:流程已执行,但被模型/provider/额度等环境条件阻塞 +- `ENV-BLOCKED`:已就绪但被浏览器、模型、provider 或服务端依赖阻塞,无法继续验证下游链路 +- `PENDING-CONFIRM`:涉及新建/撤销持久凭据或永久删除,等待用户在操作前确认 +- `NOT-RUN`:需额外账号、provider 凭据或大批量数据 +- `STATIC-RISK`:代码审查发现的候选风险,未当作已确认缺陷 + +## A. 入口、路由与导航 + +| ID | P | 测试场景/步骤 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-NAV-001 | P0 | 直接访问 `/datasets?view=new` | 进入新版 Knowledge 列表,New 按钮为 pressed | PASS | +| TC-NAV-002 | P0 | 在列表点击“旧版”,再切回“新版” | 路由、选中状态和列表内容同步;新版恢复 `view=new` | PASS | +| TC-NAV-003 | P0 | 在概览/Sources/Documents/Retrieval/Quality/Settings 刷新 | 深链可独立恢复,不回到列表或旧版 | PASS | +| TC-NAV-004 | P0 | 打开合法 space ID 的深链 | 左侧导航、名称、模式和页面内容加载正常 | PASS | +| TC-NAV-005 | P0 | 打开不存在的 UUID 空间 | 显示本地化的不存在/无权页,不泄露后端信息 | FAIL,见 KF-BUG-004 | +| TC-NAV-006 | P1 | 点击“欢迎使用新版知识库”,关闭后再进入 | 引导弹层可关闭,不阻断页面操作 | PASS | +| TC-NAV-007 | P1 | 左侧导航折叠/展开后逐项访问 | 每个链接路由正确,焦点与选中项一致 | PARTIAL,路由已验证 | +| TC-NAV-008 | P1 | 390×844 视口访问所有二级导航 | 所有页签有明确滚动/折叠可达性,不遮挡内容 | FAIL-CANDIDATE,见 KF-BUG-012 | +| TC-NAV-009 | P1 | 使用 Tab/Shift+Tab/Enter/Escape 访问页签、菜单、抽屉和对话框 | 键盘可完成主流程,焦点不丢失 | NOT-RUN | +| TC-NAV-010 | P1 | 用无 new-rag feature flag 的 workspace 访问 | 不显示新版入口或给出明确说明 | NOT-RUN | + +## B. 列表、搜索和筛选 + +| ID | P | 测试场景/步骤 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-LI-001 | P0 | 加载有数据的新版列表 | 每张卡显示名称、描述、文档/应用计数、更新时间 | PASS | +| TC-LI-002 | P0 | 输入名称子串 `ces` | 只显示 `ceshi` | PASS | +| TC-LI-003 | P0 | 输入大写 `CESHI` | 搜索大小写不敏感,显示 `ceshi` | PASS | +| TC-LI-004 | P1 | 输入不存在的唯一字符串 | 显示“无搜索结果”及清除条件入口 | FAIL,见 KF-BUG-011 | +| TC-LI-005 | P1 | 清空搜索框 | 恢复全部列表 | PASS | +| TC-LI-006 | P1 | 打开“创建者”下拉 | 显示成员搜索、列表和重置入口 | PASS | +| TC-LI-007 | P1 | 搜索不存在的创建者 | 显示“没有找到创建者” | PASS | +| TC-LI-008 | P1 | 选中当前创建者 | 条件显示“创建者: 1”,结果与成员匹配 | PASS | +| TC-LI-009 | P1 | 重置创建者筛选 | 标签和列表恢复 | PASS | +| TC-LI-010 | P1 | 创建新空间后回列表 | 新空间出现在列表前部,描述和计数正确 | PASS | +| TC-LI-011 | P1 | 点击“标签” | 如后端未提供则给出明确不可用提示 | PASS,提示 KnowledgeFS 待提供 metadata | +| TC-LI-012 | P1 | 点击“外部知识库 API” | 打开管理抽屉,无凭据泄露 | PASS | +| TC-LI-013 | P1 | 点击“服务 API” | 显示 endpoint 和 key 管理入口 | PASS | +| TC-LI-014 | P1 | 超过一页数据时搜索只在末页出现的名称 | 搜索全数据集,不只过滤已加载卡片 | STATIC-RISK,数据不足未实证 | +| TC-LI-015 | P2 | 连续快速改变搜索和创建者条件 | 旧请求不覆盖新请求,无闪回 | NOT-RUN | + +## C. 创建知识库 + +| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-CR-001 | P0 | 点击“创建” | 打开创建对话框,默认从空白开始/only_me | PASS | +| TC-CR-002 | P0 | 名称留空 | 创建按钮禁用或显示必填错误 | PASS | +| TC-CR-003 | P0 | 名称仅空格 | 视为空值,禁止创建 | PASS | +| TC-CR-004 | P0 | 名称 1 字 | 创建成功 | NOT-RUN | +| TC-CR-005 | P0 | 名称精确 40 字 | 允许提交并保存成功 | PASS,在 QA Settings 验证同约束 | +| TC-CR-006 | P0 | 名称 41 字 | 字段级拦截,不请求后端 | FAIL,见 KF-BUG-002 | +| TC-CR-007 | P0 | 描述精确 2000 字 | 允许保存 | PASS,在 QA Settings 实测 | +| TC-CR-008 | P0 | 描述 2001 字 | 字段级拦截,不请求后端 | FAIL,Settings 同约束见 KF-BUG-003 | +| TC-CR-009 | P0 | 从空白开始 + 合法名称 | 只创建 1 个空间,202/provisioning 后进入 active Sources | PASS | +| TC-CR-010 | P0 | 在加载中连续双击“创建知识库” | 幂等,只创建 1 个空间 | NOT-RUN | +| TC-CR-011 | P0 | 提交后制造超时,再点重试 | 通过幂等 key 收敛到同一 space | NOT-RUN | +| TC-CR-012 | P1 | 可见权限选择 only_me | 只有 owner 可见,创建成功 | PASS | +| TC-CR-013 | P1 | 可见权限选择 all_team_members | 所有有效成员可以 viewer 身份查看 | NOT-RUN,缺多用户 | +| TC-CR-014 | P1 | 选择 partial_members 但不选成员 | 显示“至少一名成员”并禁止保存 | PASS,Settings 实测 | +| TC-CR-015 | P1 | 连接数据源方式创建 | 创建空间后进入 provider 配置,取消不留垃圾数据 | NOT-RUN | +| TC-CR-016 | P1 | 上传文件方式创建 | 创建与 staged upload claim 原子收敛,不重复文档 | ENV-BLOCKED,Documents 入口已证实 staging 服务异常,本轮未再创建空间 | +| TC-CR-017 | P1 | 模型配置 pending/fail/retry | 显示可解释状态和重试,不重复创建 | NOT-RUN | +| TC-CR-018 | P1 | 同 slug 不同创建意图/幂等载荷 | 返回可理解 409/4xx,不出现 500 | STATIC-RISK | + +## D. 数据源与网站抓取 + +| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-SO-001 | P0 | 空间打开 Sources | 显示空状态、常用 provider 和“添加数据源” | PASS | +| TC-SO-002 | P0 | 打开添加数据源 | 显示 Website/Online Documents/Online Drive 三类 | PASS | +| TC-SO-003 | P0 | 检查 provider 清单 | Firecrawl/Jina/WaterCrawl、Notion/Google Docs/Confluence、Drive/OneDrive/S3 入口正确 | PASS | +| TC-SO-004 | P0 | Website 根 URL 输入 `not-a-url` | 提示必须为 http(s),抓取按钮禁用 | PASS | +| TC-SO-005 | P0 | Website 根 URL 输入 `https://example.com` | URL 校验通过,名称有效后可抓取 | PASS | +| TC-SO-006 | P0 | 最大页数输入 0 | 提示最小为 1,禁止抓取 | FAIL,可见 0/实际 1 | +| TC-SO-007 | P0 | 最大页数输入 1 | 允许抓取 1 页 | PASS | +| TC-SO-008 | P0 | 最大页数输入 1.5 | 提示只能是整数,禁止抓取 | FAIL,可见 1.5/实际 1 | +| TC-SO-009 | P0 | 最大页数输入 200 | 允许 | PASS | +| TC-SO-010 | P0 | 最大页数输入 1001 | 提示最大 200,禁止抓取 | FAIL,可见 1001/实际 200 | +| TC-SO-011 | P0 | Firecrawl 连接状态 | 显示已连接时才允许预览 | PASS | +| TC-SO-012 | P0 | Firecrawl 对 example.com 抓取预览 | 在可接受时间内返回页列表或明确失败 | ENV,36+ 秒仍 0 页 | +| TC-SO-013 | P0 | 抓取进行中点击 Stop | 进入 stopping,最终 stopped,表单可再操作 | PASS | +| TC-SO-014 | P0 | stopped 后点 Retry | 仅新建 1 个后续工作流,进度重置 | NOT-RUN,只检查入口 | +| TC-SO-015 | P0 | 抓取返回页面后全选/反选/单选 | 选中数量、最多 200 和添加按钮状态正确 | ENV | +| TC-SO-016 | P0 | 选择页面后“添加数据源” | 创建 source,文档进入解析/索引,不留临时 workflow | ENV | +| TC-SO-017 | P1 | 有草稿时点击取消/离开 | 弹出“放弃数据源更改”确认 | PASS | +| TC-SO-018 | P1 | 在确认对话框点“取消” | 草稿保留,返回配置页 | PASS | +| TC-SO-019 | P1 | 在确认对话框点“放弃草稿” | 临时配置/预览被清理,列表无 source | PENDING-CONFIRM | +| TC-SO-020 | P1 | 选 Jina Reader | 未安装时明确显示安装入口 | PASS,环境显示未安装 | +| TC-SO-021 | P1 | 选 WaterCrawl 并验证凭据 | 未安装/未配置时给出可操作的状态 | NOT-RUN | +| TC-SO-022 | P1 | 同时提供 connectionId 和 credentials | 请求被 422 拒绝,不创建 source | NOT-RUN | +| TC-SO-023 | P1 | Source PATCH 为空 payload | 拒绝无效操作,无 revision 变化 | NOT-RUN | +| TC-SO-024 | P1 | sync policy custom interval = 3599/3600/2592000/2592001 | 只允许 3600–2592000 秒 | NOT-RUN | +| TC-SO-025 | P1 | manual mode 同时提交 customInterval | 拒绝矛盾组合,不静默保留无效值 | STATIC-RISK | +| TC-SO-026 | P1 | stale expectedRevision/sourceVersion 保存数据源 | 返回 409,UI 提示刷新后重试 | NOT-RUN | +| TC-SO-027 | P1 | 删除 source 选 `documents=keep` | source 删除,文档和检索仍可用 | PENDING-CONFIRM | +| TC-SO-028 | P1 | 删除 source 选 `documents=cascade` | source 和所属文档按 durable job 完成清理 | PENDING-CONFIRM | + +## E. 文档上传、列表与任务 + +| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-DO-001 | P0 | 空知识库打开 Documents | 显示“暂无文档”和上传入口 | PASS | +| TC-DO-002 | P0 | 点击“添加文档” | 打开上传表单,显示支持格式和 15 MB 限制 | PASS | +| TC-DO-003 | P0 | 上传 TXT/MD/CSV/JSONL/HTML/PDF/DOCX/XLSX 小文件 | staged upload 成功,claim 一次,文档进入 processing→ready | FAIL,3 个合法 TXT/MD 均显示 `Internal Server Error`,0 文档 | +| TC-DO-004 | P0 | 上传 Unicode 文件名和中文内容 | 文件名、内容、分块和下载名不乱码 | FAIL-BLOCKED,中文文件名本地显示正常,但 staging 500,无法验证解析 | +| TC-DO-005 | P0 | 上传空文件 | 返回 422,UI 明确说明空文件,无空文档 | FAIL,0 B 被计为有效并启用提交,后端 422 仅显示英文通用错误 | +| TC-DO-006 | P0 | 上传不支持 `.exe` | 客户端或服务端拒绝,无 staged upload 残留 | PASS,本地显示“不支持或无效的文件类型”,0/1 有效且提交禁用 | +| TC-DO-007 | P0 | 上传精确 15 MiB 文件 | 根据合同允许边界值,不会 413 | PARTIAL/FAIL,前端正确接收,但 43+ 秒停留“正在上传…”;取消后无文档 | +| TC-DO-008 | P0 | 上传 15 MiB + 1 byte | 返回 413,UI 提示超限,无计费/索引任务 | PASS,本地标记“超过 15 MB 限制”,0/1 有效且未 staging | +| TC-DO-009 | P0 | 同时上传合法、空、超限和不支持文件 | 每个文件独立结果,部分失败不丢成功项,可重试失败项 | PARTIAL/FAIL,合法文件逐个报错并另有汇总错误;未能进入 claim 部分成功 | +| TC-DO-010 | P0 | 对同一 staged upload 网络重试 | 同 upload ID 仅被 claim 一次,无重复文档 | ENV-BLOCKED,staging 未成功返回 upload ID | +| TC-DO-011 | P0 | claim 前 abort staged upload | 上传被废弃,不可再 claim | PARTIAL,取消 15 MiB in-flight 上传后表单关闭且 0 文档,但请求随后仍弹失败提示 | +| TC-DO-012 | P0 | claim 后再 abort | 拒绝废弃,已创建文档状态不被破坏 | ENV-BLOCKED,无可 claim 的 upload ID | +| TC-DO-013 | P0 | 不同用户或不同 space 试图 claim upload | 统一返回无权/不存在,不能跨用户移动文件 | NOT-RUN | +| TC-DO-014 | P1 | 上传同内容不同文件名 | 产品按规则创建两个 logical document 或明确去重,不静默丢失 | ENV-BLOCKED,两个文件均 staging 500,未创建 logical document | +| TC-DO-015 | P0 | 现有文档列表加载 | 显示名称、来源、状态、修订、更新时间 | PASS,7 条 | +| TC-DO-016 | P0 | 状态筛选 Ready | 只显示 ready 文档 | PASS,4 条 | +| TC-DO-017 | P0 | 状态筛选 Failed | 只显示 failed 文档 | PASS,3 条 | +| TC-DO-018 | P1 | 文档名大小写不敏感搜索 | 返回匹配项,清空后恢复 | PASS | +| TC-DO-019 | P1 | 搜索无匹配 | 显示明确无结果空状态 | PASS | +| TC-DO-020 | P0 | 打开 Tasks 抽屉 | 显示需关注/失败任务、原因和可用操作 | PASS,5 条失败/中断 | +| TC-DO-021 | P0 | 对失败任务点 Retry | 只创建一个 retry attempt,状态可跟踪 | PENDING-CONFIRM,不重试既有业务文档 | +| TC-DO-022 | P1 | 对进行中任务 Cancel | 任务进入 canceling/canceled,无半索引残留 | PENDING-CONFIRM | +| TC-DO-023 | P1 | 任务 SSE 断网再连 | 续接或 polling 收敛,不倒退、不重复任务 | NOT-RUN | +| TC-DO-024 | P1 | 打开 Metadata schema 对话框 | 显示现有 schema;能力未开放时按钮禁用且有说明 | PASS,Add Metadata 禁用 | +| TC-DO-025 | P1 | 勾选一个文档 | 显示批量操作条和正确选中计数 | PASS | +| TC-DO-026 | P1 | 选中文档后查看 Reindex/Download/Delete 状态 | 按权限和实现能力准确启用/禁用 | PASS,Reindex 可用,Download/Delete 禁用 | +| TC-DO-027 | P0 | 批量 Reindex 选择 1/1000/1001 文档 | 只允许 1–1000;单一 durable job,不重复 | PENDING-CONFIRM | +| TC-DO-028 | P0 | Reindex payload 同时包含 `all=true` 和 documentIds | 返回 422,不运行任务 | NOT-RUN | +| TC-DO-029 | P0 | 批量删除含存在/不存在 ID | 事件结果可追踪,幂等重放不多删 | PENDING-CONFIRM | +| TC-DO-030 | P1 | 行菜单打开 | 根据状态显示 rename/reindex/disable/archive/download/delete | PASS | +| TC-DO-031 | P1 | Rename 留空 | 保存禁用或字段必填 | PASS | +| TC-DO-032 | P1 | Rename 输入 304 字 | 按后端合同限制,超限时前端拦截 | FAIL-CANDIDATE,保存仍可用,未提交 | +| TC-DO-033 | P1 | Ready/Failed/Processing 文档的禁用、归档、下载按钮 | 按状态机正确启用,禁用项有解释 | PARTIAL,已检查当前状态 | +| TC-DO-034 | P1 | 两标签页并发 metadata PATCH,使用 stale rowVersion | 第二个返回 409,UI 刷新后可重试 | NOT-RUN | +| TC-DO-035 | P0 | 删除文档时重复使用同 Idempotency-Key/不同 payload | 同 payload 重放同结果,不同 payload 返回冲突 | PENDING-CONFIRM | + +## F. 文档详情、修订、分块与 Metadata + +| ID | P | 测试场景/步骤 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-DD-001 | P0 | 打开 Ready 文档详情 | 标题、内容、来源、状态正常加载,无错误 toast | FAIL,内容成功但 3 条 404 toast | +| TC-DD-002 | P0 | 详情页不展示未授权原始内容 | viewer 仅读且内容遵循 workspace 隔离 | NOT-RUN | +| TC-DD-003 | P1 | 点击 Metadata “编辑” | 打开编辑器或明确提示不支持,按钮可恢复 | FAIL,见 KF-BUG-007 | +| TC-DD-004 | P1 | 查看 revisions 列表 | 按时间倒序,active revision 明确标识 | PENDING-CONFIRM,需自有文档 | +| TC-DD-005 | P1 | 切换不同 revision | 内容、chunk 和 outline 与修订一致,无版本竞态 | PENDING-CONFIRM | +| TC-DD-006 | P1 | 查看 revision chunks | 分页/游标无重复丢失,chunk 内容与解析结果一致 | PENDING-CONFIRM | +| TC-DD-007 | P1 | 打开单个 chunk 详情 | 显示标识、位置、内容和 metadata,新版不提供编辑假入口 | PENDING-CONFIRM | +| TC-DD-008 | P1 | 查看 outline | 层级顺序与原文档匹配,空 outline 有空状态 | PENDING-CONFIRM | +| TC-DD-009 | P1 | schema 名称 1/255/256 字及重名 | 允许 1–255,拒绝 256/重名/内建名 | PENDING-CONFIRM | +| TC-DD-010 | P1 | schema type 为 string/number/time | 每种类型保存和文档值校验正确 | PENDING-CONFIRM | +| TC-DD-011 | P1 | stale schema expectedRowVersion 更新/删除 | 返回 409,不静默覆盖 | PENDING-CONFIRM | +| TC-DD-012 | P1 | metadata value 与 schema type 不匹配 | 字段级拒绝,其他值不受影响 | PENDING-CONFIRM | + +## G. 概览与可观测性 + +| ID | P | 测试场景/步骤 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-OV-001 | P0 | 新建空间打开 Overview | 显示添加第一个数据源 onboarding,指标为— | PASS | +| TC-OV-002 | P0 | 有数据空间查看 24h/30d 指标 | 时间范围切换后查询数、回答率和对比值一致 | PASS,24h 0,30d 11/100% | +| TC-OV-003 | P1 | 需要关注列表分页 | 前后翻页稳定,不重复丢失 | PASS,2 页 | +| TC-OV-004 | P1 | 打开最近活动抽屉 | Today/30d 和活动事件显示正常 | PASS | +| TC-OV-005 | P1 | 最近活动按 operator 筛选 | 只显示对应 actor/System 事件 | PASS | +| TC-OV-006 | P1 | 资产概况文档/实体/关系/覆盖数 | 与后端 inventory 一致 | PASS,4/398/380/104 | +| TC-OV-007 | P1 | Documents 列表包含 failed 文档时核对 Overview “文档” | 标签说明是 total 还是 ready,各页口径一致 | AMBIGUOUS,Overview=4,列表/卡片=7 | +| TC-OV-008 | P1 | 新上传文档完成后不刷新查看指标 | 按设计实时/轮询更新,不长期过期 | PENDING-CONFIRM | +| TC-OV-009 | P1 | 删除文档/source/space 后核对 stats | durable job 完成后计数无残留 | PENDING-CONFIRM | + +## H. Fast / Deep / Research 检索 + +| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-RE-001 | P0 | 空库打开 Retrieval | 显示输入框、三种模式和空记录状态 | PASS | +| TC-RE-002 | P0 | 问题留空或仅空格 | Start 禁用 | PASS | +| TC-RE-003 | P0 | 问题精确 2000 字 | UI 允许,后端 query 长度约束不冲突 | PARTIAL,已核对 maxLength=2000 | +| TC-RE-004 | P0 | 问题 2001 字 | UI 拦截/字段错误,不静默截断 | NOT-RUN | +| TC-RE-005 | P0 | 单次 Fast Start | 只产生 1 个 admission、1 个 SSE 任务、1 条记录 | PASS,QA 空库 813 ms 返回 1 条记录、0 分段 | +| TC-RE-006 | P0 | 单次 Fast Retry | 只生成 1 个新 attempt,与原 trace 关联 | FAIL,生成 2 条,KF-BUG-001 | +| TC-RE-007 | P0 | 单次 Deep Start | 只产生 1 个任务和 1 条记录 | FAIL,生成 2 条,KF-BUG-001 | +| TC-RE-008 | P0 | 单次 Research Start | 只产生 1 个 research task,阶段流式更新 | FAIL/PARTIAL,既有库可启动;QA 空库单击后 27+ 秒无任务、阶段或错误,见 KF-BUG-019 | +| TC-RE-009 | P0 | Fast 对唯一 marker 检索 | 答案包含 marker,citation 定位到正确 chunk | PENDING-CONFIRM,需上传并有额度 | +| TC-RE-010 | P0 | Deep 对同一 marker 检索 | 深度检索只生成一个结果,引用可回溯 | PENDING-CONFIRM | +| TC-RE-011 | P0 | Research 对多文档问题 | 计划→检索→分析→结果完整,证据与冲突可查 | PENDING-CONFIRM | +| TC-RE-012 | P0 | 无结果查询 | 明确“无充分证据”,不编造引用 | PASS,QA 空库 Fast/Deep 明确“未召回分段”,0 引用 | +| TC-RE-013 | P0 | Fast/Deep 正常查询 | 流式完成且无错误,记录状态与答案一致 | PARTIAL,QA 空库正常 0 结果;既有库受模型额度阻塞 | +| TC-RE-014 | P1 | Research 无召回 | 阶段和最终错误一致,可重试 | FAIL/PARTIAL,QA 空库静默回到 idle;既有库显示检索失败/未召回 | +| TC-RE-015 | P1 | 有历史记录时加载 Retrieval | 不再显示“暂无测试记录” | FAIL,见 KF-BUG-010 | +| TC-RE-016 | P1 | SSE 进行中断网、恢复网络 | 续接原 trace,不重复计费/任务 | NOT-RUN | +| TC-RE-017 | P1 | 进行中取消查询 | 服务端停止,记录为 canceled,不持续消耗额度 | NOT-RUN | +| TC-RE-018 | P1 | activeDocumentIds 100/101 | 允许 100,拒绝 101,UI 显示限制 | NOT-RUN | +| TC-RE-019 | P1 | query images 1/4/5,重复 UUID | 最多 4,重复被去重或拒绝,5 张拒绝 | NOT-RUN | +| TC-RE-020 | P1 | 图片 10 MB 边界、总 32 MB 边界、伪装 MIME | 仅 gif/jpeg/png/webp,严格限制单张/总大小 | NOT-RUN | +| TC-RE-021 | P1 | 引用点击/键盘激活 | 定位正确文档、revision 和 chunk,焦点可返回 | PENDING-CONFIRM | +| TC-RE-022 | P1 | Auto 模式通过 App/Workflow Knowledge Retrieval v2 运行 | 按 Settings 自动选 Fast/Deep/Research,运行和发布一致 | NOT-RUN | +| TC-RE-023 | P1 | 用废弃 `/queries` 路由 | UI 不调用该路由;服务返回明确 503/deprecated | STATIC-VERIFIED | + +## I. Quality:参考问题、问题案例与 Replay + +| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-QA-001 | P0 | 空空间打开 Quality | 参考问题 0、问题案例 0,空状态正确 | PASS | +| TC-QA-002 | P0 | 切换参考问题/问题案例 | tab 选中和 `?tab=bad-cases` 路由一致 | PASS | +| TC-QA-003 | P0 | 创建问题与备注留空 | 在字段下显示必填错误,不创建记录 | PASS | +| TC-QA-004 | P0 | 触发必填错误后输入合法问题/备注 | 错误和红色边框立即清除 | FAIL,见 KF-BUG-008 | +| TC-QA-005 | P0 | 合法问题+备注,不选证据直接保存 | 创建 1 条 draft,列表计数+1 | PASS | +| TC-QA-006 | P0 | 编辑已有 draft 备注并保存 | 列表内容和更新时间同步 | PASS | +| TC-QA-007 | P1 | 勾选一条参考问题 | 显示“已选 1”、删除入口和清除选择 | PASS | +| TC-QA-008 | P1 | 有效证据文本后点“查找证据” | 返回候选 chunk 或明确无匹配,不报未知错误 | FAIL/ENV,当前为“未知错误” | +| TC-QA-009 | P0 | 导入合法 CSV(question/evidence/tags) | 显示行级预览和导入结果,未匹配证据保存草稿 | FAIL,2 行预览正确;提交后仅显示“未知错误”,刷新仍为 1 条、无部分成功 | +| TC-QA-010 | P0 | CSV 使用中文表头、quoted commas/tags | 正确解析,不错列/乱码 | PARTIAL,英文表头与 quoted tags 正确预览;中文表头未执行 | +| TC-QA-011 | P0 | CSV 500/501 行 | 允许 500,拒绝 501,告知上限 | NOT-RUN,基础 2 行导入已失败 | +| TC-QA-012 | P0 | CSV 精确 1 MiB/1 MiB+1 | 允许边界,超限拒绝且无部分落库 | NOT-RUN,基础 2 行导入已失败 | +| TC-QA-013 | P1 | CSV 包含合法与非法行 | 行级结果清晰,可重试失败行,不重复成功行 | NOT-RUN,基础 2 行导入已失败 | +| TC-QA-014 | P1 | 执行 replay run | 重测每个问题,显示通过/失败/无证据和可追溯 trace | PENDING-CONFIRM | +| TC-QA-015 | P1 | replay 进行中断网/刷新 | 状态可恢复,不重复执行同一问题 | NOT-RUN | +| TC-QA-016 | P1 | 问题案例从真实查询转入 | 保留 query/trace/evidence 关联,列表计数及时更新 | NOT-RUN | +| TC-QA-017 | P1 | 删除单个/批量参考问题 | 仅删除选中项,重复确认不多删 | PENDING-CONFIRM | + +## J. Settings:基础信息、可见性与检索参数 + +| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-ST-001 | P0 | 打开 Settings | 基础信息、API、模型、检索和危险操作加载正常 | PASS | +| TC-ST-002 | P0 | 名称留空/仅空格 | 显示必填错误,Save 禁用 | PASS | +| TC-ST-003 | P0 | 名称精确 40 字保存 | 保存成功,侧边栏/列表同步 | PASS,后已恢复 | +| TC-ST-004 | P0 | 名称 41 字 | 前端拦截和字段提示 | FAIL,创建页已证实 | +| TC-ST-005 | P0 | 描述精确 2000 字 | 保存成功 | PASS,后已恢复 | +| TC-ST-006 | P0 | 描述 2001 字 | 前端拦截,显示字段错误 | FAIL,见 KF-BUG-003 | +| TC-ST-007 | P0 | 合法名称/描述保存后刷新 | 持久值正确,Save 回到禁用 | PASS | +| TC-ST-008 | P1 | 改动后 Cancel | 恢复服务端值,不发 PATCH | PASS,可见性试改 | +| TC-ST-009 | P0 | 可见性 only_me/all/partial 下拉 | 三种选项齐全,与隐藏值一致 | PASS | +| TC-ST-010 | P0 | partial 不选任何成员 | 显示至少一人,禁止保存 | PASS | +| TC-ST-011 | P1 | partial 添加/移除成员并保存 | 成员权限和 auth epoch 正确更新 | NOT-RUN,缺多用户 | +| TC-ST-012 | P1 | API Access 开关 on/off | 立即保存或明确告知自动保存,侧边栏同步 | PASS,已恢复 off | +| TC-ST-013 | P0 | 系统推理模型下拉 | 只显示可用模型,缺失/失效时可解释 | PARTIAL,当前 gpt-5.6 | +| TC-ST-014 | P0 | Embedding 模型下拉 | 只显示可用 embedding,Fast/Deep 不允许缺失 | PARTIAL,当前 text-embedding-3-large | +| TC-ST-015 | P0 | 同时修改 embedding 与 retrieval settings | 产品要求拆分操作,返回明确 422 而非部分保存 | NOT-RUN | +| TC-ST-016 | P0 | embedding migration queued/running/succeeded/failed/canceled | 进度、checkpoint 和恢复入口清晰,激活前不破坏旧索引 | NOT-RUN | +| TC-ST-017 | P0 | 启用 Rerank 但不选模型 | 禁止保存并提示必选模型 | NOT-RUN | +| TC-ST-018 | P0 | Rerank 关闭时尝试启用 Fast/Deep Score threshold | threshold 禁用,规则清晰 | PASS,当前禁用 | +| TC-ST-019 | P0 | Retrieval mode Fast→Deep→Fast | 每次只保存一次,选中状态持久 | PASS,已恢复 Fast | +| TC-ST-020 | P0 | Retrieval mode Research | 模式保存并使 Retrieval 按钮语义变为开始研究 | PASS,在 Retrieval 已验证 | +| TC-ST-021 | P0 | Top K = 0 | 拦截或明确 clamp 到 1,显示值与保存值一致 | PASS,显示和 slider 均为 1 | +| TC-ST-022 | P0 | Top K = 1/10 | 允许边界值,持久后一致 | PASS | +| TC-ST-023 | P0 | Top K = 11 | 拦截或明确 clamp 到 10,显示值与保存值一致 | PASS,显示和 slider 均为 10 | +| TC-ST-024 | P0 | threshold = -0.01/0/1/1.01 | 只允许 0–1,保存值与显示一致 | NOT-RUN,rerank 未配置 | +| TC-ST-025 | P0 | 两标签页使用相同 expectedRevision 并发保存 | 第二次 409,给出刷新/合并引导,不静默覆盖 | NOT-RUN | +| TC-ST-026 | P1 | Save 返回 422/409/503 | 区分字段错误、冲突和服务不可用,不统一报网络 | FAIL,422 被误报网络 | +| TC-ST-027 | P0 | 点击删除空间 | 必须二次确认,完成后立即从列表隐藏 | PENDING-CONFIRM | + +## K. 权限、成员、API 和应用绑定 + +| ID | P | 测试场景/角色 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-AU-001 | P0 | owner 访问列表/详情/上传/设置/成员/API/删除 | 所有功能可用 | PARTIAL,owner 主流程通过 | +| TC-AU-002 | P0 | editor 访问读/编辑/文档写/检索 | 允许;成员/API key/删库按合同禁止 | NOT-RUN | +| TC-AU-003 | P0 | viewer 访问读/检索 | 只读和 query 允许;所有写操作隐藏/禁止 | NOT-RUN | +| TC-AU-004 | P0 | only_me 下非 owner 访问深链 | 与不存在资源统一为 404 | NOT-RUN | +| TC-AU-005 | P0 | all_team_members 的未显式成员 | 以 viewer 访问,不获得编辑权 | NOT-RUN | +| TC-AU-006 | P0 | partial_members 的非选中成员 | 列表不可见,深链 404 | NOT-RUN | +| TC-AU-007 | P0 | RBAC 查询异常 | fail closed,不使用本地角色绕过 | NOT-RUN | +| TC-AU-008 | P0 | 成员被移除/可见性收窄时仍打开详情 | 页面下一次请求立即失效,旧 SSE/token 不能继续 | NOT-RUN | +| TC-AU-009 | P0 | 用旧 API credential 在撤权后访问 | 立即 401/403/404,不等 TTL 过期 | NOT-RUN | +| TC-AU-010 | P0 | External API endpoint 为 `not-a-url` | 只显示一条本地化字段错误,不保存 | FAIL,见 KF-BUG-009 | +| TC-AU-011 | P1 | External API 合法 endpoint/key 创建并测试 | 保存后可编辑/撤销,key 不再明文显示 | NOT-RUN,无可用外部 API | +| TC-AU-012 | P0 | 创建 Service API Key | 只在创建瞬间显示 secret,复制和撤销可用 | PASS,已创建 1 枚临时 key | +| TC-AU-013 | P0 | 撤销 Service API Key 后重试访问 | 立即失效,不会继续签发空间 token | PENDING-CONFIRM | +| TC-AU-014 | P0 | Agent/Agent Chat 绑定 Agent channel | 只允许支持的 app 类型且 space active/channel enabled | NOT-RUN | +| TC-AU-015 | P0 | Workflow/Advanced Chat 绑定 Workflow channel | 最多 10 个 space,第 11 个有明确错误 | NOT-RUN | +| TC-AU-016 | P0 | 解除 app binding 后运行已发布 app | 旧 binding 不再可用,运行给出明确错误 | NOT-RUN | +| TC-AU-017 | P0 | 使用新 Service API Key 请求 `/v1/datasets` | 返回 200 和合法分页 JSON | PASS | +| TC-AU-018 | P0 | 将 Service API Key 修改一位后请求 | 返回 401,不返回数据 | PASS | +| TC-AU-019 | P1 | 合法 key 使用成功后重新打开密钥列表 | 最后使用时间更新 | FAIL,仍显示“从未”,KF-BUG-013 | +| TC-AU-020 | P0 | QA space API Access = off 时用有效 key 请求该 space ID | 返回不可见/404 | PASS,返回 404 | +| TC-AU-021 | P0 | QA space API Access on→刷新→off→刷新 | 即时保存;两次刷新均与服务端一致,最终恢复关闭 | PASS,开启显示“已启用”,最终确认 `aria-checked=false` | +| TC-AU-022 | P0 | 用旧 Dataset Service API Key 调用 KFS admission | 旧 key 不可冒充 KFS scoped credential | PASS,API 域名返回 401 `knowledge_fs_invalid_credential` | +| TC-AU-023 | P0 | API Access 已开启时点击空间侧栏“API 访问” | 显示可用 endpoint、专属 credential 创建/管理或明确下一步 | FAIL,只弹空白“不可用”对话框,见 KF-BUG-018 | + +## L. 恢复性、错误映射与非功能 + +| ID | P | 测试场景 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-RS-001 | P0 | API 返回 401 | 刷新身份后仅重放一次,不重复 reindex/query 等 POST | NOT-RUN | +| TC-RS-002 | P0 | API 返回 403/404 | 无权与不存在对外表现一致,不泄露资源 | FAIL-PARTIAL,404 toast 泄露路由 | +| TC-RS-003 | P0 | API 返回 409 revision conflict | 保留编辑,提示刷新/合并,不静默覆盖 | NOT-RUN | +| TC-RS-004 | P0 | API 返回 422 field validation | 错误映射到具体字段和限制 | FAIL,名称/描述均只给通用错误 | +| TC-RS-005 | P1 | API 返回 429 | 显示重试时间,按 Retry-After 控制,不密集重试 | NOT-RUN | +| TC-RS-006 | P1 | API 返回 503 | 区分服务不可用与网络断开,保留可重试数据 | ENV/PARTIAL | +| TC-RS-007 | P1 | 断网后在表单点 Save,恢复后 Retry | 不丢输入,仅提交一次 | NOT-RUN | +| TC-RS-008 | P1 | 长任务刷新/关闭再进入 | 根据 durable job ID 恢复进度,不新建任务 | NOT-RUN | +| TC-RS-009 | P1 | 快速连续点击 Start/Retry/Save | 前端防抖+后端幂等,仅一次运行 | FAIL,Retrieval 已复现重复 | +| TC-RS-010 | P1 | 多标签页交错加载列表/详情并返回 | 旧响应不覆盖新路由,焦点和状态正确 | NOT-RUN | +| TC-RS-011 | P1 | 390 px/768 px/1280 px 响应式 | 无内容遮挡、不可达导航和水平滚动陷阱 | PARTIAL,390 px 有导航溢出 | +| TC-RS-012 | P1 | WCAG A/AA 自动扫描新版六个页面 | 无 serious/critical,表单标签和对比度合格 | NOT-RUN,现有 a11y E2E 只扫 legacy | +| TC-RS-013 | P2 | Chrome 缩放 200%、系统大字体 | 无截断、遮挡、无法滚动的对话框 | NOT-RUN | + +## M. Legacy Dataset / RAG Pipeline 回归高风险用例 + +> 新版 KnowledgeFS 与旧 Dataset 后端仍并存。以下是为防止切流/兼容回归必须保留的用例,本轮没有在业务数据上执行。 + +| ID | P | 测试场景 | 预期结果 | 本轮结果 | +|---|---|---|---|---| +| TC-LG-001 | P0 | 旧 segment 创建不传 `attachment_ids` | 正常创建、建向量且返回 2xx;不得“500 但已部分落库” | STATIC-RISK | +| TC-LG-002 | P0 | 旧 metadata rename 重名/冲突 | 明确失败,刷新后真实名称不变 | STATIC-RISK | +| TC-LG-003 | P0 | 旧 metadata delete 不存在 ID | 返回 404/4xx,不吞异常后 204 | STATIC-RISK | +| TC-LG-004 | P0 | 删除仍被 App/Workflow 使用的 dataset | use-check 阻止并指明关联应用,不断链 | STATIC-RISK | +| TC-LG-005 | P1 | hit test topK 0/超大/负数/NaN,score 越界 | 统一严格校验,不进入模型/数据库 | NOT-RUN | +| TC-LG-006 | P1 | segment 空内容、QA 空 answer、disabled 后编辑 | 按状态机拒绝,无向量残留 | NOT-RUN | +| TC-LG-007 | P1 | 上传后快速 pause/resume/retry/delete/archive | 按 indexing/paused/error/archived 状态机仅允许合法操作 | NOT-RUN | +| TC-LG-008 | P1 | Pipeline draft 用 JSON/text/plain/其他 Content-Type | 只接受 application/json 或 text/plain JSON,其他 415 | NOT-RUN | +| TC-LG-009 | P1 | 两标签页保存同一 draft hash | 第二次 DraftWorkflowNotSync,不静默覆盖 | NOT-RUN | +| TC-LG-010 | P1 | 无 draft/缺 knowledge-index node/缺 embedding/不兼容 chunk 发布 | 发布前完整验证,不产生半成品 published version | NOT-RUN | +| TC-LG-011 | P1 | 删除 active workflow/draft,restore draft | 阻止删 active/draft;只能从 published restore | NOT-RUN | +| TC-LG-012 | P1 | DSL import 无效 YAML/依赖 pending/export secret | 失败 400,依赖待确认 202,默认 export 剥离凭据 | NOT-RUN | + +## 测试执行建议顺序 + +1. 先解决 KF-BUG-001–005,补充有额度的模型环境。 +2. 经用户确认后执行 TC-DO-003–014,等索引 ready。 +3. 紧接执行 TC-DD-004–012、TC-RE-009–011、TC-QA-009–017,保证同一批合成语料可串联校验。 +4. 使用 owner/editor/viewer 三账号执行 TC-AU-001–009,并在正在运行的 SSE 中撤权。 +5. 最后执行删除和清理,核对列表、FS、检索、概览和 App binding 全部无残留。 diff --git a/test-results/knowledge-fs/2026-08-11/knowledge-fs-functional-test-report.md b/test-results/knowledge-fs/2026-08-11/knowledge-fs-functional-test-report.md new file mode 100644 index 00000000000..35afb93d484 --- /dev/null +++ b/test-results/knowledge-fs/2026-08-11/knowledge-fs-functional-test-report.md @@ -0,0 +1,292 @@ +# KnowledgeFS 全功能测试报告 + +配套交付:[详细测试用例](./knowledge-fs-detailed-test-cases.md) · [合成上传数据](./test-data/) + +## 1. 测试摘要 + +- 测试环境:`https://new-rag.dify.dev/datasets?view=new` +- 测试日期:2026-08-11 +- 测试终端:用户已登录的 Google Chrome,桌面视口 + 390×844 移动视口 +- 测试角色:当前 workspace 的 owner 用户 +- 测试方法:真实浏览器功能测试、边界值、错误处理、状态流转、恢复性、静态代码/现有自动化测试对照 +- 用例库:已编制 242 条可执行用例;按当前用例状态统计为 89 `PASS`、33 条含 `FAIL`、4 `ENV`、4 `ENV-BLOCKED`、27 `PENDING-CONFIRM`,其余为部分执行、需多用户/provider/大数据的未执行项或静态风险 +- 初始测试状态:Chrome 本地文件权限已修复,合成文件已实际提交到上传/Quality 页面;QA API Access 已完成 off→on→刷新→off→刷新并恢复关闭。初测时所有文档 staging 均失败或悬挂,QA 空间为 0 文档。永久删除、创建 KnowledgeFS 专属凭据与密钥撤销仍等待操作前确认。 +- 修复复测状态:19 个已确认缺陷中 18 个已完成代码修复、专项自动化回归及 Linear 状态同步;唯一未关闭的是 KF-BUG-014 / WTA-1928。该项复测时 TXT/Markdown staging 已成功,但测试环境的文档 claim 路由稳定返回 404,需部署当前 API 后完成 `staging → claim 202 → ready` 实链路复测。 + +## 2. 结论 + +初始功能测试表明 KnowledgeFS 新版的页面架构和大部分基础交互可用:新旧版切换、列表筛选、空库创建、空状态、概览指标、文档列表、任务抽屉、Fast/Deep 空库检索、Quality 参考问题创建/编辑、设置保存与 API Access 开关持久化均能正常工作。 + +经过本轮修复,除 KF-BUG-014 的测试环境部署阻塞外,其余 18 个已确认缺陷均已修复并通过所属层专项回归。下列条目保留初测时的风险与现象,最新修复状态以第 4 节缺陷表和各缺陷复测记录为准。 + +但当前不建议直接认定为“可全量发布”。除原有 5 个 P1 级问题外,本轮真实文件/API 集成又暴露出 5 个 P1 级阻断: + +1. Fast/Deep 检索单次操作会生成重复记录/重复任务,存在重复计费和重复执行风险。 +2. 创建名称超长和设置描述超长均缺少前端校验,后端拒绝后界面还给出错误或误导性归因。 +3. 不存在的知识库深链会把后端 API 路由和候选路由直接暴露在英文 toast 中。 +4. 网站抓取“最大页数”显示值与真实提交值不一致,越界值被静默截断。 +5. 既有文档库的 Fast/Deep 在当前环境中全部返回 `Query generation failed`,Research 可进入流式过程但无召回;QA 空库 Fast/Deep 则可正常返回 0 分段。真实答案与引用仍受模型额度和上传失败双重阻塞。 +6. 合法 TXT/Markdown staged upload 全部返回 `Internal Server Error`,无法创建任何文档,解析、分块、索引和引用链路整体被阻断。 +7. 0 B 文件被前端视为有效并启用提交,后端 422 后只显示英文通用错误;精确 15 MiB 上传 43+ 秒无超时收敛。 +8. 合法 2 行 Quality CSV 可正确预览,但导入仅报“未知错误”,刷新确认 0 行落库。 +9. API Access 开启且刷新持久后,侧栏入口仍只弹空白“不可用”;旧 Dataset Service API Key 调用 KFS admission 得到 401,界面没有可用的 KFS 专属凭据路径。 +10. QA 空库 Research 单击后 27+ 秒无任务、阶段、结果或错误,静默回到可重试状态;Fast/Deep 在同一空库均能正确显示 0 分段。 + +## 3. 测试数据与环境影响 + +### 3.1 本轮创建的合成数据 + +- 测试知识库:`KF-QA-20260811-066145` +- Knowledge Space ID:`019fef0d-732b-73d4-95e6-e943be794403` +- 描述:`KnowledgeFS 功能测试专用;仅含合成数据;待确认后清理。` +- 可见权限:`only_me` +- API 访问:测试中临时启用并经刷新确认;已恢复为未启用并再次刷新确认 +- 检索模式:Fast,Top K = 10(边界测试后已恢复) +- Quality 参考问题:1 条合成草稿,已验证创建和编辑;2 行 CSV 导入失败且刷新后无部分落库 +- Firecrawl:1 个已停止的临时抓取草稿,未添加为数据源 +- 文件:尝试 3 个小型 TXT/MD、0 B、精确 15 MiB、15 MiB+1、`.exe`;staging/本地校验后仍为 0 文档 +- Service API Key:已创建 1 个临时 key,已完成鉴权测试,尚未撤销;完整 secret 未写入报告 + +QA 空间的 Fast 和 Deep 各执行过一次 0 文档检索并正常返回 0 分段;这些“记录”刷新后不再显示。Research 单次执行没有生成可见记录。 + +### 3.2 对既有知识库的影响 + +在现有 `ceshi` 知识库中产生了以下合成检索记录,用于确认单次提交重复执行问题: + +- Fast:3 条同一合成问题记录(首次 1 条 + 单次 Retry 额外生成 2 条) +- Deep:单次 Start 生成 2 条 +- Research:1 条 + +既有知识库的名称、描述、可见权限、API 访问和 Top K 均已恢复到测试前状态。本轮没有删除任何现有数据。 + +## 4. 已确认缺陷 + +| 编号 | 优先级 | 模块 | 摘要 | 状态 | +|---|---|---|---|---| +| KF-BUG-001 | P1 / Major | Retrieval | Fast/Deep 单次开始或重试生成两条记录/任务 | 已修复;WTA-1914 Done | +| KF-BUG-002 | P1 / Major | Create | 41 字名称可提交,后端拒绝后误报“权限” | 已修复;WTA-1915 Done | +| KF-BUG-003 | P1 / Major | Settings | 2001 字描述可提交,后端拒绝后误报“网络” | 已修复;WTA-1916 Done | +| KF-BUG-004 | P1 / Major | Error handling | 不存在空间的 404 toast 暴露后端 API 路由 | 已修复;WTA-1917 Done | +| KF-BUG-005 | P1 / Major | Website source | 抓取页数显示值与实际提交值静默不一致 | 已修复;WTA-1918 Done | +| KF-BUG-006 | P2 / Major | Document detail | 详情内容已加载时同时出现 3 条相同英文 404 toast | 已修复;WTA-1919 Done | +| KF-BUG-007 | P2 / Major | Metadata | 详情页点击“编辑”后按钮永久禁用,无编辑器和反馈 | 已修复;WTA-1920 Done | +| KF-BUG-008 | P2 / Normal | Quality | 必填错误在输入合法值后不消失,查找证据只报“未知错误” | 已修复;WTA-1921 Done | +| KF-BUG-009 | P2 / Normal | External API | 非法 endpoint 同时弹出两条错误,中英混用 | 已修复;WTA-1922 Done | +| KF-BUG-010 | P3 / Minor | Retrieval | 有大量历史记录时仍显示“暂无测试记录” | 已修复;WTA-1923 Done | +| KF-BUG-011 | P3 / Minor | List | 搜索无结果时列表全空,无“无结果”解释和清除指引 | 已修复;WTA-1924 Done | +| KF-BUG-012 | P3 / Usability | Responsive | 390 px 下顶部功能导航超出视口,“设置”无明显可达性提示 | 已修复;WTA-1925 Done | +| KF-BUG-013 | P2 / Normal | Service API | Key 已成功鉴权调用 200,密钥列表“最后使用”仍持续显示“从未” | 已修复;WTA-1926 Done | +| KF-BUG-014 | P1 / Major | Document upload | 合法 TXT/Markdown staged upload 全部报 `Internal Server Error`,0 文档 | 复测阻塞:staging 已成功,claim 端点 404;WTA-1928 未关闭 | +| KF-BUG-015 | P1 / Major | Upload validation | 0 B 文件被计为有效并启用提交,422 仅显示英文通用错误 | 已修复;WTA-1929 Done | +| KF-BUG-016 | P2 / Major | Upload recovery | 精确 15 MiB 上传 43+ 秒持续“正在上传…”,无超时/失败收敛 | 已修复;WTA-1930 Done | +| KF-BUG-017 | P1 / Major | Quality CSV | 合法 2 行 CSV 预览正确,提交只报“未知错误”且 0 行落库 | 已修复;WTA-1931 Done | +| KF-BUG-018 | P1 / Major | KnowledgeFS API | API Access 已启用但侧栏仅弹空白“不可用”,无 KFS 凭据路径 | 已修复;WTA-1932 Done | +| KF-BUG-019 | P1 / Major | Research | 空库 Research 单次启动后无任务、阶段、结果或错误反馈 | 已修复;WTA-1933 Done | + +### 4.1 修复验证 + +- Web:14 个所属功能 spec、507 个测试全部通过;全量 `pnpm type-check` 通过。 +- Dify API:5 个相关测试文件、172 个测试全部通过;目标 Ruff 检查通过。 +- KnowledgeFS API:Golden Question gateway 回归 5/5 通过;API package TypeScript 检查通过。 +- 补丁完整性:`git diff --check` 通过;本轮未创建 Git commit。 +- Linear:18 个已验证缺陷均更新为 `Done` 并指派给 `jyong`;WTA-1928 保持未关闭并继续归属 Milestone `测试联调`。 + +### 4.2 Linear Issue 映射 + +以下缺陷均已创建到 Linear 项目 [Make RAG Great Again](https://linear.app/dify/project/make-rag-great-again-b9f26b73ca33/issues),Team 为 `WTA`,初始状态为 `Backlog`,Milestone 为 `测试联调`。优先级映射为 P1 → High、P2 → Medium、P3 → Low。 + +| 缺陷 | Linear Issue | Linear 优先级 | +|---|---|---| +| KF-BUG-001 | [WTA-1914](https://linear.app/dify/issue/WTA-1914) | High | +| KF-BUG-002 | [WTA-1915](https://linear.app/dify/issue/WTA-1915) | High | +| KF-BUG-003 | [WTA-1916](https://linear.app/dify/issue/WTA-1916) | High | +| KF-BUG-004 | [WTA-1917](https://linear.app/dify/issue/WTA-1917) | High | +| KF-BUG-005 | [WTA-1918](https://linear.app/dify/issue/WTA-1918) | High | +| KF-BUG-006 | [WTA-1919](https://linear.app/dify/issue/WTA-1919) | Medium | +| KF-BUG-007 | [WTA-1920](https://linear.app/dify/issue/WTA-1920) | Medium | +| KF-BUG-008 | [WTA-1921](https://linear.app/dify/issue/WTA-1921) | Medium | +| KF-BUG-009 | [WTA-1922](https://linear.app/dify/issue/WTA-1922) | Medium | +| KF-BUG-010 | [WTA-1923](https://linear.app/dify/issue/WTA-1923) | Low | +| KF-BUG-011 | [WTA-1924](https://linear.app/dify/issue/WTA-1924) | Low | +| KF-BUG-012 | [WTA-1925](https://linear.app/dify/issue/WTA-1925) | Low | +| KF-BUG-013 | [WTA-1926](https://linear.app/dify/issue/WTA-1926) | Medium | +| KF-BUG-014 | [WTA-1928](https://linear.app/dify/issue/WTA-1928) | High | +| KF-BUG-015 | [WTA-1929](https://linear.app/dify/issue/WTA-1929) | High | +| KF-BUG-016 | [WTA-1930](https://linear.app/dify/issue/WTA-1930) | Medium | +| KF-BUG-017 | [WTA-1931](https://linear.app/dify/issue/WTA-1931) | High | +| KF-BUG-018 | [WTA-1932](https://linear.app/dify/issue/WTA-1932) | High | +| KF-BUG-019 | [WTA-1933](https://linear.app/dify/issue/WTA-1933) | High | + +### KF-BUG-001:Fast/Deep 单次操作重复提交 + +**前置条件**:进入有历史数据的知识库 `ceshi` → 检索测试。 + +**步骤**: + +1. Fast 输入唯一问题 `KF_QA_NO_RESULT_20260811_X9Z`。 +2. 点击一次“开始测试”,等待失败。 +3. 点击一次“重试”。 +4. 切换 Deep,输入普通问题,点击一次“开始测试”。 + +**期望**:每次用户操作仅产生 1 个请求、1 个任务和 1 条记录。 + +**实际**:Fast 单次 Retry 额外生成 2 条;Deep 单次 Start 生成 2 条,其中一条显示 0s,另一条显示 1s。 + +**影响**:重复模型调用、重复计费、任务历史污染、Quality 归档和观测指标失真。 + +### KF-BUG-002:创建名称超长时前端放行并误导性报错 + +**步骤**:创建知识库 → 从空白开始 → 输入 41 个中文字符 → 点击创建。 + +**期望**:前端限制为 40 个字符,或在字段下明确提示“不超过 40 个字符”并禁用提交。 + +**实际**:页面接受 41 字,创建按钮仍可用;后端拒绝后同时出现英文 `KnowledgeFS request is invalid.` 与“请检查权限后重试”。权限说明与真实原因无关。 + +**对照**:同一测试库设置为精确 40 字时保存成功。 + +### KF-BUG-003:设置描述超长后误报网络错误 + +**步骤**:设置 → 描述输入 2001 字 → 保存。 + +**期望**:最多 2000 字;超限时字段级提示并禁止保存。 + +**实际**:保存按钮可用;后端返回无效请求,界面主告警却是“无法保存更改。请检查网络后重试”,同时还有英文 toast。 + +**对照**:精确 2000 字保存成功,随后已恢复原描述。 + +### KF-BUG-004:404 错误泄露内部 API 路由 + +**步骤**:直接访问 `/datasets/new/00000000-0000-0000-0000-000000000000`。 + +**期望**:仅显示本地化的“知识库不存在或无权访问”,不向用户暴露内部路由。 + +**实际**:主页正确显示“未找到知识库”,但 toast 包含完整 `/console/api/knowledge-fs/spaces/...` 请求 URI 和多个候选后端路由。 + +**影响**:开发细节泄露、中英文不一致,也使标准 404 用户体验变差。 + +### KF-BUG-005:抓取页数越界被静默截断 + +**步骤**:添加数据源 → Website/Firecrawl → 有效 URL → 展开抓取选项 → 依次输入 `0`、`1.5`、`1001`。 + +**期望**:只接受 1–200 的整数;无效时提示并禁止抓取。 + +**实际**:用户可见文本分别保留为 `0`、`1.5`、`1001`,内部 number 值却静默变为 `1`、`1`、`200`,“抓取并预览”仍可用。 + +**影响**:用户对抓取规模的认知与实际执行不一致,可造成内容缺失且难以定位。 + +### KF-BUG-013:Service API Key 最后使用时间不更新 + +**步骤**: + +1. 在“服务 API → API 密钥”中创建一枚临时 key。 +2. 使用该 key 请求 `GET https://new-rag-api.dify.dev/v1/datasets?page=1&limit=1`,得到 HTTP 200 和合法 JSON 结构。 +3. 将 key 末位替换后重试,得到 HTTP 401,证明 200 来自该新 key 的有效鉴权。 +4. 多次关闭并重新打开 API 密钥列表。 + +**期望**:“最后使用”更新为实际调用时间或在明确的可接受延迟后更新。 + +**实际**:列表持续显示“从未”。 + +**影响**:凭据审计信息不可靠,管理员无法正确判断闲置、泄露或正在使用的 key。 + +### KF-BUG-014 / 015 / 016:文档上传链路不可用且边界恢复不一致 + +**小文件步骤**:在 QA 空间 Documents → 添加文档,一次选择 `kf-qa-marker.txt`、`kf-qa-duplicate.txt`、`kf-qa-unicode-中文.md`。 + +**实际**:三个文件均被 UI 计为有效并立即 staging;随后同时出现 1 条“无法上传这些文档,请重试。”和 3 条 `Internal Server Error`。点击“添加并处理”后页面不跳转,刷新 Documents 仍为 0 文档,确认不存在表面失败/后台部分成功。 + +**空文件步骤**:单独选择 0 B `kf-qa-empty.txt`。 + +**实际**:UI 显示“1 个中 1 个有效”、`TXT · 0 B`、预览按钮和可用的“添加并处理”;后端拒绝后仅显示“无法上传这些文档,请重试。”与英文 `The request was well-formed but was unable to be followed due to semantic errors.`,没有“空文件”字段级原因。 + +**边界步骤**:分别选择 15,728,640 B 和 15,728,641 B 文件。 + +**实际**:上限+1 正确本地拒绝;精确上限正确进入 staging,但 43+ 秒仍停留“正在上传…”,主按钮仍可用。点击取消后表单关闭且 0 文档,但稍后仍出现上传失败提示,说明 in-flight 请求没有及时收敛到取消状态。 + +**影响**:当前环境无法通过 UI 建立任何新文档;同时空文件会浪费请求,长上传缺少超时和可解释的恢复状态。 + +**2026-08-11 修复后复测**:在同一 QA 空间分别重新选择 `kf-qa-unicode-中文.md`(230 B)与 `kf-qa-marker.txt`(315 B),两者 staged upload 均成功,文件由“正在上传”收敛为可提交状态,说明最初的 staging 500 已不再复现。点击“添加并处理”后,Console API 的 `POST /console/api/knowledge-fs/spaces/019fef0d-732b-73d4-95e6-e943be794403/documents` 稳定返回 404;同一 staged TXT 重试仍为 404。页面无法 claim、解析或索引文档,并再次泄露候选内部路由。两次取消后暂存对象均已清理,Documents 保持 0 文档。当前仓库已包含该 POST 路由,因此更符合测试环境前后端部署版本不一致;本地已将 claim 请求设为 silent,避免部署后继续向用户泄露原始路由。部署当前 API 并完成 TXT/Markdown `staging → claim 202 → ready` smoke 前,WTA-1928 不应标记 Done。 + +### KF-BUG-017:Quality CSV 可预览但无法导入 + +**步骤**:Quality → 导入 CSV → 选择包含 `question,evidence,tags` 的 2 行 UTF-8 合成 CSV;两行 tags 均使用 quoted comma。 + +**期望**:两行预览后导入;没有文档证据匹配时按页面说明保存为草稿,并给出逐行结果。 + +**实际**:预览内容、中文和 tags 均正确;点击导入只在弹窗内显示“未知错误”。关闭弹窗并刷新后参考问题仍为 1 条,确认没有部分成功。 + +### KF-BUG-018:开启 API Access 后无可用 KnowledgeFS 凭据入口 + +**步骤**:Settings 打开 API Access,等待即时保存并刷新;点击侧栏“API 访问”。 + +**实际**:开关和侧栏均稳定显示已启用,但弹窗只有标题“不可用”和一个无标签关闭按钮,没有 endpoint、权限动作、创建/选择 KFS credential 或解释。使用现有 Dataset Service API Key 请求 API 域名的 KFS admission,得到 401 `knowledge_fs_invalid_credential`;Web 域名同一路径为 HTML 404。完成验证后已关闭 API Access 并刷新确认。 + +**影响**:管理员可以打开能力开关,却无法从产品界面获得真正可用的 KFS credential,形成“已启用但不可调用”的死路。 + +### KF-BUG-019:空库 Research 失败静默 + +**步骤**:QA 空库 Retrieval,选择 Research,输入合成问题,单击一次“开始研究”。 + +**期望**:至少创建一条记录并显示计划/检索阶段;若空库不支持 Research,应明确提示无文档或无证据。 + +**实际**:观察 27+ 秒后仍无记录、阶段、结果、toast 或错误;按钮恢复为可点击。相同空库的 Fast/Deep 分别在 813 ms/720 ms 正常返回 0 分段和本地化空结果。 + +## 5. 环境问题、受限项与待复测风险 + +| 编号 | 现象 | 当前判定 | 下一步 | +|---|---|---|---| +| ENV-001 | 页面显示模型消息额度为 0;既有 `ceshi` 的 Fast/Deep 均 `Query generation failed` | 更像模型/额度环境阻塞;QA 空库 Fast/Deep 的 0 结果链路可用 | 补充可用模型额度后复测真实答案和引用 | +| ENV-002 | 既有库 Research 流式步骤可见,最终“检索失败/未召回分段” | 链路启动正常,语料/模型不可用;QA 空库另有静默失败缺陷 | 文件上传并建立索引后复测 | +| ENV-003 | Firecrawl 对 `https://example.com` 36+ 秒仍 0 页 | 未能区分 provider/网络/产品超时 | 有 provider 观测日志时复测;本轮 Stop 状态流转正常 | +| ENV-004 | Jina Reader 显示“集成未安装” | 环境能力缺失,非功能缺陷 | 安装并配置凭据后测试 | +| ENV-005 | Chrome 扩展最初拒绝文件选择 | **已解决**:用户开启 file URL 访问后,多文件、Unicode 和 15 MiB 文件均可交给页面 | 无;后续失败已确认发生在应用 staging 层 | +| RISK-001 | 列表搜索实现可能只过滤已加载页 | 代码侧候选风险,当前只有 6 个空间无法实证 | 准备超过一页的数据后搜索末页项 | +| RISK-002 | 旧版 segment 无 `attachment_ids` 可能接口 500 但数据部分落库 | 后端静态审查候选 | 在可清理的 legacy 测试库通过 UI/API 复测 | +| RISK-003 | 旧版 metadata rename/delete 可能吞异常后表面成功 | 后端静态审查候选 | 每次操作后刷新并核对真实 metadata | +| RISK-004 | 删除仍被 App 使用的 legacy dataset 可能未调用 use-check | 后端静态审查候选 | 使用专用 App+dataset 验证防误删 | + +## 6. 已执行功能覆盖 + +下表记录初始真实环境测试结果,用于保留原始证据;修复后的最新状态以第 4 节为准。 + +| 功能域 | 已实测内容 | 结果 | +|---|---|---| +| 入口/路由 | `/datasets?view=new`、Legacy/New 切换、刷新、列表回显、不存在 ID 深链 | 主流程通过;404 toast 有泄露问题 | +| 列表 | 大小写不敏感搜索、无结果、创建者筛选/搜索/重置、新库排序 | 通过;无结果缺少空状态 | +| 创建 | 空库、名称空白/41/精确40字、描述、only_me、异步加载后进入 Sources | 合法创建通过;超长校验失败 | +| 概览 | 无数据 onboarding、24h/30d 指标、需关注分页、最近活动抽屉与运营者筛选、资产图谱 | 通过 | +| 数据源目录 | Website/Online Documents/Drive,Firecrawl/Jina/WaterCrawl/Notion/Google Docs/Confluence/Drive/OneDrive/S3 入口 | 目录可用;部分集成未安装 | +| Website | URL 格式、抓取选项、最大页数边界、实际 Firecrawl preview、Stop、Retry 入口、草稿离开确认 | Stop 通过;页数校验失败;preview 环境阻塞 | +| 文档列表/上传 | Ready/Failed 筛选、搜索、无结果、任务抽屉、选择/批量操作、行菜单、重命名;真实 TXT/MD/Unicode/0 B/15 MiB/15 MiB+1/`.exe` | 列表主流程通过;合法 staging 失败、空文件校验和大文件恢复异常 | +| 文档详情 | 内容加载、metadata 区、编辑入口 | 内容加载成功,同时有重复 404;metadata 编辑异常 | +| Retrieval | 空白输入、2000 字 UI 上限属性、Fast/Deep/Research,Start、Retry、记录、空库 0 结果、Research 流式阶段 | QA Fast/Deep 空结果通过;既有库重复/额度失败;QA Research 静默 | +| Quality | 空状态、参考问题/问题案例 tab、新建必填校验、创建草稿、编辑、选择、2 行 CSV 预览与提交 | 创建/编辑与 CSV 解析通过;CSV 提交未知错误、校验清除异常 | +| Settings | 名称 40/41、描述 2000/2001、可见权限、partial 零成员、API Access off/on/off 持久化、Fast/Deep、Top K 0/1/10/11、Rerank/threshold 依赖 | API Access 已恢复 off;描述超长错误处理失败 | +| API 入口 | External Knowledge API;Dataset Service API key 创建/正负鉴权;KFS admission Web/API 域名;空间 API Access | Dataset key 200/错误 key 401;KFS admission 对旧 key 返回结构化 401;空间 API 面板不可用;最后使用不更新 | +| 响应式 | 390×844 下上传页、顶部功能导航 | 内容可用;导航超出视口待改进 | + +## 7. 暂未执行的破坏性/需授权用例 + +以下用例已就绪,但本轮未在没有明确确认的情况下执行: + +1. 真实文件上传已执行:`.exe` 与 15 MiB+1 本地拒绝符合预期;KF-BUG-015/016 已修复。小型 TXT/Markdown staging 复测已成功,但 claim 端点仍在测试环境返回 404;部署当前 API 后继续部分成功、重复与 Unicode 内容验证。 +2. 解析和索引仍被 claim 404 与 0 文档阻断;部署同步后验证 revision/chunk/outline/metadata、重命名、重建索引、禁用、批量任务、取消/重试和下载。 +3. 基础 Quality CSV 初测可预览但无法导入;KF-BUG-017 的“匹配能力不可用时保存草稿”已完成代码修复和 API 回归。500/501 行、1 MiB 边界、中文表头和部分错误恢复仍需部署后实链路复测。 +4. QA API Access on/off 与恢复已完成。KF-BUG-018 已补齐 KnowledgeFS 专属 credential 的创建、列表、撤销及 admission endpoint 说明;部署后仍需在真实环境执行两步 admission+SSE,现有 Dataset Service API Key 按安全边界继续应被 401 拒绝。 +5. 永久清理仍需操作前确认:删除 1 条参考问题、1 个已停止 Firecrawl 草稿和整个 QA 空间;撤销临时 Dataset Service API Key。 + +## 8. 自动化覆盖现状 + +- `web/features/new-rag` 约 642 个前端单元/组件测试,功能广,但主要基于 mocked API 和 happy-dom。 +- Dify 后端 KnowledgeFS 相关约 511 个 Python test function,但 `api/tests/integration_tests` 没有真实 KnowledgeFS 集成链路覆盖。 +- 独立 `knowledge-fs/` 约 4131 个 TypeScript test declaration,大部分 E2E 仍使用 in-memory/fake/stub。 +- 现有 Cucumber 只覆盖旧 Dataset API 和少量 Knowledge Retrieval 节点,没有 `/datasets?view=new` 的真实 Chrome E2E。 +- 结论:当前最大缺口是登录态真实 Chrome + Dify BFF + KnowledgeFS + 对象存储 + 解析/索引 + 模型 + provider 的跨服务链路。 + +## 9. 发布建议 + +1. 发布门禁继续阻断唯一未完成的 KF-BUG-014:部署当前 API 后必须通过 TXT/Markdown `staging → claim 202 → ready`。KF-BUG-001–013、015–019 已完成代码修复与所属层回归,仍应随同部署进行关键真实链路复测。 +2. 为所有 KnowledgeFS API 建立统一的错误码→本地化字段错误映射,不直接显示后端原始 message。 +3. 为 query admission/stream 和 UI Start/Retry 增加端到端幂等性用例,以记录 ID、trace ID 和计费事件三重断言“一次操作只有一次执行”。 +4. 为新版列表、创建、文件上传、真实索引、Fast/Deep/Research 引用、Quality replay 增加 Playwright/Cucumber 浏览器回归。 +5. 在测试环境配置一个有额度的固定模型和一个稳定的合成 provider,避免功能回归长期被“额度为 0”阻塞。 diff --git a/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-duplicate.txt b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-duplicate.txt new file mode 100644 index 00000000000..bffe53ea8c6 --- /dev/null +++ b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-duplicate.txt @@ -0,0 +1,5 @@ +KnowledgeFS QA synthetic corpus + +Unique marker: KF_QA_MARKER_20260811_X9Z +This intentionally duplicates kf-qa-marker.txt to test duplicate-content handling. + diff --git a/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-marker.txt b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-marker.txt new file mode 100644 index 00000000000..28b343723d8 --- /dev/null +++ b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-marker.txt @@ -0,0 +1,8 @@ +KnowledgeFS QA synthetic corpus + +Unique marker: KF_QA_MARKER_20260811_X9Z +The marker is used only to verify parsing, indexing, retrieval, citations, and deletion cleanup. + +中文校验:知识库测试标记是 KF_QA_MARKER_20260811_X9Z。 +English check: the KnowledgeFS test marker is KF_QA_MARKER_20260811_X9Z. + diff --git a/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-reference-questions.csv b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-reference-questions.csv new file mode 100644 index 00000000000..cfadcee71a5 --- /dev/null +++ b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-reference-questions.csv @@ -0,0 +1,4 @@ +question,evidence,tags +KnowledgeFS 测试标记是什么?,KF_QA_MARKER_20260811_X9Z,"smoke,marker" +哪个标记用于 Unicode 测试?,KF_QA_UNICODE_20260811,"unicode,markdown" + diff --git a/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-unicode-中文.md b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-unicode-中文.md new file mode 100644 index 00000000000..5630ecfa389 --- /dev/null +++ b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-unicode-中文.md @@ -0,0 +1,6 @@ +# KnowledgeFS 合成测试文档 + +- 标记:`KF_QA_UNICODE_20260811` +- 用途:验证 Unicode 文件名、Markdown 解析、分块和引用定位。 +- 内容:这是完全合成的测试数据,不包含任何业务信息。 + diff --git a/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-unsupported.exe b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-unsupported.exe new file mode 100644 index 00000000000..a58cca1c233 --- /dev/null +++ b/test-results/knowledge-fs/2026-08-11/test-data/kf-qa-unsupported.exe @@ -0,0 +1,2 @@ +This is harmless plain text with an intentionally unsupported .exe extension. +It must never be executed; it is only an upload validation fixture. diff --git a/web/app/components/datasets/external-api/external-api-modal/Form.tsx b/web/app/components/datasets/external-api/external-api-modal/Form.tsx index f3bee9cefc6..ab17b65819f 100644 --- a/web/app/components/datasets/external-api/external-api-modal/Form.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/Form.tsx @@ -15,6 +15,7 @@ type FormProps = { onChange: (val: CreateExternalAPIReq) => void formSchemas: FormSchema[] inputClassName?: string + errors?: Partial> } const Form: FC = React.memo( @@ -26,6 +27,7 @@ const Form: FC = React.memo( onChange, formSchemas, inputClassName, + errors, }) => { const { t, i18n } = useTranslation() const docLink = useDocLink() @@ -50,6 +52,8 @@ const Form: FC = React.memo( variable === 'name' ? value[variable] : value.settings[variable as keyof typeof value.settings] || '' + const fieldError = errors?.[variable as keyof typeof errors] + const errorId = `${variable}-error` return (
= React.memo( type={type === 'secret' ? 'password' : 'text'} id={variable} name={variable} + aria-describedby={fieldError ? errorId : undefined} + aria-invalid={Boolean(fieldError)} value={fieldValue} onChange={(val) => handleFormChange(variable, val.target.value)} required={required} className={cn(inputClassName)} /> + {fieldError && ( + + )}
) } diff --git a/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx index da5adb3caba..4b0e3adf6e8 100644 --- a/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx @@ -220,6 +220,25 @@ describe('AddExternalAPIModal', () => { }) }) + it('shows one localized field error and does not request an invalid endpoint', async () => { + render() + + fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Test' } }) + const endpointInput = screen.getByLabelText(/api endpoint/i) + fireEvent.change(endpointInput, { target: { value: 'not-a-url' } }) + fireEvent.change(screen.getByLabelText(/api key/i), { target: { value: 'key12345' } }) + + fireEvent.click(screen.getByText('dataset.externalAPIForm.save').closest('button')!) + + const alerts = await screen.findAllByRole('alert') + expect(alerts).toHaveLength(1) + expect(alerts[0]).toHaveTextContent('dataset.newKnowledge.invalidRootUrl') + expect(endpointInput).toHaveAttribute('aria-invalid', 'true') + expect(endpointInput).toHaveAccessibleDescription('dataset.newKnowledge.invalidRootUrl') + expect(createExternalAPI).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + it('should handle create API error', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed')) @@ -240,7 +259,7 @@ describe('AddExternalAPIModal', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'Failed to save/update External API', + message: 'common.api.actionFailed', }) }) diff --git a/web/app/components/datasets/external-api/external-api-modal/index.tsx b/web/app/components/datasets/external-api/external-api-modal/index.tsx index d012ce49a01..aed8d743d6e 100644 --- a/web/app/components/datasets/external-api/external-api-modal/index.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/index.tsx @@ -66,6 +66,17 @@ const emptyExternalAPIFormData: CreateExternalAPIReq = { }, } +function isValidHttpEndpoint(value: string) { + try { + const endpoint = new URL(value) + return ( + (endpoint.protocol === 'http:' || endpoint.protocol === 'https:') && Boolean(endpoint.host) + ) + } catch { + return false + } +} + const AddExternalAPIModal: FC = ({ data, onSave, @@ -77,6 +88,7 @@ const AddExternalAPIModal: FC = ({ const { t } = useTranslation() const [loading, setLoading] = useState(false) const [showConfirm, setShowConfirm] = useState(false) + const [endpointError, setEndpointError] = useState() const [formData, setFormData] = useState(() => isEditMode && data ? data : emptyExternalAPIFormData, ) @@ -86,9 +98,15 @@ const AddExternalAPIModal: FC = ({ : Object.values(value).some((v) => v.trim() === ''), ) const handleDataChange = (val: CreateExternalAPIReq) => { + if (val.settings.endpoint !== formData.settings.endpoint) setEndpointError(undefined) setFormData(val) } const handleSave = async () => { + const endpoint = formData.settings.endpoint.trim() + if (!isValidHttpEndpoint(endpoint)) { + setEndpointError(t(($) => $['newKnowledge.invalidRootUrl'], { ns: 'dataset' })) + return + } if (formData && formData.settings.api_key && formData.settings.api_key?.length < 5) { toast.error(t(($) => $['apiBasedExtension.modal.apiKey.lengthError'], { ns: 'common' })) setLoading(false) @@ -103,11 +121,13 @@ const AddExternalAPIModal: FC = ({ formData.settings.api_key === '[__HIDDEN__]' ? '[__HIDDEN__]' : formData.settings.api_key await onEdit({ ...formData, - settings: { ...formData.settings, api_key: apiKeyToSend }, + settings: { ...formData.settings, api_key: apiKeyToSend, endpoint }, }) toast.success('External API updated successfully') } else { - const res = await createExternalAPI({ body: formData }) + const res = await createExternalAPI({ + body: { ...formData, settings: { ...formData.settings, endpoint } }, + }) if (res && res.id) { toast.success('External API saved successfully') onSave(res) @@ -116,7 +136,7 @@ const AddExternalAPIModal: FC = ({ onCancel() } catch (error) { console.error('Error saving/updating external API:', error) - toast.error('Failed to save/update External API') + toast.error(t(($) => $['api.actionFailed'], { ns: 'common' })) } finally { setLoading(false) } @@ -195,6 +215,7 @@ const AddExternalAPIModal: FC = ({ value={formData} onChange={handleDataChange} formSchemas={formSchemas} + errors={{ endpoint: endpointError }} className="min-h-0 w-full flex-1 overflow-y-auto px-6 py-3" />
@@ -205,9 +226,12 @@ const AddExternalAPIModal: FC = ({ type="submit" variant="primary" onClick={() => { + if (!isValidHttpEndpoint(formData.settings.endpoint.trim())) { + setEndpointError(t(($) => $['newKnowledge.invalidRootUrl'], { ns: 'dataset' })) + return + } if (isEditMode && (datasetBindings?.length ?? 0) > 0) setShowConfirm(true) - else if (isEditMode && onEdit) onEdit(formData) - else handleSave() + else void handleSave() }} disabled={hasEmptyInputs || loading} > diff --git a/web/features/new-rag/__tests__/create-knowledge-page.spec.tsx b/web/features/new-rag/__tests__/create-knowledge-page.spec.tsx index 73002491471..c506482889f 100644 --- a/web/features/new-rag/__tests__/create-knowledge-page.spec.tsx +++ b/web/features/new-rag/__tests__/create-knowledge-page.spec.tsx @@ -608,6 +608,38 @@ describe('CreateKnowledgePage', () => { expect(createButton).toBeEnabled() }) + it('accepts a 40-character name and submits the exact value', async () => { + const user = userEvent.setup() + const boundaryName = '知'.repeat(40) + renderPage() + + await user.type( + screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }), + boundaryName, + ) + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })) + + await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce()) + expect(serviceMock.create).toHaveBeenCalledWith({ + body: expect.objectContaining({ name: boundaryName }), + }) + }) + + it('keeps a 41-character name visible, identifies the field, and blocks the request', async () => { + const user = userEvent.setup() + const invalidName = '知'.repeat(41) + renderPage() + + const nameInput = screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }) + await user.type(nameInput, invalidName) + + expect(nameInput).toHaveValue(invalidName) + expect(nameInput).toHaveAttribute('aria-invalid', 'true') + expect(nameInput).toHaveAccessibleDescription('datasetCreation.stepOne.modal.nameLengthInvalid') + expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled() + expect(serviceMock.create).not.toHaveBeenCalled() + }) + it('creates a private empty knowledge space, invalidates the list, and navigates', async () => { const user = userEvent.setup() const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) @@ -1687,6 +1719,30 @@ describe('CreateKnowledgePage', () => { expect(serviceMock.create).not.toHaveBeenCalled() }) + it('rejects an empty upload before staging or creating the knowledge space', async () => { + const user = userEvent.setup() + navigationMock.startMode = 'upload' + renderPage() + await fillRequiredFields(user) + const emptyFile = new File([], 'empty.txt', { type: 'text/plain' }) + + await user.upload( + screen.getByLabelText('dataset.newKnowledge.uploadFiles', { + selector: 'input[type="file"]', + }), + emptyFile, + ) + + expect(screen.getByText('empty.txt')).toBeInTheDocument() + expect( + screen.getByText('dataset.newKnowledge.documentUploadExclusion.fileEmpty'), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled() + expect(serviceMock.stageUpload).not.toHaveBeenCalled() + expect(serviceMock.upload).not.toHaveBeenCalled() + expect(serviceMock.create).not.toHaveBeenCalled() + }) + it('marks only the file currently being uploaded as pending', async () => { const user = userEvent.setup() navigationMock.startMode = 'upload' diff --git a/web/features/new-rag/__tests__/document-detail-page.spec.tsx b/web/features/new-rag/__tests__/document-detail-page.spec.tsx index a7c771ac95e..8030f186b4e 100644 --- a/web/features/new-rag/__tests__/document-detail-page.spec.tsx +++ b/web/features/new-rag/__tests__/document-detail-page.spec.tsx @@ -247,6 +247,12 @@ const outlineOptions = vi.hoisted(() => queryKind: 'outline', })), ) +const metadataFieldsOptions = vi.hoisted(() => + vi.fn((options: object) => ({ + ...options, + queryKey: ['knowledge-fs', 'metadata-fields'], + })), +) const documentTasksOptions = vi.hoisted(() => vi.fn((options: Omit) => ({ ...options, @@ -472,9 +478,7 @@ vi.mock('@/service/client', () => ({ metadata: { get: { key: () => ['knowledge-fs', 'metadata-fields'], - queryOptions: ({ input }: { input: unknown }) => ({ - queryKey: ['knowledge-fs', 'metadata-fields', input], - }), + queryOptions: metadataFieldsOptions, }, }, jobs: { @@ -714,9 +718,19 @@ describe('DocumentDetailPage', () => { query: { cursor: 'next' }, }) expect(outlineOptions).toHaveBeenCalledWith({ + context: { silent: true }, input: { params: { control_space_id: 'space-1', document_id: 'asset-1' }, }, + retry: false, + }) + expect(metadataFieldsOptions).toHaveBeenCalledWith({ + context: { silent: true }, + input: { + params: { control_space_id: 'space-1' }, + query: { limit: 100 }, + }, + retry: false, }) expect(infiniteInput(documentTasksOptions.mock.lastCall?.[0])(null)).toEqual({ params: { control_space_id: 'space-1' }, @@ -1080,31 +1094,46 @@ describe('DocumentDetailPage', () => { expect(screen.getByLabelText('reviewed_at')).toHaveAttribute('type', 'datetime-local') }) - it('keeps the edit action busy while resolving metadata types', async () => { + it('enters metadata editing immediately while field types are still resolving', async () => { const user = userEvent.setup() - let resolveMetadataRefetch!: (value: { data: DocumentMetadataField[] }) => void documentQuery.data = logicalDocument({ userMetadata: { category: '' } }) metadataFieldsQuery.data = undefined - metadataFieldsQuery.refetch.mockImplementation( - () => - new Promise((resolve) => { - resolveMetadataRefetch = resolve - }), - ) + metadataFieldsQuery.isPending = true render() - const editButton = screen.getByRole('button', { name: 'common.operation.edit' }) - await user.click(editButton) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) - expect(editButton).toHaveAttribute('aria-disabled', 'true') - await user.click(editButton) + expect(await screen.findByLabelText('category')).toBeInTheDocument() + expect(metadataFieldsQuery.refetch).not.toHaveBeenCalled() + }) + + it('keeps metadata editing usable and exposes retry when the field catalog fails', async () => { + const user = userEvent.setup() + documentQuery.data = logicalDocument({ userMetadata: { category: 'support' } }) + metadataFieldsQuery.data = undefined + metadataFieldsQuery.error = new Error('metadata catalog unavailable') + + render() + + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + const category = screen.getByLabelText('category') + expect(category).toBeEnabled() + await user.clear(category) + await user.type(category, 'product') + + await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })) + expect( + screen.getByText('dataset.newKnowledge.documentLoadErrorDescription'), + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) expect(metadataFieldsQuery.refetch).toHaveBeenCalledOnce() - await act(async () => { - resolveMetadataRefetch({ data: [metadataField()] }) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(patchDocumentMetadata).toHaveBeenCalledWith({ + body: { expectedRowVersion: 2, patch: { category: 'product' } }, + params: { control_space_id: 'space-1', document_id: 'document-1' }, }) - expect(await screen.findByLabelText('category')).toBeInTheDocument() }) it('converts UTC metadata timestamps to local datetime input values', async () => { diff --git a/web/features/new-rag/__tests__/documents-page.spec.tsx b/web/features/new-rag/__tests__/documents-page.spec.tsx index 15dbded1f21..855eacc0ae7 100644 --- a/web/features/new-rag/__tests__/documents-page.spec.tsx +++ b/web/features/new-rag/__tests__/documents-page.spec.tsx @@ -512,8 +512,8 @@ vi.mock('@/service/client', () => ({ vi.mock('../services/processing-task-events', () => ({ streamProcessingTaskEvents })) vi.mock('../knowledge-fs-upload', () => ({ discardKnowledgeFsStagedUpload: discardStagedUploadMutation, - stageKnowledgeFsDocument: async (file: File) => { - const result = await stageUploadMutation({ body: { file } }) + stageKnowledgeFsDocument: async (file: File, signal?: AbortSignal) => { + const result = await stageUploadMutation({ body: { file } }, { signal }) return result.id }, uploadKnowledgeFsDocuments: async ( @@ -1708,9 +1708,12 @@ describe('DocumentsPage', () => { await user.upload(input, new File(['one'], 'one.md', { type: 'text/markdown' })) await waitFor(() => - expect(stageUploadMutation).toHaveBeenCalledWith({ - body: { file: expect.objectContaining({ name: 'one.md' }) }, - }), + expect(stageUploadMutation).toHaveBeenCalledWith( + { + body: { file: expect.objectContaining({ name: 'one.md' }) }, + }, + { signal: expect.any(AbortSignal) }, + ), ) await waitForDocumentFilesStaged() expect(uploadMutation.mutateAsync).not.toHaveBeenCalled() @@ -1949,6 +1952,132 @@ describe('DocumentsPage', () => { expect(screen.queryByLabelText('dataset.newKnowledge.uploadDocuments')).not.toBeInTheDocument() }) + it('times out an exact 15 MiB staging request and discards a late success', async () => { + vi.useFakeTimers() + let resolveStaging!: (value: { id: string }) => void + let stagingSignal: AbortSignal | undefined + stageUploadMutation.mockImplementationOnce( + (_input: unknown, options: { signal?: AbortSignal }) => + new Promise((resolve) => { + stagingSignal = options.signal + resolveStaging = resolve + }), + ) + const rendered = render(, { + searchParams: '?upload=1', + }) + + try { + const maxSizeFile = new File(['boundary'], 'boundary.txt', { type: 'text/plain' }) + Object.defineProperty(maxSizeFile, 'size', { value: 15 * 1024 * 1024 }) + fireEvent.change(screen.getByLabelText('dataset.newKnowledge.uploadDocuments'), { + target: { files: [maxSizeFile] }, + }) + + expect(stageUploadMutation).toHaveBeenCalledOnce() + expect(stagingSignal?.aborted).toBe(false) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await Promise.resolve() + await Promise.resolve() + }) + + expect(stagingSignal?.aborted).toBe(true) + expect(toastMock.error).toHaveBeenCalledOnce() + expect(toastMock.error).toHaveBeenCalledWith('dataset.newKnowledge.documentUploadFailed') + expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })).toBeEnabled() + expect(screen.getByRole('listitem')).not.toHaveAttribute('aria-busy') + + await act(async () => { + resolveStaging({ id: 'late-boundary-upload' }) + await Promise.resolve() + await Promise.resolve() + }) + + expect(discardStagedUploadMutation).toHaveBeenCalledOnce() + expect(discardStagedUploadMutation).toHaveBeenCalledWith('late-boundary-upload') + expect(toastMock.error).toHaveBeenCalledOnce() + expect(uploadMutation.mutateAsync).not.toHaveBeenCalled() + } finally { + rendered.unmount() + vi.useRealTimers() + } + }) + + it('cancels exact 15 MiB staging and ignores a late failure', async () => { + const user = userEvent.setup() + let rejectStaging!: (reason: unknown) => void + let stagingSignal: AbortSignal | undefined + stageUploadMutation.mockImplementationOnce( + (_input: unknown, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + stagingSignal = options.signal + rejectStaging = reject + }), + ) + render(, { searchParams: '?upload=1' }) + const maxSizeFile = new File(['draft'], 'draft.txt', { type: 'text/plain' }) + Object.defineProperty(maxSizeFile, 'size', { value: 15 * 1024 * 1024 }) + fireEvent.change(screen.getByLabelText('dataset.newKnowledge.uploadDocuments'), { + target: { files: [maxSizeFile] }, + }) + + expect(stageUploadMutation).toHaveBeenCalledOnce() + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(stagingSignal?.aborted).toBe(true) + expect(toastMock.error).not.toHaveBeenCalled() + expect( + screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' }), + ).toBeInTheDocument() + + await act(async () => { + rejectStaging(new Error('late staging failure')) + await Promise.resolve() + await Promise.resolve() + }) + + expect(discardStagedUploadMutation).not.toHaveBeenCalled() + expect(toastMock.error).not.toHaveBeenCalled() + expect(uploadMutation.mutateAsync).not.toHaveBeenCalled() + }) + + it('reports a real staging failure when another file in the batch is canceled', async () => { + const user = userEvent.setup() + const canceledFile = new File(['cancel'], 'cancel.txt', { type: 'text/plain' }) + const failedFile = new File(['fail'], 'fail.txt', { type: 'text/plain' }) + let canceledSignal: AbortSignal | undefined + let rejectFailedStaging!: (reason: unknown) => void + stageUploadMutation.mockImplementation( + ({ body }: { body: { file: File } }, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (body.file === canceledFile) canceledSignal = options.signal + if (body.file === failedFile) rejectFailedStaging = reject + }), + ) + render(, { searchParams: '?upload=1' }) + fireEvent.change(screen.getByLabelText('dataset.newKnowledge.uploadDocuments'), { + target: { files: [canceledFile, failedFile] }, + }) + + expect(stageUploadMutation).toHaveBeenCalledTimes(2) + await user.click( + screen.getByRole('button', { name: 'common.operation.remove · cancel.txt' }), + ) + expect(canceledSignal?.aborted).toBe(true) + + await act(async () => { + rejectFailedStaging(new Error('staging service unavailable')) + await Promise.resolve() + await Promise.resolve() + }) + + expect(toastMock.error).toHaveBeenCalledOnce() + expect(toastMock.error).toHaveBeenCalledWith('dataset.newKnowledge.documentUploadFailed') + expect(uploadMutation.mutateAsync).not.toHaveBeenCalled() + }) + it('excludes unsupported files locally while uploading valid files', async () => { const user = userEvent.setup() render() @@ -1988,6 +2117,22 @@ describe('DocumentsPage', () => { expect(screen.getByText('dataset.newKnowledge.documentUploadExclusion.fileSize')).toBeVisible() }) + it('rejects empty files locally with a field-level reason', async () => { + const user = userEvent.setup() + render() + const emptyFile = new File([], 'empty.txt', { type: 'text/plain' }) + + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })) + fireEvent.change(screen.getByLabelText('dataset.newKnowledge.uploadDocuments'), { + target: { files: [emptyFile] }, + }) + + expect(stageUploadMutation).not.toHaveBeenCalled() + expect(uploadMutation.mutateAsync).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' })).toBeDisabled() + expect(screen.getByText('dataset.newKnowledge.documentUploadExclusion.fileEmpty')).toBeVisible() + }) + it('reports local exclusions and API upload failures', async () => { const user = userEvent.setup() render() diff --git a/web/features/new-rag/__tests__/knowledge-fs-upload.spec.ts b/web/features/new-rag/__tests__/knowledge-fs-upload.spec.ts index d219016ac57..a08d380a49c 100644 --- a/web/features/new-rag/__tests__/knowledge-fs-upload.spec.ts +++ b/web/features/new-rag/__tests__/knowledge-fs-upload.spec.ts @@ -44,11 +44,15 @@ describe('uploadKnowledgeFsDocuments', () => { it('stages and discards files through the generated Dify API contract', async () => { const file = new File(['one'], 'one.md', { type: 'text/markdown' }) + const controller = new AbortController() - await expect(stageKnowledgeFsDocument(file)).resolves.toBe('staged-upload-1') + await expect(stageKnowledgeFsDocument(file, controller.signal)).resolves.toBe('staged-upload-1') await discardKnowledgeFsStagedUpload('staged-upload-1') - expect(serviceMock.stageUpload).toHaveBeenCalledWith({ body: { file } }) + expect(serviceMock.stageUpload).toHaveBeenCalledWith( + { body: { file } }, + { context: { silent: true }, signal: controller.signal }, + ) expect(serviceMock.discardUpload).toHaveBeenCalledWith({ params: { upload_id: 'staged-upload-1' }, }) @@ -79,12 +83,14 @@ describe('uploadKnowledgeFsDocuments', () => { body: { upload_id: 'staged-upload-0' }, params: { control_space_id: 'control-space-1' }, }, + { context: { silent: true } }, ], [ { body: { upload_id: 'staged-upload-1' }, params: { control_space_id: 'control-space-1' }, }, + { context: { silent: true } }, ], ]) expect(onProgress.mock.calls).toEqual([ diff --git a/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx b/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx index b251f6d53c5..317aa212f2f 100644 --- a/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx +++ b/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx @@ -5,7 +5,7 @@ import type { import type { ReactNode } from 'react' import type { Member } from '@/models/common' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { screen, waitFor, within } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { render } from '@/test/console/render' import { KnowledgeSettingsForm } from '../knowledge-settings-form' @@ -389,6 +389,48 @@ describe('KnowledgeSettingsForm', () => { }) }) + it('accepts 2000 description characters and blocks 2001 with a field error', async () => { + const user = userEvent.setup() + renderForm() + + const descriptionInput = screen.getByRole('textbox', { + name: 'datasetSettings.form.desc', + }) + const saveButton = screen.getByRole('button', { + name: 'dataset.newKnowledge.settings.saveChanges', + }) + const invalidDescription = '知'.repeat(2001) + fireEvent.change(descriptionInput, { target: { value: invalidDescription } }) + + expect(descriptionInput).toHaveValue(invalidDescription) + expect(descriptionInput).toHaveAttribute('aria-invalid', 'true') + expect(descriptionInput).toHaveAccessibleDescription( + 'workflow.chatVariable.modal.descriptionTooLong:{"maxLength":2000}', + ) + expect(saveButton).toBeDisabled() + expect(serviceMock.patchSpace).not.toHaveBeenCalled() + + const boundaryDescription = '知'.repeat(2000) + fireEvent.change(descriptionInput, { target: { value: `${boundaryDescription} ` } }) + expect(descriptionInput).toHaveAttribute('aria-invalid', 'true') + expect(saveButton).toBeDisabled() + expect(serviceMock.patchSpace).not.toHaveBeenCalled() + + fireEvent.change(descriptionInput, { target: { value: boundaryDescription } }) + expect(descriptionInput).not.toHaveAttribute('aria-invalid', 'true') + expect(saveButton).toBeEnabled() + await user.click(saveButton) + + await waitFor(() => expect(serviceMock.patchSpace).toHaveBeenCalledOnce()) + expect(serviceMock.patchSpace).toHaveBeenCalledWith( + { + body: { description: boundaryDescription }, + params: { control_space_id: 'space-1' }, + }, + expect.anything(), + ) + }) + it('disables API access directly and preserves unrelated channels', async () => { const user = userEvent.setup() renderForm() @@ -418,6 +460,43 @@ describe('KnowledgeSettingsForm', () => { expect(toastMock.success).not.toHaveBeenCalled() }) + it('restores the API access switch after failure and retries the intended value', async () => { + const user = userEvent.setup() + serviceMock.patchExternalAccess.mockRejectedValueOnce(new Error('network error')) + renderForm({ + externalAccess: { + ...externalAccess, + agent_enabled: false, + service_api_enabled: false, + }, + }) + + const apiAccessSwitch = screen.getByRole('switch', { + name: 'dataset.newKnowledge.apiAgentAccess', + }) + expect(apiAccessSwitch).toHaveAttribute('aria-checked', 'false') + await user.click(apiAccessSwitch) + + expect(await screen.findByText('dataset.newKnowledge.settings.saveFailed')).toBeInTheDocument() + expect(apiAccessSwitch).toHaveAttribute('aria-checked', 'false') + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + await waitFor(() => expect(serviceMock.patchExternalAccess).toHaveBeenCalledTimes(2)) + expect(serviceMock.patchExternalAccess).toHaveBeenLastCalledWith( + { + body: { + agent_enabled: true, + mcp_enabled: true, + service_api_enabled: true, + workflow_enabled: true, + }, + params: { control_space_id: 'space-1' }, + }, + expect.anything(), + ) + await waitFor(() => expect(apiAccessSwitch).toHaveAttribute('aria-checked', 'true')) + }) + it('requires the exact knowledge name before deletion', async () => { const user = userEvent.setup() renderForm() diff --git a/web/features/new-rag/__tests__/knowledge-space-shell.spec.tsx b/web/features/new-rag/__tests__/knowledge-space-shell.spec.tsx index 3ec771dd8b4..cd1d47816b0 100644 --- a/web/features/new-rag/__tests__/knowledge-space-shell.spec.tsx +++ b/web/features/new-rag/__tests__/knowledge-space-shell.spec.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { render } from '@/test/console/render' import { KnowledgeSpaceShell } from '../knowledge-space-shell' @@ -73,6 +73,23 @@ vi.mock('@/service/client', () => ({ vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn() })) +vi.mock('../components/knowledge-fs-api-access-dialog', () => ({ + KnowledgeFsApiAccessDialog: ({ + canManageCredentials, + enabled, + open, + }: { + canManageCredentials: boolean + enabled: boolean + open: boolean + }) => + open ? ( +
+ {String(enabled)}:{String(canManageCredentials)} +
+ ) : null, +})) + describe('KnowledgeSpaceShell', () => { beforeEach(() => { vi.clearAllMocks() @@ -91,6 +108,7 @@ describe('KnowledgeSpaceShell', () => { expect(queryOptionsMock).toHaveBeenCalledWith({ input: { params: { control_space_id: 'space-1' } }, + context: { silent: true }, }) expect(screen.getByRole('status')).toBeInTheDocument() }) @@ -140,6 +158,24 @@ describe('KnowledgeSpaceShell', () => { expect(screen.getByText('source content')).toBeInTheDocument() }) + it('keeps all navigation discoverable in a three-column mobile grid', () => { + queryMock.data = { + control_space_id: 'space-1', + state: 'active', + technical_summary: { name: 'Support knowledge' }, + } + + render(source content) + + const navigation = screen.getByRole('navigation', { name: 'Support knowledge' }) + expect(navigation).toHaveClass('grid', 'grid-cols-3', 'sm:flex', 'sm:flex-col') + expect(navigation).not.toHaveClass('overflow-x-auto') + expect(within(navigation).getAllByRole('link')).toHaveLength(6) + expect( + within(navigation).getByRole('link', { name: 'common.datasetMenus.settings' }), + ).toBeInTheDocument() + }) + it('shows API access as inactive when either public channel is disabled', () => { queryMock.data = { control_space_id: 'space-1', @@ -154,6 +190,23 @@ describe('KnowledgeSpaceShell', () => { expect(screen.getByText('dataset.newKnowledge.apiAccessInactive')).toBeInTheDocument() }) + it('opens the real KnowledgeFS credential management path', async () => { + const user = userEvent.setup() + queryMock.data = { + control_space_id: 'space-1', + permission_keys: ['knowledge_space_access_config', 'knowledge_space_api_key_manage'], + state: 'active', + technical_summary: { name: 'Support knowledge' }, + } + + render(source content) + await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.apiAgentAccess' })) + + expect(screen.getByRole('dialog', { name: 'knowledge-fs-api-access' })).toHaveTextContent( + 'true:true', + ) + }) + it('does not invent sidebar metadata when the summary profile is unavailable', () => { queryMock.data = { control_space_id: 'space-1', diff --git a/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx b/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx index 971459c6a82..c7f945627c8 100644 --- a/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx +++ b/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx @@ -504,6 +504,35 @@ describe('NewKnowledgeList', () => { expect(screen.queryByText('Engineering handbook')).not.toBeInTheDocument() }) + it('shows a search-specific empty state and restores the loaded list when cleared', async () => { + const user = userEvent.setup() + setResolvedPage([ + { + createdAt: '2026-07-15T00:00:00Z', + id: 'space-1', + name: 'Support knowledge', + revision: 1, + slug: 'support-knowledge', + tenantId: 'tenant-1', + updatedAt: '2026-07-18T00:00:00Z', + }, + ]) + renderWithNuqs() + + const search = screen.getByRole('searchbox', { name: 'common.operation.search' }) + await user.type(search, 'no matching knowledge') + + expect( + screen.getByText('common.operation.noSearchResults:{"content":"dataset.knowledge"}'), + ).toBeInTheDocument() + expect(screen.getByText('no matching knowledge')).toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'dataset.knowledge' })).not.toBeInTheDocument() + + await user.click(screen.getByText('common.operation.clear')) + expect(search).toHaveValue('') + expect(screen.getByRole('link', { name: 'Support knowledge' })).toBeInTheDocument() + }) + it('filters the collection by selected creators and clears the filter', async () => { const user = userEvent.setup() setResolvedPage([ diff --git a/web/features/new-rag/__tests__/quality-page.spec.tsx b/web/features/new-rag/__tests__/quality-page.spec.tsx index d3725894dff..57f19cf135d 100644 --- a/web/features/new-rag/__tests__/quality-page.spec.tsx +++ b/web/features/new-rag/__tests__/quality-page.spec.tsx @@ -370,7 +370,7 @@ describe('QualityPage', () => { ) }) - it('shows both required-field messages after an empty golden question submission', async () => { + it('clears each required-field message as soon as that field becomes valid', async () => { const user = userEvent.setup() renderPage() @@ -392,20 +392,49 @@ describe('QualityPage', () => { screen.getByPlaceholderText('dataset.newKnowledge.qualityPage.questionPlaceholder'), 'New question', ) - expect( - screen.getByText('dataset.newKnowledge.qualityPage.questionRequired'), - ).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.qualityPage.save' })) expect( screen.queryByText('dataset.newKnowledge.qualityPage.questionRequired'), ).not.toBeInTheDocument() expect( screen.getByText('dataset.newKnowledge.qualityPage.annotationRequired'), ).toBeInTheDocument() + + await user.type( + screen.getByPlaceholderText('dataset.newKnowledge.qualityPage.annotationPlaceholder'), + 'Expected answer', + ) + expect( + screen.queryByText('dataset.newKnowledge.qualityPage.questionRequired'), + ).not.toBeInTheDocument() + expect( + screen.queryByText('dataset.newKnowledge.qualityPage.annotationRequired'), + ).not.toBeInTheDocument() expect(serviceMock.createGolden).not.toHaveBeenCalled() }) + it('explains when evidence matching is unavailable instead of showing an unknown error', async () => { + serviceMock.matchEvidence.mockRejectedValueOnce(new Response(null, { status: 503 })) + const user = userEvent.setup() + renderPage() + + await screen.findByText('What is the refund policy?') + await user.click( + screen.getByRole('button', { name: 'dataset.newKnowledge.qualityPage.addGolden' }), + ) + await user.type( + screen.getByPlaceholderText('dataset.newKnowledge.qualityPage.evidencePlaceholder'), + 'refund within 30 days', + ) + await user.click( + screen.getByRole('button', { name: 'dataset.newKnowledge.qualityPage.findEvidence' }), + ) + + expect( + await screen.findByText('dataset.newKnowledge.qualityPage.noEvidenceMatch'), + ).toBeInTheDocument() + expect(screen.queryByText('dataset.unknownError')).not.toBeInTheDocument() + }) + it('reveals the full annotation and submits edits through the update contract', async () => { const user = userEvent.setup() serviceMock.updateGolden.mockResolvedValue({}) diff --git a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx index 811ff76cd6e..7418aee5e9b 100644 --- a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx +++ b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx @@ -258,6 +258,182 @@ describe('RetrievalTestPage', () => { }) }) + it('admits only one research task while the first Start request is pending', async () => { + let resolvePlan: ((value: Awaited>) => void) | undefined + const pendingPlan = new Promise>>((resolve) => { + resolvePlan = resolve + }) + apiMock.planResearch.mockReturnValueOnce(pendingPlan) + const user = userEvent.setup() + renderPage() + + await user.type( + screen.getByLabelText('dataset.newKnowledge.retrievalTest.queryPlaceholder'), + 'Compare the refund policies', + ) + await user.click( + screen.getByRole('button', { + name: 'dataset.newKnowledge.settings.retrievalMode.research', + }), + ) + const start = screen.getByRole('button', { + name: 'dataset.newKnowledge.retrievalTest.startResearch', + }) + await user.click(start) + await user.click(start) + + expect(apiMock.planResearch).toHaveBeenCalledOnce() + resolvePlan?.({ + budget: { budget_usd: 1, exceeds_budget: false }, + estimates: {}, + knowledge_space_id: 'space-1', + query: 'Compare the refund policies', + retrieval_plan: { top_k: 8 }, + steps: [], + strategy_version: 'research-dry-run-planner-v1', + }) + await waitFor(() => expect(apiMock.createResearch).toHaveBeenCalledOnce()) + }) + + it.each([ + { + label: 'dataset.newKnowledge.settings.retrievalMode.fast', + mode: 'fast', + }, + { + label: 'dataset.newKnowledge.settings.retrievalMode.deep', + mode: 'deep', + }, + ] as const)( + 'admits only one $mode query while the first Start request is pending', + async ({ label, mode }) => { + let resolveAdmission: ((value: Record) => void) | undefined + apiMock.queryAdmission.mockReturnValueOnce( + new Promise>((resolve) => { + resolveAdmission = resolve + }), + ) + const user = userEvent.setup() + renderPage() + + await user.type( + screen.getByLabelText('dataset.newKnowledge.retrievalTest.queryPlaceholder'), + `Run one ${mode} query`, + ) + if (mode === 'deep') await user.click(screen.getByRole('button', { name: label })) + const start = screen.getByRole('button', { + name: 'dataset.newKnowledge.retrievalTest.run', + }) + act(() => { + start.click() + start.click() + }) + + expect(apiMock.queryAdmission).toHaveBeenCalledOnce() + expect(apiMock.queryAdmission).toHaveBeenCalledWith({ + body: { mode, query: `Run one ${mode} query` }, + params: { control_space_id: 'space-1' }, + }) + expect(apiMock.streamQuery).not.toHaveBeenCalled() + + await act(async () => resolveAdmission?.({})) + await waitFor(() => expect(apiMock.streamQuery).toHaveBeenCalledOnce()) + }, + ) + + it('admits only one query when Retry is triggered twice while admission is pending', async () => { + apiMock.traces = [ + { + completed: false, + created_at: '2026-07-29T00:00:00.000Z', + duration_ms: 30_000, + id: 'trace-failed', + mode: 'fast', + profile: {}, + query: 'Retry this query once', + result_count: 0, + scores: {}, + stages: [{ name: 'query.generate', status: 'error' }], + }, + ] + let resolveAdmission: ((value: Record) => void) | undefined + apiMock.queryAdmission.mockReturnValueOnce( + new Promise>((resolve) => { + resolveAdmission = resolve + }), + ) + renderPage({ searchParams: '?trace=trace-failed' }) + + const retry = await screen.findByRole('button', { + name: 'dataset.newKnowledge.retrievalTest.retry', + }) + act(() => { + retry.click() + retry.click() + }) + + expect(apiMock.queryAdmission).toHaveBeenCalledOnce() + expect(apiMock.queryAdmission).toHaveBeenCalledWith({ + body: { mode: 'fast', query: 'Retry this query once' }, + params: { control_space_id: 'space-1' }, + }) + expect(apiMock.streamQuery).not.toHaveBeenCalled() + + await act(async () => resolveAdmission?.({})) + await waitFor(() => expect(apiMock.streamQuery).toHaveBeenCalledOnce()) + }) + + it('keeps a newly admitted empty-space Research run visible through its terminal event', async () => { + apiMock.createResearch.mockResolvedValueOnce({ + cost: {}, + created_at: 1_800_000_000, + id: 'research-1', + knowledge_space_id: 'space-1', + metadata: {}, + query: 'Anything here?', + stage: 'queued', + updated_at: 1_800_000_000, + }) + apiMock.streamResearchEvents.mockImplementation( + async ({ onEvent }: { onEvent: (event: Record) => void }) => { + onEvent({ + createdAt: '2027-01-15T08:00:01.000Z', + id: 'research-failed-1', + payload: { error: 'Published runtime snapshot unavailable' }, + researchTaskJobId: 'research-1', + sequence: 1, + stage: 'failed', + type: 'research_task.failed', + }) + return { cursor: '1', reconnect: false, terminal: true } + }, + ) + const user = userEvent.setup() + renderPage() + + await user.type( + screen.getByLabelText('dataset.newKnowledge.retrievalTest.queryPlaceholder'), + 'Anything here?', + ) + await user.click( + screen.getByRole('button', { + name: 'dataset.newKnowledge.settings.retrievalMode.research', + }), + ) + await user.click( + screen.getByRole('button', { name: 'dataset.newKnowledge.retrievalTest.startResearch' }), + ) + + const record = await screen.findByRole('button', { name: /Anything here\?/ }) + expect(record).toHaveAttribute('aria-pressed', 'true') + expect( + await screen.findByText('dataset.newKnowledge.retrievalTest.noChunksTitle'), + ).toBeInTheDocument() + expect( + screen.queryByText('dataset.newKnowledge.retrievalTest.emptyTitle'), + ).not.toBeInTheDocument() + }) + it('replays research progress events and shows actual stage durations', async () => { apiMock.researchTasks = [ { @@ -457,17 +633,52 @@ describe('RetrievalTestPage', () => { renderPage() - expect(screen.getByText('dataset.newKnowledge.retrievalTest.justNow')).toBeInTheDocument() + expect(screen.getAllByText('dataset.newKnowledge.retrievalTest.justNow')).not.toHaveLength(0) act(() => vi.advanceTimersByTime(30_001)) - expect( - screen.queryByText('dataset.newKnowledge.retrievalTest.justNow'), - ).not.toBeInTheDocument() - expect(screen.getByText('A recent retrieval run')).toBeInTheDocument() + expect(screen.queryAllByText('dataset.newKnowledge.retrievalTest.justNow')).toHaveLength(0) + expect(screen.getByRole('button', { name: /A recent retrieval run/ })).toBeInTheDocument() } finally { vi.useRealTimers() } }) + it('selects the newest persisted record instead of showing the no-runs empty state', () => { + apiMock.traces = [ + { + completed: true, + created_at: '2026-07-29T00:00:00.000Z', + id: 'trace-newest', + mode: 'fast', + profile: {}, + query: 'Newest persisted query', + result_count: 0, + scores: {}, + stages: [], + }, + { + completed: true, + created_at: '2026-07-28T00:00:00.000Z', + id: 'trace-older', + mode: 'fast', + profile: {}, + query: 'Older persisted query', + result_count: 0, + scores: {}, + stages: [], + }, + ] + + renderPage() + + expect(screen.getByRole('button', { name: /Newest persisted query/ })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + expect( + screen.queryByText('dataset.newKnowledge.retrievalTest.emptyTitle'), + ).not.toBeInTheDocument() + }) + it('renders generated Research answer deltas while the task is still active', async () => { apiMock.researchTasks = [ { @@ -543,7 +754,7 @@ describe('RetrievalTestPage', () => { const user = userEvent.setup() renderPage() - await user.click(screen.getByText('What is the warranty?')) + await user.click(screen.getByRole('button', { name: /What is the warranty\?/ })) expect(await screen.findByText('The persisted warranty answer.')).toBeInTheDocument() expect( @@ -618,7 +829,7 @@ describe('RetrievalTestPage', () => { const user = userEvent.setup() const { onUrlUpdate } = renderPage() - await user.click(screen.getByText('What is the warranty?')) + await user.click(screen.getByRole('button', { name: /What is the warranty\?/ })) await waitFor(() => { const urlUpdate = onUrlUpdate.mock.calls.at(-1)?.[0] @@ -676,7 +887,7 @@ describe('RetrievalTestPage', () => { const user = userEvent.setup() renderPage() - await user.click(screen.getByText('Compare the refund policies')) + await user.click(screen.getByRole('button', { name: /Compare the refund policies/ })) await waitFor(() => expect(apiMock.streamCapability).toHaveBeenCalledTimes(2)) expect(apiMock.streamResearchEvents).toHaveBeenNthCalledWith( @@ -737,7 +948,7 @@ describe('RetrievalTestPage', () => { const user = userEvent.setup() renderPage() - await user.click(screen.getByText('Compare the refund policies')) + await user.click(screen.getByRole('button', { name: /Compare the refund policies/ })) await waitFor(() => expect(apiMock.refetchTasks).toHaveBeenCalledOnce()) await waitFor(() => expect(apiMock.refetchPartials).toHaveBeenCalledOnce()) @@ -763,7 +974,7 @@ describe('RetrievalTestPage', () => { expect( screen.getByText('dataset.newKnowledge.retrievalTest.retrievingActive · 2/4'), ).toBeInTheDocument() - await user.click(screen.getByText('Compare the refund policies')) + await user.click(screen.getByRole('button', { name: /Compare the refund policies/ })) const queryInput = screen.getByLabelText('dataset.newKnowledge.retrievalTest.queryPlaceholder') expect( screen.getByRole('button', { name: 'dataset.newKnowledge.retrievalTest.startResearch' }), @@ -938,7 +1149,7 @@ describe('RetrievalTestPage', () => { const user = userEvent.setup() renderPage() - await user.click(screen.getByText('What is the refund policy?')) + await user.click(screen.getByRole('button', { name: /What is the refund policy\?/ })) expect( screen.getByRole('link', { name: 'dataset.newKnowledge.retrievalTest.open' }), diff --git a/web/features/new-rag/__tests__/website-crawl-preview.spec.tsx b/web/features/new-rag/__tests__/website-crawl-preview.spec.tsx index 79e23e65b06..14cfa66e073 100644 --- a/web/features/new-rag/__tests__/website-crawl-preview.spec.tsx +++ b/web/features/new-rag/__tests__/website-crawl-preview.spec.tsx @@ -518,19 +518,41 @@ describe('WebsiteCrawlPreview', () => { await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledOnce()) }) - it('caps crawl previews at the selection contract limit', async () => { + it.each(['0', '1.5', '201'])( + 'keeps invalid page limit %s visible and blocks the crawl request', + async (invalidLimit) => { + render() + const user = await fillValidForm() + await user.click(screen.getByRole('button', { name: /^dataset\.newKnowledge\.crawlOptions/ })) + const pageLimit = screen.getByRole('textbox', { name: 'dataset.newKnowledge.maxPages' }) + await user.clear(pageLimit) + await user.type(pageLimit, invalidLimit) + + expect(pageLimit).toHaveValue(invalidLimit) + expect(pageLimit).toHaveAttribute('aria-invalid', 'true') + expect(pageLimit).toHaveAccessibleDescription('dataset.newKnowledge.maxPages: 1–200') + expect( + screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }), + ).toBeDisabled() + expect(clientMock.createSource).not.toHaveBeenCalled() + expect(clientMock.startPreview).not.toHaveBeenCalled() + }, + ) + + it.each([1, 200])('submits the exact valid page limit %s', async (validLimit) => { render() const user = await fillValidForm() await user.click(screen.getByRole('button', { name: /^dataset\.newKnowledge\.crawlOptions/ })) const pageLimit = screen.getByRole('textbox', { name: 'dataset.newKnowledge.maxPages' }) await user.clear(pageLimit) - await user.type(pageLimit, '1000') - await user.tab() - expect(pageLimit).toHaveValue('200') + await user.type(pageLimit, String(validLimit)) + expect(pageLimit).toHaveValue(String(validLimit)) await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' })) await waitFor(() => expect(clientMock.createSource).toHaveBeenCalledOnce()) - expect(clientMock.createSource.mock.calls[0]?.[0].body.metadata.crawlOptions.limit).toBe(200) + expect(clientMock.createSource.mock.calls[0]?.[0].body.metadata.crawlOptions.limit).toBe( + validLimit, + ) }) it('preserves a replacement crawl page limit after clearing the input', async () => { diff --git a/web/features/new-rag/components/__tests__/knowledge-fs-api-access-dialog.spec.tsx b/web/features/new-rag/components/__tests__/knowledge-fs-api-access-dialog.spec.tsx new file mode 100644 index 00000000000..6a01a1169a9 --- /dev/null +++ b/web/features/new-rag/components/__tests__/knowledge-fs-api-access-dialog.spec.tsx @@ -0,0 +1,242 @@ +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { render } from '@/test/console/render' +import { KnowledgeFsApiAccessDialog } from '../knowledge-fs-api-access-dialog' + +const serviceMock = vi.hoisted(() => ({ + create: vi.fn(), + listQueryOptions: vi.fn(() => ({ queryKey: ['knowledge-fs', 'credentials'] })), + refetch: vi.fn(), + revoke: vi.fn(), +})) + +const credentialsQueryMock = vi.hoisted(() => ({ + data: { + data: [ + { + allowed_actions: ['queries.create'], + credential_last4: '1234', + credential_prefix: 'kfs_', + expires_at: null, + id: 'credential-1', + last_used_at: null, + principal: 'credential-1', + revision: 1, + status: 'active', + }, + ], + }, + isError: false, + isPending: false, + refetch: serviceMock.refetch, +})) + +const useQueryOptionsMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiBaseUrl: () => ({ data: { api_base_url: 'https://api.example.com/v1/' } }), +})) + +vi.mock('@/app/components/base/copy-feedback', () => ({ + default: ({ content }: { content: string }) => ( + + + +
+
+

+ {t(($) => $['serviceApi.card.endpoint'])} +

+
+ + {endpoint || tAppApi(($) => $.loading)} + + {endpoint && } +
+
+ + {!enabled ? ( +
+ {t(($) => $['newKnowledge.apiAccessInactive'])} +
+ ) : !canManageCredentials ? ( +
+ {t(($) => $['newKnowledge.settings.viewOnly'])} +
+ ) : ( + <> + {createdCredential && ( +
+

+ {tAppApi(($) => $['apiKeyModal.secretKey'])} +

+

+ {tAppApi(($) => $['apiKeyModal.generateTips'])} +

+
+ + {createdCredential.credential} + + +
+
+ )} + + {actionError === 'create' && ( +
+ {tCommon(($) => $['api.actionFailed'])} +
+ )} + +
+

+ {tAppApi(($) => $['apiKeyModal.apiSecretKey'])} +

+ +
+ + {credentialsQuery.isPending ? ( +

+ {tAppApi(($) => $.loading)} +

+ ) : credentialsQuery.isError ? ( +
+ + {tCommon(($) => $['api.actionFailed'])} + + +
+ ) : credentialsQuery.data?.data.length ? ( +
    + {credentialsQuery.data.data.map((credential) => { + const masked = maskedCredential(credential) + return ( +
  • + +
    + {masked} +

    + {tAppApi(($) => $['apiKeyModal.lastUsed'])}:{' '} + {credential.last_used_at + ? new Date(credential.last_used_at).toLocaleString(i18n.language) + : tAppApi(($) => $.never)} +

    +
    + + {t(($) => + credential.status === 'active' + ? $['newKnowledge.apiAccessActive'] + : $['newKnowledge.apiAccessInactive'], + )} + + +
  • + ) + })} +
+ ) : ( +

+ {tAppApi(($) => $['develop.noContent'])} +

+ )} + + )} +
+ + + + { + if (!nextOpen && !revokeMutation.isPending) { + setCredentialToRevoke(undefined) + setActionError(undefined) + } + }} + > + + {tAppApi(($) => $['actionMsg.deleteConfirmTitle'])} + + {tAppApi(($) => $['actionMsg.deleteConfirmTips'])} + + {actionError === 'revoke' && ( +
+ {tCommon(($) => $['api.actionFailed'])} +
+ )} + + + {tCommon(($) => $['operation.cancel'])} + + void revokeCredential()} + > + {tCommon(($) => $['operation.delete'])} + + +
+
+ + ) +} diff --git a/web/features/new-rag/create-knowledge-page.tsx b/web/features/new-rag/create-knowledge-page.tsx index 182133bd8a5..db2c5477aaf 100644 --- a/web/features/new-rag/create-knowledge-page.tsx +++ b/web/features/new-rag/create-knowledge-page.tsx @@ -83,6 +83,8 @@ function normalizeStartMode(value: string | null): NewKnowledgeStartMode { export function CreateKnowledgePage() { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') + const { t: tDatasetCreation } = useTranslation('datasetCreation') + const { t: tWorkflow } = useTranslation('workflow') const router = useRouter() const searchParams = useSearchParams() const queryClient = useQueryClient() @@ -128,7 +130,11 @@ export function CreateKnowledgePage() { const createMutation = useMutation({ mutationFn: createKnowledge }) const submissionPending = createMutation.isPending || uploading || stagingCount > 0 const createErrorMessage = t(($) => $['newKnowledge.createFailed']) - const nameSubmissionBlocked = !name.trim() + const normalizedName = name.trim() + const normalizedDescription = description.trim() + const nameLengthInvalid = Array.from(normalizedName).length > NAME_MAX_LENGTH + const descriptionLengthInvalid = Array.from(normalizedDescription).length > DESCRIPTION_MAX_LENGTH + const nameSubmissionBlocked = !normalizedName || nameLengthInvalid || descriptionLengthInvalid const uploadSubmissionBlocked = startMode === 'upload' && (!uploadAvailable || @@ -336,9 +342,7 @@ export function CreateKnowledgePage() { const handleSubmit = async () => { if (submissionPending || uploadSubmissionBlocked || sourceSubmissionBlocked) return - const normalizedName = name.trim() - const normalizedDescription = description.trim() - if (!normalizedName) return + if (!normalizedName || nameLengthInvalid || descriptionLengthInvalid) return const latestInitialSource = initialSourceRef.current ?? initialSource if (startMode === 'source' && !latestInitialSource) return @@ -452,6 +456,7 @@ export function CreateKnowledgePage() { { if (typeof value === 'string' && value.length > 0 && !value.trim()) return t(($) => $['newKnowledge.nameRequired']) @@ -467,8 +472,10 @@ export function CreateKnowledgePage() { $['newKnowledge.namePlaceholder'])} required value={name} @@ -480,15 +487,20 @@ export function CreateKnowledgePage() { {t(($) => $['newKnowledge.nameRequired'])} + + {tDatasetCreation(($) => $['stepOne.modal.nameLengthInvalid'])} + - + {t(($) => $['newKnowledge.description'])}