Update application components and supporting logic

This commit is contained in:
Jyong 2026-08-11 05:52:22 -04:00
parent cb62c52255
commit 14e5afa54d
71 changed files with 2713 additions and 217 deletions

View File

@ -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")

View File

@ -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
# ---------------------------------------------------------------------

View File

@ -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()

View File

@ -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"}]

View File

@ -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"

View File

@ -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",

View File

@ -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:

View File

@ -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.

View File

@ -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(

View File

@ -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];

View File

@ -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 字 | 字段级拦截,不请求后端 | FAILSettings 同约束见 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 但不选成员 | 显示“至少一名成员”并禁止保存 | PASSSettings 实测 |
| TC-CR-015 | P1 | 连接数据源方式创建 | 创建空间后进入 provider 配置,取消不留垃圾数据 | NOT-RUN |
| TC-CR-016 | P1 | 上传文件方式创建 | 创建与 staged upload claim 原子收敛,不重复文档 | ENV-BLOCKEDDocuments 入口已证实 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 抓取预览 | 在可接受时间内返回页列表或明确失败 | ENV36+ 秒仍 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 | 只允许 36002592000 秒 | NOT-RUN |
| TC-SO-025 | P1 | manual mode 同时提交 customInterval | 拒绝矛盾组合,不静默保留无效值 | STATIC-RISK |
| TC-SO-026 | P1 | stale expectedRevision/sourceVersion 保存数据源 | 返回 409UI 提示刷新后重试 | 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 | FAIL3 个合法 TXT/MD 均显示 `Internal Server Error`0 文档 |
| TC-DO-004 | P0 | 上传 Unicode 文件名和中文内容 | 文件名、内容、分块和下载名不乱码 | FAIL-BLOCKED中文文件名本地显示正常但 staging 500无法验证解析 |
| TC-DO-005 | P0 | 上传空文件 | 返回 422UI 明确说明空文件,无空文档 | FAIL0 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 | 返回 413UI 提示超限,无计费/索引任务 | PASS本地标记“超过 15 MB 限制”0/1 有效且未 staging |
| TC-DO-009 | P0 | 同时上传合法、空、超限和不支持文件 | 每个文件独立结果,部分失败不丢成功项,可重试失败项 | PARTIAL/FAIL合法文件逐个报错并另有汇总错误未能进入 claim 部分成功 |
| TC-DO-010 | P0 | 对同一 staged upload 网络重试 | 同 upload ID 仅被 claim 一次,无重复文档 | ENV-BLOCKEDstaging 未成功返回 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 | 现有文档列表加载 | 显示名称、来源、状态、修订、更新时间 | PASS7 条 |
| TC-DO-016 | P0 | 状态筛选 Ready | 只显示 ready 文档 | PASS4 条 |
| TC-DO-017 | P0 | 状态筛选 Failed | 只显示 failed 文档 | PASS3 条 |
| TC-DO-018 | P1 | 文档名大小写不敏感搜索 | 返回匹配项,清空后恢复 | PASS |
| TC-DO-019 | P1 | 搜索无匹配 | 显示明确无结果空状态 | PASS |
| TC-DO-020 | P0 | 打开 Tasks 抽屉 | 显示需关注/失败任务、原因和可用操作 | PASS5 条失败/中断 |
| 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能力未开放时按钮禁用且有说明 | PASSAdd Metadata 禁用 |
| TC-DO-025 | P1 | 勾选一个文档 | 显示批量操作条和正确选中计数 | PASS |
| TC-DO-026 | P1 | 选中文档后查看 Reindex/Download/Delete 状态 | 按权限和实现能力准确启用/禁用 | PASSReindex 可用Download/Delete 禁用 |
| TC-DO-027 | P0 | 批量 Reindex 选择 1/1000/1001 文档 | 只允许 11000单一 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 | 第二个返回 409UI 刷新后可重试 | 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 字及重名 | 允许 1255拒绝 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 指标 | 时间范围切换后查询数、回答率和对比值一致 | PASS24h 030d 11/100% |
| TC-OV-003 | P1 | 需要关注列表分页 | 前后翻页稳定,不重复丢失 | PASS2 页 |
| TC-OV-004 | P1 | 打开最近活动抽屉 | Today/30d 和活动事件显示正常 | PASS |
| TC-OV-005 | P1 | 最近活动按 operator 筛选 | 只显示对应 actor/System 事件 | PASS |
| TC-OV-006 | P1 | 资产概况文档/实体/关系/覆盖数 | 与后端 inventory 一致 | PASS4/398/380/104 |
| TC-OV-007 | P1 | Documents 列表包含 failed 文档时核对 Overview “文档” | 标签说明是 total 还是 ready各页口径一致 | AMBIGUOUSOverview=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 条记录 | PASSQA 空库 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 检索 | 答案包含 markercitation 定位到正确 chunk | PENDING-CONFIRM需上传并有额度 |
| TC-RE-010 | P0 | Deep 对同一 marker 检索 | 深度检索只生成一个结果,引用可回溯 | PENDING-CONFIRM |
| TC-RE-011 | P0 | Research 对多文档问题 | 计划→检索→分析→结果完整,证据与冲突可查 | PENDING-CONFIRM |
| TC-RE-012 | P0 | 无结果查询 | 明确“无充分证据”,不编造引用 | PASSQA 空库 Fast/Deep 明确“未召回分段”0 引用 |
| TC-RE-013 | P0 | Fast/Deep 正常查询 | 流式完成且无错误,记录状态与答案一致 | PARTIALQA 空库正常 0 结果;既有库受模型额度阻塞 |
| TC-RE-014 | P1 | Research 无召回 | 阶段和最终错误一致,可重试 | FAIL/PARTIALQA 空库静默回到 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拒绝 101UI 显示限制 | 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 | 导入合法 CSVquestion/evidence/tags | 显示行级预览和导入结果,未匹配证据保存草稿 | FAIL2 行预览正确;提交后仅显示“未知错误”,刷新仍为 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 模型下拉 | 只显示可用 embeddingFast/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 | 只允许 01保存值与显示一致 | NOT-RUNrerank 未配置 |
| TC-ST-025 | P0 | 两标签页使用相同 expectedRevision 并发保存 | 第二次 409给出刷新/合并引导,不静默覆盖 | NOT-RUN |
| TC-ST-026 | P1 | Save 返回 422/409/503 | 区分字段错误、冲突和服务不可用,不统一报网络 | FAIL422 被误报网络 |
| TC-ST-027 | P0 | 点击删除空间 | 必须二次确认,完成后立即从列表隐藏 | PENDING-CONFIRM |
## K. 权限、成员、API 和应用绑定
| ID | P | 测试场景/角色 | 预期结果 | 本轮结果 |
|---|---|---|---|---|
| TC-AU-001 | P0 | owner 访问列表/详情/上传/设置/成员/API/删除 | 所有功能可用 | PARTIALowner 主流程通过 |
| 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 | PASSAPI 域名返回 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-PARTIAL404 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 | 前端防抖+后端幂等,仅一次运行 | FAILRetrieval 已复现重复 |
| TC-RS-010 | P1 | 多标签页交错加载列表/详情并返回 | 旧响应不覆盖新路由,焦点和状态正确 | NOT-RUN |
| TC-RS-011 | P1 | 390 px/768 px/1280 px 响应式 | 无内容遮挡、不可达导航和水平滚动陷阱 | PARTIAL390 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/超大/负数/NaNscore 越界 | 统一严格校验,不进入模型/数据库 | 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/draftrestore 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-001005补充有额度的模型环境。
2. 经用户确认后执行 TC-DO-003014等索引 ready。
3. 紧接执行 TC-DD-004012、TC-RE-009011、TC-QA-009017保证同一批合成语料可串联校验。
4. 使用 owner/editor/viewer 三账号执行 TC-AU-001009并在正在运行的 SSE 中撤权。
5. 最后执行删除和清理核对列表、FS、检索、概览和 App binding 全部无残留。

View File

@ -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 访问:测试中临时启用并经刷新确认;已恢复为未启用并再次刷新确认
- 检索模式FastTop K = 10边界测试后已恢复
- Quality 参考问题1 条合成草稿已验证创建和编辑2 行 CSV 导入失败且刷新后无部分落库
- Firecrawl1 个已停止的临时抓取草稿,未添加为数据源
- 文件:尝试 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` 知识库中产生了以下合成检索记录,用于确认单次提交重复执行问题:
- Fast3 条同一合成问题记录(首次 1 条 + 单次 Retry 额外生成 2 条)
- Deep单次 Start 生成 2 条
- Research1 条
既有知识库的名称、描述、可见权限、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 端点 404WTA-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 修复验证
- Web14 个所属功能 spec、507 个测试全部通过;全量 `pnpm type-check` 通过。
- Dify API5 个相关测试文件、172 个测试全部通过;目标 Ruff 检查通过。
- KnowledgeFS APIGolden Question gateway 回归 5/5 通过API package TypeScript 检查通过。
- 补丁完整性:`git diff --check` 通过;本轮未创建 Git commit。
- Linear18 个已验证缺陷均更新为 `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-001Fast/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-004404 错误泄露内部 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`。
**期望**:只接受 1200 的整数;无效时提示并禁止抓取。
**实际**:用户可见文本分别保留为 `0`、`1.5`、`1001`,内部 number 值却静默变为 `1`、`1`、`200`,“抓取并预览”仍可用。
**影响**:用户对抓取规模的认知与实际执行不一致,可造成内容缺失且难以定位。
### KF-BUG-013Service 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-017Quality 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/DriveFirecrawl/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 区、编辑入口 | 内容加载成功,同时有重复 404metadata 编辑异常 |
| Retrieval | 空白输入、2000 字 UI 上限属性、Fast/Deep/ResearchStart、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 APIDataset Service API key 创建/正负鉴权KFS admission Web/API 域名;空间 API Access | Dataset key 200/错误 key 401KFS 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-001013、015019 已完成代码修复与所属层回归,仍应随同部署进行关键真实链路复测。
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”阻塞。

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,4 @@
question,evidence,tags
KnowledgeFS 测试标记是什么?,KF_QA_MARKER_20260811_X9Z,"smoke,marker"
哪个标记用于 Unicode 测试?,KF_QA_UNICODE_20260811,"unicode,markdown"
1 question evidence tags
2 KnowledgeFS 测试标记是什么? KF_QA_MARKER_20260811_X9Z smoke,marker
3 哪个标记用于 Unicode 测试? KF_QA_UNICODE_20260811 unicode,markdown

View File

@ -0,0 +1,6 @@
# KnowledgeFS 合成测试文档
- 标记:`KF_QA_UNICODE_20260811`
- 用途:验证 Unicode 文件名、Markdown 解析、分块和引用定位。
- 内容:这是完全合成的测试数据,不包含任何业务信息。

View File

@ -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.

View File

@ -15,6 +15,7 @@ type FormProps = {
onChange: (val: CreateExternalAPIReq) => void
formSchemas: FormSchema[]
inputClassName?: string
errors?: Partial<Record<'api_key' | 'endpoint' | 'name', string>>
}
const Form: FC<FormProps> = React.memo(
@ -26,6 +27,7 @@ const Form: FC<FormProps> = React.memo(
onChange,
formSchemas,
inputClassName,
errors,
}) => {
const { t, i18n } = useTranslation()
const docLink = useDocLink()
@ -50,6 +52,8 @@ const Form: FC<FormProps> = 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 (
<div
@ -80,11 +84,18 @@ const Form: FC<FormProps> = 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 && (
<p id={errorId} className="system-xs-regular text-text-destructive" role="alert">
{fieldError}
</p>
)}
</div>
)
}

View File

@ -220,6 +220,25 @@ describe('AddExternalAPIModal', () => {
})
})
it('shows one localized field error and does not request an invalid endpoint', async () => {
render(<AddExternalAPIModal {...defaultProps} />)
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',
})
})

View File

@ -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<AddExternalAPIModalProps> = ({
data,
onSave,
@ -77,6 +88,7 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [endpointError, setEndpointError] = useState<string>()
const [formData, setFormData] = useState<CreateExternalAPIReq>(() =>
isEditMode && data ? data : emptyExternalAPIFormData,
)
@ -86,9 +98,15 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({
: 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<AddExternalAPIModalProps> = ({
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<AddExternalAPIModalProps> = ({
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<AddExternalAPIModalProps> = ({
value={formData}
onChange={handleDataChange}
formSchemas={formSchemas}
errors={{ endpoint: endpointError }}
className="min-h-0 w-full flex-1 overflow-y-auto px-6 py-3"
/>
<div className="flex shrink-0 items-center justify-end gap-2 self-stretch p-6 pt-5">
@ -205,9 +226,12 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({
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}
>

View File

@ -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'

View File

@ -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<InfiniteOptions, 'queryKind'>) => ({
...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(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
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(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
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 () => {

View File

@ -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(<DocumentsPage knowledgeSpaceId="space-1" />, {
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(<DocumentsPage knowledgeSpaceId="space-1" />, { 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(<DocumentsPage knowledgeSpaceId="space-1" />, { 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(<DocumentsPage knowledgeSpaceId="space-1" />)
@ -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(<DocumentsPage knowledgeSpaceId="space-1" />)
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(<DocumentsPage knowledgeSpaceId="space-1" />)

View File

@ -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([

View File

@ -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()

View File

@ -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 ? (
<div role="dialog" aria-label="knowledge-fs-api-access">
{String(enabled)}:{String(canManageCredentials)}
</div>
) : 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(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
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(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
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',

View File

@ -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(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
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([

View File

@ -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({})

View File

@ -258,6 +258,182 @@ describe('RetrievalTestPage', () => {
})
})
it('admits only one research task while the first Start request is pending', async () => {
let resolvePlan: ((value: Awaited<ReturnType<typeof apiMock.planResearch>>) => void) | undefined
const pendingPlan = new Promise<Awaited<ReturnType<typeof apiMock.planResearch>>>((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<string, never>) => void) | undefined
apiMock.queryAdmission.mockReturnValueOnce(
new Promise<Record<string, never>>((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<string, never>) => void) | undefined
apiMock.queryAdmission.mockReturnValueOnce(
new Promise<Record<string, never>>((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<string, unknown>) => 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' }),

View File

@ -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(<WebsiteCrawlPreview connection={connection} knowledgeSpaceId="space-1" />)
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: 1200')
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(<WebsiteCrawlPreview connection={connection} knowledgeSpaceId="space-1" />)
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 () => {

View File

@ -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 }) => (
<button type="button" aria-label={`copy:${content}`} />
),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useMutation: (options: { mutationFn: (input: unknown) => Promise<unknown> }) => ({
isPending: false,
mutateAsync: options.mutationFn,
}),
useQuery: (options: unknown) => {
useQueryOptionsMock(options)
return credentialsQueryMock
},
}
})
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
spaces: {
byControlSpaceId: {
credentials: {
byCredentialId: {
delete: serviceMock.revoke,
},
post: serviceMock.create,
},
},
},
},
},
consoleQuery: {
knowledgeFs: {
spaces: {
byControlSpaceId: {
credentials: {
get: {
queryOptions: serviceMock.listQueryOptions,
},
},
},
},
},
},
}))
describe('KnowledgeFsApiAccessDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
credentialsQueryMock.isError = false
credentialsQueryMock.isPending = false
serviceMock.create.mockResolvedValue({
allowed_actions: ['queries.create'],
credential: 'kfs_secret-once',
credential_last4: 'once',
credential_prefix: 'kfs_',
expires_at: null,
id: 'credential-2',
principal: 'credential-2',
})
serviceMock.refetch.mockResolvedValue(undefined)
serviceMock.revoke.mockResolvedValue(undefined)
})
it('uses the real credentials API and reveals the new secret once', async () => {
const user = userEvent.setup()
render(
<KnowledgeFsApiAccessDialog
canManageCredentials
enabled
knowledgeSpaceId="space-1"
open
onOpenChange={vi.fn()}
/>,
)
expect(
screen.getByText('https://api.example.com/v1/knowledge-fs/spaces/space-1/queries/admission'),
).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.apiCredentialDescription')).toBeInTheDocument()
expect(serviceMock.listQueryOptions).toHaveBeenCalledWith({
input: { params: { control_space_id: 'space-1' } },
context: { silent: true },
})
expect(useQueryOptionsMock).toHaveBeenCalledWith(expect.objectContaining({ enabled: true }))
await user.click(screen.getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
expect(serviceMock.create).toHaveBeenCalledWith(
{
body: { allowed_actions: ['queries.create'], expires_at: null },
params: { control_space_id: 'space-1' },
},
{ context: { silent: true } },
)
expect(screen.getByText('kfs_secret-once')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'copy:kfs_secret-once' })).toBeInTheDocument()
expect(serviceMock.refetch).toHaveBeenCalledOnce()
})
it('revokes an existing credential through its generated endpoint', async () => {
const user = userEvent.setup()
render(
<KnowledgeFsApiAccessDialog
canManageCredentials
enabled
knowledgeSpaceId="space-1"
open
onOpenChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.delete kfs_••••1234' }))
await user.click(screen.getByRole('button', { name: /^common\.operation\.delete$/ }))
await waitFor(() => expect(serviceMock.revoke).toHaveBeenCalledOnce())
expect(serviceMock.revoke).toHaveBeenCalledWith(
{
params: { control_space_id: 'space-1', credential_id: 'credential-1' },
},
{ context: { silent: true } },
)
expect(serviceMock.refetch).toHaveBeenCalledOnce()
})
it('does not expose credential creation while API access is disabled', () => {
render(
<KnowledgeFsApiAccessDialog
canManageCredentials
enabled={false}
knowledgeSpaceId="space-1"
open
onOpenChange={vi.fn()}
/>,
)
expect(screen.getByText('dataset.newKnowledge.apiAccessInactive')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' }),
).not.toBeInTheDocument()
expect(useQueryOptionsMock).toHaveBeenCalledWith(expect.objectContaining({ enabled: false }))
})
it('renders one local error when credential creation fails silently', async () => {
const user = userEvent.setup()
serviceMock.create.mockRejectedValueOnce(new Error('upstream route detail'))
render(
<KnowledgeFsApiAccessDialog
canManageCredentials
enabled
knowledgeSpaceId="space-1"
open
onOpenChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' }))
const alerts = await screen.findAllByRole('alert')
expect(alerts).toHaveLength(1)
expect(alerts[0]).toHaveTextContent('common.api.actionFailed')
expect(serviceMock.create).toHaveBeenCalledWith(expect.any(Object), {
context: { silent: true },
})
expect(screen.queryByText('upstream route detail')).not.toBeInTheDocument()
})
it('keeps one local error visible in the revoke confirmation when revocation fails silently', async () => {
const user = userEvent.setup()
serviceMock.revoke.mockRejectedValueOnce(new Error('upstream revoke route detail'))
render(
<KnowledgeFsApiAccessDialog
canManageCredentials
enabled
knowledgeSpaceId="space-1"
open
onOpenChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.delete kfs_••••1234' }))
const confirmation = screen.getByRole('alertdialog')
await user.click(
within(confirmation).getByRole('button', { name: /^common\.operation\.delete$/ }),
)
const alert = await within(confirmation).findByRole('alert')
expect(alert).toHaveTextContent('common.api.actionFailed')
expect(screen.getAllByRole('alert')).toHaveLength(1)
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
expect(serviceMock.revoke).toHaveBeenCalledWith(expect.any(Object), {
context: { silent: true },
})
expect(serviceMock.refetch).not.toHaveBeenCalled()
expect(screen.queryByText('upstream revoke route detail')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,324 @@
'use client'
import type {
KnowledgeFsCredentialCreateResponse,
KnowledgeFsCredentialItemResponse,
} from '@dify/contracts/api/console/knowledge-fs/types.gen'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CopyFeedback from '@/app/components/base/copy-feedback'
import { consoleClient, consoleQuery } from '@/service/client'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
const DEFAULT_ALLOWED_ACTIONS = ['queries.create']
function maskedCredential(credential: KnowledgeFsCredentialItemResponse) {
return `${credential.credential_prefix}••••${credential.credential_last4}`
}
export function KnowledgeFsApiAccessDialog({
canManageCredentials,
enabled,
knowledgeSpaceId,
onOpenChange,
open,
}: {
canManageCredentials: boolean
enabled: boolean
knowledgeSpaceId: string
onOpenChange: (open: boolean) => void
open: boolean
}) {
const { t } = useTranslation('dataset')
const { t: tAppApi, i18n } = useTranslation('appApi')
const { t: tCommon } = useTranslation('common')
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
const [createdCredential, setCreatedCredential] = useState<KnowledgeFsCredentialCreateResponse>()
const [credentialToRevoke, setCredentialToRevoke] = useState<KnowledgeFsCredentialItemResponse>()
const [actionError, setActionError] = useState<'create' | 'revoke'>()
const credentialsQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.credentials.get.queryOptions({
input: { params: { control_space_id: knowledgeSpaceId } },
context: { silent: true },
}),
enabled: open && enabled && canManageCredentials,
})
const createMutation = useMutation({
mutationFn: (
input: Parameters<
typeof consoleClient.knowledgeFs.spaces.byControlSpaceId.credentials.post
>[0],
) =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.credentials.post(input, {
context: { silent: true },
}),
})
const revokeMutation = useMutation({
mutationFn: (
input: Parameters<
typeof consoleClient.knowledgeFs.spaces.byControlSpaceId.credentials.byCredentialId.delete
>[0],
) =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.credentials.byCredentialId.delete(input, {
context: { silent: true },
}),
})
const endpoint = apiBaseInfo?.api_base_url
? `${apiBaseInfo.api_base_url.replace(/\/$/, '')}/knowledge-fs/spaces/${encodeURIComponent(knowledgeSpaceId)}/queries/admission`
: ''
const createCredential = async () => {
if (!enabled || !canManageCredentials || createMutation.isPending) return
setActionError(undefined)
try {
const result = await createMutation.mutateAsync({
body: { allowed_actions: DEFAULT_ALLOWED_ACTIONS, expires_at: null },
params: { control_space_id: knowledgeSpaceId },
})
setCreatedCredential(result)
await credentialsQuery.refetch()
} catch {
setActionError('create')
}
}
const revokeCredential = async () => {
if (!credentialToRevoke || revokeMutation.isPending) return
setActionError(undefined)
try {
await revokeMutation.mutateAsync({
params: {
control_space_id: knowledgeSpaceId,
credential_id: credentialToRevoke.id,
},
})
setCredentialToRevoke(undefined)
await credentialsQuery.refetch()
} catch {
setActionError('revoke')
}
}
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setCreatedCredential(undefined)
setCredentialToRevoke(undefined)
setActionError(undefined)
}
onOpenChange(nextOpen)
}
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-150! max-w-[calc(100vw-2rem)]! flex-col overflow-hidden! rounded-2xl! p-0!">
<header className="flex shrink-0 items-start justify-between gap-4 px-6 pt-6 pb-4">
<div>
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['newKnowledge.apiAgentAccess'])}
</DialogTitle>
<p className="mt-1 body-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.apiCredentialDescription'])}
</p>
</div>
<Button
type="button"
variant="ghost"
aria-label={tCommon(($) => $['operation.close'])}
className="size-8 shrink-0 px-0"
onClick={() => handleOpenChange(false)}
>
<span aria-hidden className="i-ri-close-line size-4" />
</Button>
</header>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 pb-6">
<section>
<p className="system-xs-semibold-uppercase text-text-tertiary">
{t(($) => $['serviceApi.card.endpoint'])}
</p>
<div className="mt-1 flex min-h-8 items-center gap-1 rounded-lg bg-components-input-bg-normal py-1 pr-1 pl-3">
<code className="min-w-0 flex-1 truncate system-xs-medium text-text-secondary">
{endpoint || tAppApi(($) => $.loading)}
</code>
{endpoint && <CopyFeedback content={endpoint} />}
</div>
</section>
{!enabled ? (
<div className="rounded-lg bg-background-section px-3 py-2 body-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.apiAccessInactive'])}
</div>
) : !canManageCredentials ? (
<div className="rounded-lg bg-background-section px-3 py-2 body-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.settings.viewOnly'])}
</div>
) : (
<>
{createdCredential && (
<section
className="rounded-xl border border-text-success/20 bg-state-success-hover p-3"
role="status"
>
<p className="system-sm-semibold text-text-secondary">
{tAppApi(($) => $['apiKeyModal.secretKey'])}
</p>
<p className="mt-1 body-xs-regular text-text-tertiary">
{tAppApi(($) => $['apiKeyModal.generateTips'])}
</p>
<div className="mt-2 flex min-h-8 items-center gap-1 rounded-lg bg-components-input-bg-normal py-1 pr-1 pl-3">
<code className="min-w-0 flex-1 system-xs-medium break-all text-text-secondary">
{createdCredential.credential}
</code>
<CopyFeedback content={createdCredential.credential} />
</div>
</section>
)}
{actionError === 'create' && (
<div
className="rounded-lg bg-components-badge-status-light-error-bg px-3 py-2 body-xs-regular text-text-destructive"
role="alert"
>
{tCommon(($) => $['api.actionFailed'])}
</div>
)}
<div className="flex items-center justify-between gap-3">
<p className="system-sm-semibold text-text-secondary">
{tAppApi(($) => $['apiKeyModal.apiSecretKey'])}
</p>
<Button
type="button"
size="small"
variant="primary"
loading={createMutation.isPending}
onClick={() => void createCredential()}
>
{tAppApi(($) => $['apiKeyModal.createNewSecretKey'])}
</Button>
</div>
{credentialsQuery.isPending ? (
<p role="status" className="py-5 text-center body-xs-regular text-text-tertiary">
{tAppApi(($) => $.loading)}
</p>
) : credentialsQuery.isError ? (
<div
className="flex items-center justify-between gap-3 rounded-lg bg-components-badge-status-light-error-bg px-3 py-2"
role="alert"
>
<span className="body-xs-regular text-text-destructive">
{tCommon(($) => $['api.actionFailed'])}
</span>
<Button size="small" onClick={() => void credentialsQuery.refetch()}>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
) : credentialsQuery.data?.data.length ? (
<ul className="space-y-2">
{credentialsQuery.data.data.map((credential) => {
const masked = maskedCredential(credential)
return (
<li
key={credential.id}
className="flex min-w-0 items-center gap-3 rounded-xl border border-components-panel-border px-3 py-2"
>
<span
aria-hidden
className={`size-2 shrink-0 rounded-full ${credential.status === 'active' ? 'bg-util-colors-green-green-500' : 'bg-text-quaternary'}`}
/>
<div className="min-w-0 flex-1">
<code className="system-xs-medium text-text-secondary">{masked}</code>
<p className="mt-0.5 body-xs-regular text-text-tertiary">
{tAppApi(($) => $['apiKeyModal.lastUsed'])}:{' '}
{credential.last_used_at
? new Date(credential.last_used_at).toLocaleString(i18n.language)
: tAppApi(($) => $.never)}
</p>
</div>
<span className="sr-only">
{t(($) =>
credential.status === 'active'
? $['newKnowledge.apiAccessActive']
: $['newKnowledge.apiAccessInactive'],
)}
</span>
<Button
type="button"
size="small"
variant="ghost"
aria-label={`${tCommon(($) => $['operation.delete'])} ${masked}`}
disabled={credential.status !== 'active'}
onClick={() => {
setActionError(undefined)
setCredentialToRevoke(credential)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</Button>
</li>
)
})}
</ul>
) : (
<p className="py-5 text-center body-xs-regular text-text-tertiary">
{tAppApi(($) => $['develop.noContent'])}
</p>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(credentialToRevoke)}
onOpenChange={(nextOpen) => {
if (!nextOpen && !revokeMutation.isPending) {
setCredentialToRevoke(undefined)
setActionError(undefined)
}
}}
>
<AlertDialogContent>
<AlertDialogTitle>{tAppApi(($) => $['actionMsg.deleteConfirmTitle'])}</AlertDialogTitle>
<AlertDialogDescription>
{tAppApi(($) => $['actionMsg.deleteConfirmTips'])}
</AlertDialogDescription>
{actionError === 'revoke' && (
<div
className="mx-6 mt-4 rounded-lg bg-components-badge-status-light-error-bg px-3 py-2 body-xs-regular text-text-destructive"
role="alert"
>
{tCommon(($) => $['api.actionFailed'])}
</div>
)}
<AlertDialogActions>
<AlertDialogCancelButton disabled={revokeMutation.isPending}>
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={revokeMutation.isPending}
onClick={() => void revokeCredential()}
>
{tCommon(($) => $['operation.delete'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -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() {
<Field
name="name"
className="gap-1.5"
invalid={nameLengthInvalid}
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && !value.trim())
return t(($) => $['newKnowledge.nameRequired'])
@ -467,8 +472,10 @@ export function CreateKnowledgePage() {
</FieldLabel>
<FieldControl
autoComplete="off"
aria-describedby={
nameLengthInvalid ? 'knowledge-create-name-error' : undefined
}
disabled={submissionLocked}
maxLength={NAME_MAX_LENGTH}
placeholder={t(($) => $['newKnowledge.namePlaceholder'])}
required
value={name}
@ -480,15 +487,20 @@ export function CreateKnowledgePage() {
<FieldError match="valueMissing">
{t(($) => $['newKnowledge.nameRequired'])}
</FieldError>
<FieldError id="knowledge-create-name-error" match={nameLengthInvalid}>
{tDatasetCreation(($) => $['stepOne.modal.nameLengthInvalid'])}
</FieldError>
<FieldError match="customError" />
</Field>
<Field name="description" className="gap-1.5">
<Field name="description" className="gap-1.5" invalid={descriptionLengthInvalid}>
<FieldLabel>{t(($) => $['newKnowledge.description'])}</FieldLabel>
<Textarea
autoComplete="off"
aria-describedby={
descriptionLengthInvalid ? 'knowledge-create-description-error' : undefined
}
className="min-h-20"
disabled={submissionLocked}
maxLength={DESCRIPTION_MAX_LENGTH}
name="description"
placeholder={t(($) => $['newKnowledge.descriptionPlaceholder'])}
value={description}
@ -500,6 +512,14 @@ export function CreateKnowledgePage() {
<FieldDescription>
{t(($) => $['newKnowledge.descriptionHelp'])}
</FieldDescription>
<FieldError
id="knowledge-create-description-error"
match={descriptionLengthInvalid}
>
{tWorkflow(($) => $['chatVariable.modal.descriptionTooLong'], {
maxLength: DESCRIPTION_MAX_LENGTH,
})}
</FieldError>
</Field>
<div className="flex flex-col gap-1.5">
<Select

View File

@ -1,5 +1,6 @@
'use client'
import type { DocumentUploadIssue } from './document-upload-policy'
import type { KnowledgeFsUploadPhase } from './knowledge-fs-upload'
import { cn } from '@langgenius/dify-ui/cn'
import { useId, useState } from 'react'
@ -15,7 +16,7 @@ import { createRequestId } from './request-id'
export type QueuedUpload = {
file: File
id: string
issue?: 'fileSize' | 'fileType'
issue?: DocumentUploadIssue
stagedUploadId?: string
stagingFailed?: boolean
}

View File

@ -41,6 +41,7 @@ export function documentOutlineQueryOptions({
consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.outline
return outlineQuery.get.queryOptions({
context: { silent: true },
input: documentAssetId
? {
params: {
@ -49,5 +50,6 @@ export function documentOutlineQueryOptions({
},
}
: skipToken,
retry: false,
})
}

View File

@ -105,7 +105,6 @@ export function DocumentMetadataCard({
const router = useRouter()
const [drafts, setDrafts] = useState<MetadataDraft[]>([])
const [editing, setEditing] = useState(false)
const [preparing, setPreparing] = useState(false)
const [saving, setSaving] = useState(false)
const [creating, setCreating] = useState(false)
const [retryableCreateName, setRetryableCreateName] = useState<string>()
@ -124,11 +123,18 @@ export function DocumentMetadataCard({
...documentMetadataFieldsQueryOptions(controlSpaceId),
enabled: editing,
})
const fields = metadataFieldsQuery.data ?? []
const fields = useMemo(() => metadataFieldsQuery.data ?? [], [metadataFieldsQuery.data])
const resolvedDrafts = useMemo(() => {
const fieldTypes = new Map(fields.map((field) => [field.name, field.type]))
return drafts.map((draft) => ({
...draft,
type: fieldTypes.get(draft.name) ?? draft.type,
}))
}, [drafts, fields])
const renderedItems = useMemo(
() =>
editing
? drafts.map((draft) => ({
? resolvedDrafts.map((draft) => ({
id: draft.id,
name: draft.name,
type: draft.type,
@ -140,7 +146,7 @@ export function DocumentMetadataCard({
type: documentMetadataType(value),
value,
})),
[drafts, editing, entries],
[editing, entries, resolvedDrafts],
)
const invalidateMetadataQueries = async () => {
@ -158,21 +164,11 @@ export function DocumentMetadataCard({
])
}
const startEditing = async () => {
if (!canEdit || preparing) return
setPreparing(true)
try {
let availableFields = fields
if (!metadataFieldsQuery.data) {
const result = await metadataFieldsQuery.refetch()
availableFields = result.data ?? []
}
setEditBaseline({ metadata: document.userMetadata, rowVersion: document.rowVersion })
setDrafts(metadataDrafts(document, availableFields))
setEditing(true)
} finally {
setPreparing(false)
}
const startEditing = () => {
if (!canEdit) return
setEditBaseline({ metadata: document.userMetadata, rowVersion: document.rowVersion })
setDrafts(metadataDrafts(document, fields))
setEditing(true)
}
const cancelEditing = () => {
@ -239,11 +235,11 @@ export function DocumentMetadataCard({
if (!canEdit || saving) return
const patch: Record<string, unknown> = {}
const original = new Map(editableDocumentMetadataEntries(editBaseline.metadata))
const nextNames = new Set(drafts.map((draft) => draft.name))
const nextNames = new Set(resolvedDrafts.map((draft) => draft.name))
for (const name of original.keys()) {
if (!nextNames.has(name)) patch[name] = null
}
for (const draft of drafts) {
for (const draft of resolvedDrafts) {
const value = metadataValueFromInput(draft.value, draft.type)
if (!original.has(draft.name) || !Object.is(original.get(draft.name), value))
patch[draft.name] = value
@ -282,13 +278,7 @@ export function DocumentMetadataCard({
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['metadata.documentMetadata.metadataToolTip'])}
</p>
<Button
className="mt-2"
disabled={!canEdit}
loading={preparing}
onClick={() => void startEditing()}
variant="primary"
>
<Button className="mt-2" disabled={!canEdit} onClick={startEditing} variant="primary">
{t(($) => $['metadata.documentMetadata.startLabeling'])}
<span aria-hidden className="ml-1 i-ri-arrow-right-line size-4" />
</Button>
@ -302,12 +292,7 @@ export function DocumentMetadataCard({
{t(($) => $['metadata.metadata'])}
</h2>
{!editing && canEdit && (
<Button
loading={preparing}
onClick={() => void startEditing()}
size="small"
variant="ghost"
>
<Button onClick={startEditing} size="small" variant="ghost">
<span aria-hidden className="mr-1 i-ri-edit-line size-3.5" />
{tCommon(($) => $['operation.edit'])}
</Button>

View File

@ -74,10 +74,12 @@ export function documentMetadataFieldFromApi(
export function documentMetadataFieldsQueryOptions(knowledgeSpaceId: string) {
return {
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.metadata.get.queryOptions({
context: { silent: true },
input: {
params: { control_space_id: knowledgeSpaceId },
query: { limit: 100 },
},
retry: false,
}),
select: (response: { data: KnowledgeFsMetadataFieldResponse[] }) =>
response.data.map(documentMetadataFieldFromApi),

View File

@ -26,7 +26,7 @@ export const DOCUMENT_UPLOAD_ACCEPT = DOCUMENT_UPLOAD_EXTENSIONS.map(
(extension) => `.${extension}`,
).join(',')
export type DocumentUploadIssue = 'fileSize' | 'fileType'
export type DocumentUploadIssue = 'fileEmpty' | 'fileSize' | 'fileType'
export function documentUploadFingerprint(file: File) {
return `${file.name}:${file.size}:${file.lastModified}`
@ -51,6 +51,7 @@ export function documentUploadFileExtension(name: string) {
}
export function documentUploadIssue(file: File): DocumentUploadIssue | undefined {
if (file.size === 0) return 'fileEmpty'
if (file.size > DOCUMENT_UPLOAD_MAX_BYTES) return 'fileSize'
if (!documentUploadExtensionSet.has(documentUploadFileExtension(file.name))) return 'fileType'
}

View File

@ -2,6 +2,7 @@
import type { DocumentAction } from './document-actions-dropdown'
import type { DocumentProcessingTask } from './document-models'
import type { DocumentUploadIssue } from './document-upload-policy'
import type { KnowledgeFsUploadPhase, KnowledgeFsUploadProgress } from './knowledge-fs-upload'
import type {
ProcessingTaskEvent,
@ -75,6 +76,7 @@ const MAX_TASK_EVENT_STREAMS = 6
const MAX_AUTO_CURSOR_PAGES = 20
const FAILED_TASK_POLL_REQUEST_TIMEOUT = 3000
const TERMINAL_RECONCILIATION_REQUEST_TIMEOUT = 3000
const DOCUMENT_STAGING_REQUEST_TIMEOUT = 30_000
const BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL = 5000
const MAX_BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL = 30000
const documentFilterParser = parseAsStringLiteral([
@ -97,6 +99,20 @@ const documentMetadataParser = parseAsStringLiteral(['1'] as const).withOptions(
history: 'replace',
})
class DocumentStagingCanceledError extends Error {
constructor() {
super('Document staging was canceled')
this.name = 'DocumentStagingCanceledError'
}
}
class DocumentStagingTimeoutError extends Error {
constructor() {
super('Document staging timed out')
this.name = 'DocumentStagingTimeoutError'
}
}
const uploadExclusionReasonKey = {
batch_byte_limit_exceeded: 'batchLimit',
document_not_found: 'target',
@ -146,7 +162,8 @@ async function findBackgroundTasks(
}
type UploadExclusionReasonKey =
(typeof uploadExclusionReasonKey)[keyof typeof uploadExclusionReasonKey]
| DocumentUploadIssue
| (typeof uploadExclusionReasonKey)[keyof typeof uploadExclusionReasonKey]
type TerminalTaskPin = {
observedAt: string
@ -252,7 +269,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
const uploadRequestIdsRef = useRef(new Map<string, string>())
const stagedUploadIdsRef = useRef(new Map<File, string>())
const stagingPromisesRef = useRef(new Map<File, Promise<string>>())
const discardAfterStagingRef = useRef(new Set<File>())
const stagingControllersRef = useRef(new Map<File, AbortController>())
const reindexPendingRef = useRef(false)
const documentActionPendingRef = useRef(false)
const bulkActionPendingRef = useRef(false)
@ -417,37 +434,79 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
const active = stagingPromisesRef.current.get(file)
if (active) return active
discardAfterStagingRef.current.delete(file)
const promise = stageKnowledgeFsDocument(file)
.then((uploadId) => {
if (discardAfterStagingRef.current.delete(file)) {
void discardKnowledgeFsStagedUpload(uploadId).catch(() => undefined)
return uploadId
const controller = new AbortController()
let settled = false
let timeout: number | undefined
const promise = new Promise<string>((resolve, reject) => {
function cleanup() {
if (timeout !== undefined) window.clearTimeout(timeout)
controller.signal.removeEventListener('abort', handleAbort)
if (stagingControllersRef.current.get(file) === controller) {
stagingPromisesRef.current.delete(file)
stagingControllersRef.current.delete(file)
}
stagedUploadIdsRef.current.set(file, uploadId)
return uploadId
})
.finally(() => stagingPromisesRef.current.delete(file))
}
function rejectOnce(error: unknown) {
if (settled) return
settled = true
cleanup()
reject(error)
}
function handleAbort() {
rejectOnce(
controller.signal.reason instanceof Error
? controller.signal.reason
: new DocumentStagingCanceledError(),
)
}
controller.signal.addEventListener('abort', handleAbort, { once: true })
timeout = window.setTimeout(
() => controller.abort(new DocumentStagingTimeoutError()),
DOCUMENT_STAGING_REQUEST_TIMEOUT,
)
void stageKnowledgeFsDocument(file, controller.signal).then(
(uploadId) => {
if (settled) {
void discardKnowledgeFsStagedUpload(uploadId).catch(() => undefined)
return
}
settled = true
cleanup()
stagedUploadIdsRef.current.set(file, uploadId)
resolve(uploadId)
},
(error) => {
rejectOnce(error)
},
)
})
stagingPromisesRef.current.set(file, promise)
stagingControllersRef.current.set(file, controller)
return promise
})
if (!tasks.length) return
const results = await Promise.allSettled(tasks)
const failed = results.find(
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected',
)
const failed =
failures.find(({ reason }) => !(reason instanceof DocumentStagingCanceledError)) ??
failures[0]
if (failed) throw failed.reason
}, [])
const discardStagedFile = useCallback((file: File) => {
if (stagingPromisesRef.current.has(file)) discardAfterStagingRef.current.add(file)
stagingControllersRef.current.get(file)?.abort(new DocumentStagingCanceledError())
const uploadId = stagedUploadIdsRef.current.get(file)
stagedUploadIdsRef.current.delete(file)
if (uploadId) void discardKnowledgeFsStagedUpload(uploadId).catch(() => undefined)
}, [])
const discardStagedUploadObjects = useCallback(() => {
const uploadIds = [...stagedUploadIdsRef.current.values()]
for (const file of stagingPromisesRef.current.keys()) discardAfterStagingRef.current.add(file)
for (const controller of stagingControllersRef.current.values())
controller.abort(new DocumentStagingCanceledError())
stagingControllersRef.current.clear()
stagingPromisesRef.current.clear()
stagedUploadIdsRef.current.clear()
uploadProgressRef.current.clear()
uploadRequestIdsRef.current.clear()
@ -479,6 +538,12 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
discardAllStagedFiles()
closeUploadForm()
}, [closeUploadForm, discardAllStagedFiles])
useEffect(
() => () => {
discardStagedUploadObjects()
},
[discardStagedUploadObjects],
)
useEffect(() => {
if (uploadRequest !== '1' || permissionPending || canUpload) return
discardStagedUploadObjects()
@ -2679,6 +2744,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
try {
await stageFiles(files)
} catch (error) {
if (error instanceof DocumentStagingCanceledError) return
toast.error(t(($) => $['newKnowledge.documentUploadFailed']))
throw error
}

View File

@ -13,10 +13,13 @@ type UploadProgressEntry = {
export type KnowledgeFsUploadPhase = 'completed' | 'pending'
export type KnowledgeFsUploadProgress = Map<string, UploadProgressEntry>
export async function stageKnowledgeFsDocument(file: File) {
const staged = await consoleClient.knowledgeFs.uploads.post({
body: { file },
})
export async function stageKnowledgeFsDocument(file: File, signal?: AbortSignal) {
const staged = await consoleClient.knowledgeFs.uploads.post(
{
body: { file },
},
{ context: { silent: true }, signal },
)
return staged.id
}
@ -36,10 +39,13 @@ export async function uploadKnowledgeFsDocuments(
if (progress.get(upload.id)?.phase === 'completed') continue
progress.set(upload.id, { phase: 'pending' })
onProgress?.(upload.file, 'pending')
await consoleClient.knowledgeFs.spaces.byControlSpaceId.documents.post({
body: { upload_id: upload.uploadId },
params: { control_space_id: controlSpaceId },
})
await consoleClient.knowledgeFs.spaces.byControlSpaceId.documents.post(
{
body: { upload_id: upload.uploadId },
params: { control_space_id: controlSpaceId },
},
{ context: { silent: true } },
)
progress.set(upload.id, { phase: 'completed' })
onProgress?.(upload.file, 'completed')
}

View File

@ -40,7 +40,11 @@ import { consoleQuery } from '@/service/client'
import { KnowledgeSettingsMembers } from './components/knowledge-settings-members'
import { KnowledgeSpaceIcon } from './components/knowledge-space-icon'
import { RetrievalModeSegmentedControl } from './components/retrieval-mode-segmented-control'
import { isKnowledgeModelSetupReady, KNOWLEDGE_NAME_MAX_LENGTH } from './constants'
import {
isKnowledgeModelSetupReady,
KNOWLEDGE_DESCRIPTION_MAX_LENGTH,
KNOWLEDGE_NAME_MAX_LENGTH,
} from './constants'
import { newKnowledgeListPath } from './routes'
const TOP_K_MIN = 1
@ -48,6 +52,7 @@ const TOP_K_MAX = 10
const SCORE_THRESHOLD_MIN = 0
const SCORE_THRESHOLD_MAX = 1
const NAME_ERROR_ID = 'knowledge-name-error'
const DESCRIPTION_ERROR_ID = 'knowledge-description-error'
const API_ACCESS_DESCRIPTION_ID = 'knowledge-api-access-description'
const REASONING_MODEL_LABEL_ID = 'knowledge-reasoning-model-label'
const EMBEDDING_MODEL_LABEL_ID = 'knowledge-embedding-model-label'
@ -215,6 +220,7 @@ export function KnowledgeSettingsForm({
const { t: tCommon } = useTranslation('common')
const { t: tSettings } = useTranslation('datasetSettings')
const { t: tAppDebug } = useTranslation('appDebug')
const { t: tWorkflow } = useTranslation('workflow')
const queryClient = useQueryClient()
const router = useRouter()
const { data: reasoningModelList } = useModelList(ModelTypeEnum.textGeneration)
@ -284,6 +290,7 @@ export function KnowledgeSettingsForm({
const [deleteConfirmation, setDeleteConfirmation] = useState('')
const deleteCancelRef = useRef<HTMLButtonElement>(null)
const pendingNavigationRef = useRef<string | undefined>(undefined)
const pendingExternalAccessEnabledRef = useRef(initialApiEnabled)
const completedBasicSaveFingerprintsRef = useRef<Partial<Record<BasicSaveSlice, string>>>({})
const handledMigrationIdRef = useRef<string | undefined>(undefined)
const pendingSettingsDraftRef = useRef<SettingsDraft | undefined>(undefined)
@ -333,6 +340,7 @@ export function KnowledgeSettingsForm({
const basicDirty = spaceDirty || membersDirty
const isDirty = basicDirty
const nameInvalid = !name.trim()
const descriptionInvalid = Array.from(description).length > KNOWLEDGE_DESCRIPTION_MAX_LENGTH
const membersInvalid =
canManageAccess && visibility === 'partial_members' && selectedMemberIds.length === 0
@ -379,7 +387,8 @@ export function KnowledgeSettingsForm({
const fieldsDisabled = !canEdit || isSaving
const retrievalFieldsDisabled = fieldsDisabled || (!initialModelSetup && embeddingDirty)
const scoreThresholdAvailable = retrievalMode === 'research' || rerankEnabled
const saveDisabled = !basicDirty || nameInvalid || membersInvalid || isSaving || serverConflict
const saveDisabled =
!basicDirty || nameInvalid || descriptionInvalid || membersInvalid || isSaving || serverConflict
const startDraft = () => onDraftStart?.()
const resetDraft = () => {
@ -516,6 +525,7 @@ export function KnowledgeSettingsForm({
const performExternalAccessSave = async (enabled: boolean) => {
if (!canEdit || !canManageAccess || externalAccessMutation.isPending) return
pendingExternalAccessEnabledRef.current = enabled
setSaveErrorSlice(undefined)
try {
await externalAccessMutation.mutateAsync({
@ -527,8 +537,10 @@ export function KnowledgeSettingsForm({
},
params: { control_space_id: space.control_space_id },
})
setApiEnabled(enabled)
await invalidateSettingsQueries()
} catch {
setApiEnabled(initialApiEnabled)
setSaveErrorSlice('externalAccess')
}
}
@ -630,7 +642,7 @@ export function KnowledgeSettingsForm({
const retrySave = () => {
if (saveErrorSlice === 'externalAccess') {
void performExternalAccessSave(apiEnabled)
void performExternalAccessSave(pendingExternalAccessEnabledRef.current)
return
}
if (saveErrorSlice === 'settings') {
@ -840,19 +852,37 @@ export function KnowledgeSettingsForm({
</SettingsRow>
<SettingsRow label={tSettings(($) => $['form.desc'])}>
<Textarea
aria-label={tSettings(($) => $['form.desc'])}
autoComplete="off"
name="knowledge-description"
value={description}
disabled={fieldsDisabled}
placeholder={t(($) => $['newKnowledge.settings.descriptionPlaceholder'])}
className="min-h-20 resize-none"
onValueChange={(value) => {
startDraft()
setDescription(value)
}}
/>
<div>
<Textarea
aria-label={tSettings(($) => $['form.desc'])}
aria-describedby={descriptionInvalid ? DESCRIPTION_ERROR_ID : undefined}
aria-invalid={descriptionInvalid}
autoComplete="off"
name="knowledge-description"
value={description}
disabled={fieldsDisabled}
placeholder={t(($) => $['newKnowledge.settings.descriptionPlaceholder'])}
className={cn(
'min-h-20 resize-none',
descriptionInvalid && 'ring-1 ring-text-destructive',
)}
onValueChange={(value) => {
startDraft()
setDescription(value)
}}
/>
{descriptionInvalid && (
<p
id={DESCRIPTION_ERROR_ID}
className="mt-1 system-xs-regular text-text-destructive"
role="alert"
>
{tWorkflow(($) => $['chatVariable.modal.descriptionTooLong'], {
maxLength: KNOWLEDGE_DESCRIPTION_MAX_LENGTH,
})}
</p>
)}
</div>
</SettingsRow>
<SettingsRow label={tSettings(($) => $['form.permissions'])}>

View File

@ -4,7 +4,6 @@ import type { CSSProperties, ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -16,6 +15,7 @@ import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { KnowledgeFsApiAccessDialog } from './components/knowledge-fs-api-access-dialog'
import { KnowledgeSpaceIcon } from './components/knowledge-space-icon'
import {
newKnowledgeDetailPath,
@ -79,10 +79,12 @@ export function KnowledgeSpaceShell({
const { t: tCommon } = useTranslation('common')
const { t: tApp } = useTranslation('app')
const [sidebarExpanded, setSidebarExpanded] = useState(true)
const [apiAccessDialogOpen, setApiAccessDialogOpen] = useState(false)
const pathname = usePathname()
const knowledgeSpaceQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.get.queryOptions({
input: { params: { control_space_id: knowledgeSpaceId } },
context: { silent: true },
}),
refetchInterval: (query) => (query.state.data?.state === 'provisioning' ? 1000 : false),
retry: (failureCount, error) => {
@ -95,6 +97,9 @@ export function KnowledgeSpaceShell({
const canManageAccess = (knowledgeSpaceQuery.data?.permission_keys ?? []).includes(
'knowledge_space_access_config',
)
const canManageCredentials = (knowledgeSpaceQuery.data?.permission_keys ?? []).includes(
'knowledge_space_api_key_manage',
)
const externalAccessQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.externalAccess.get.queryOptions({
input: { params: { control_space_id: knowledgeSpaceId } },
@ -162,7 +167,6 @@ export function KnowledgeSpaceShell({
pathname === retrievalTestPath || pathname.startsWith(`${retrievalTestPath}/`)
const qualityActive = pathname === qualityPath || pathname.startsWith(`${qualityPath}/`)
const settingsActive = pathname === settingsPath || pathname.startsWith(`${settingsPath}/`)
const showDeferredPage = () => toast.info(t(($) => $['cornerLabel.unavailable']))
const navItemClassName =
'flex h-8 shrink-0 items-center gap-2 rounded-lg pr-1 pl-3 system-sm-medium outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const navIcon = (className: string) => (
@ -260,7 +264,7 @@ export function KnowledgeSpaceShell({
</div>
</div>
<nav
className="flex gap-0.5 overflow-x-auto px-2 py-1 sm:flex-1 sm:flex-col"
className="grid grid-cols-3 gap-0.5 px-2 py-1 sm:flex sm:flex-1 sm:flex-col"
aria-label={knowledgeSpaceName}
>
<Link
@ -363,7 +367,7 @@ export function KnowledgeSpaceShell({
'w-full border-[0.5px] border-components-panel-border text-text-secondary',
sidebarExpanded ? 'justify-start' : 'justify-center px-0',
)}
onClick={showDeferredPage}
onClick={() => setApiAccessDialogOpen(true)}
>
{navIcon('i-custom-vender-knowledge-api-aggregate')}
{sidebarExpanded && (
@ -392,6 +396,13 @@ export function KnowledgeSpaceShell({
{children}
</section>
</div>
<KnowledgeFsApiAccessDialog
canManageCredentials={canManageCredentials}
enabled={apiAccessEnabled}
knowledgeSpaceId={knowledgeSpaceId}
open={apiAccessDialogOpen}
onOpenChange={setApiAccessDialogOpen}
/>
</div>
)
}

View File

@ -201,6 +201,20 @@ export function NewKnowledgeList({
/>
)}
</div>
) : normalizedSearchValue && visibleKnowledgeSpaces.length === 0 ? (
<div className="px-4 pt-2 pb-8 sm:px-8">
<NewKnowledgePageState
title={tCommon(($) => $['operation.noSearchResults'], {
content: t(($) => $.knowledge),
})}
description={searchValue.trim()}
action={
<Button onClick={() => setSearchValue('')}>
{tCommon(($) => $['operation.clear'])}
</Button>
}
/>
</div>
) : knowledgeSpaces.length === 0 && creatorIds.length === 0 ? (
<NewKnowledgeEmptyState
canConnect={canConnect}

View File

@ -29,6 +29,17 @@ function parseTags(value: string) {
.filter(Boolean)
}
function errorStatus(error: unknown): number | undefined {
if (error instanceof Response) return error.status
if (!error || typeof error !== 'object') return undefined
const status = 'status' in error ? error.status : undefined
if (typeof status === 'number') return status
const data = 'data' in error ? error.data : undefined
if (!data || typeof data !== 'object') return undefined
const dataStatus = 'status' in data ? data.status : undefined
return typeof dataStatus === 'number' ? dataStatus : undefined
}
export function GoldenQuestionDialog({
error,
initialValue,
@ -57,7 +68,7 @@ export function GoldenQuestionDialog({
const [tags, setTags] = useState(initialValue.tags.join(', '))
const [questionInvalid, setQuestionInvalid] = useState(false)
const [annotationInvalid, setAnnotationInvalid] = useState(false)
const [matchError, setMatchError] = useState(false)
const [matchError, setMatchError] = useState<'unavailable' | 'unknown'>()
const matchMutation = useMutation(
consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.evidenceMatches.post.mutationOptions(),
)
@ -91,14 +102,14 @@ export function GoldenQuestionDialog({
const findEvidence = async () => {
if (!evidenceText.trim()) return
setMatchError(false)
setMatchError(undefined)
try {
await matchMutation.mutateAsync({
body: { evidence: evidenceText.trim() },
params: { control_space_id: knowledgeSpaceId },
})
} catch {
setMatchError(true)
} catch (error) {
setMatchError(errorStatus(error) === 503 ? 'unavailable' : 'unknown')
}
}
@ -131,7 +142,10 @@ export function GoldenQuestionDialog({
className="h-22 resize-y"
placeholder={t(($) => $['newKnowledge.qualityPage.questionPlaceholder'])}
value={question}
onValueChange={setQuestion}
onValueChange={(value) => {
setQuestion(value)
if (value.trim()) setQuestionInvalid(false)
}}
/>
{questionInvalid && (
<FieldError match className="py-0.5 body-xs-regular text-text-destructive">
@ -149,7 +163,10 @@ export function GoldenQuestionDialog({
className={mode === 'edit' ? 'h-22 min-h-22 resize-y' : 'h-16 min-h-16 resize-y'}
placeholder={t(($) => $['newKnowledge.qualityPage.annotationPlaceholder'])}
value={annotation}
onValueChange={setAnnotation}
onValueChange={(value) => {
setAnnotation(value)
if (value.trim()) setAnnotationInvalid(false)
}}
/>
{annotationInvalid && (
<FieldError match className="py-0.5 body-xs-regular text-text-destructive">
@ -164,7 +181,11 @@ export function GoldenQuestionDialog({
className="h-20 resize-y"
placeholder={t(($) => $['newKnowledge.qualityPage.evidencePlaceholder'])}
value={evidenceText}
onValueChange={setEvidenceText}
onValueChange={(value) => {
setEvidenceText(value)
setMatchError(undefined)
matchMutation.reset()
}}
/>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="system-xs-regular text-text-tertiary">
@ -195,7 +216,13 @@ export function GoldenQuestionDialog({
</Button>
</div>
</div>
{matchError && <FieldError match>{t(($) => $.unknownError)}</FieldError>}
{matchError && (
<FieldError match>
{matchError === 'unavailable'
? t(($) => $['newKnowledge.qualityPage.noEvidenceMatch'])
: t(($) => $.unknownError)}
</FieldError>
)}
{matchMutation.isSuccess && candidates.length === 0 && (
<p className="mt-2 body-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.qualityPage.noEvidenceMatch'])}

View File

@ -1047,19 +1047,15 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
const [composerDraft, setComposerDraft] = useState<ComposerDraft>({ mode: 'fast', query: '' })
const [localRun, setLocalRun] = useState<LocalQueryRun>()
const [localSelected, setLocalSelected] = useState<SelectedRun>()
const selected: SelectedRun | undefined = linkedResearchId
? { id: linkedResearchId, kind: 'research' }
: linkedTraceId
? { id: linkedTraceId, kind: 'trace' }
: localSelected
const selectedHistoryKey =
selected && selected.kind !== 'local' ? `${selected.kind}:${selected.id}` : undefined
const [researchPlans, setResearchPlans] = useState<
Record<string, KnowledgeFsResearchTaskPlanResponse>
>({})
const [researchEvents, setResearchEvents] = useState<Record<string, ResearchTaskProgressEvent[]>>(
{},
)
const [admittedResearchTasks, setAdmittedResearchTasks] = useState<
Record<string, KnowledgeFsResearchTaskResponse>
>({})
const [researchExpanded, setResearchExpanded] = useState<Record<string, boolean>>({})
const [qualityDecisions, setQualityDecisions] = useState<Record<string, QualityDecision>>({})
const [qualityPendingKey, setQualityPendingKey] = useState<string>()
@ -1070,6 +1066,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
taskId: string
}>()
const queryAbortControllerRef = useRef<AbortController>(undefined)
const runInFlightRef = useRef(false)
useEffect(
() => () => {
@ -1088,12 +1085,34 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.researchTasks.get.queryOptions({
input: { params: { control_space_id: knowledgeSpaceId } },
}),
refetchInterval: (current) =>
current.state.data?.data.some((task) => researchTaskIsActive(task)) ? 1000 : false,
refetchInterval: (current) => {
const persistedTasks = current.state.data?.data ?? []
const persistedById = new Map(persistedTasks.map((task) => [task.id, task]))
const admittedTaskIsActive = Object.values(admittedResearchTasks).some((task) => {
const persisted = persistedById.get(task.id)
const effectiveTask =
persisted && persisted.updated_at >= task.updated_at ? persisted : task
return researchTaskIsActive(effectiveTask)
})
return admittedTaskIsActive || persistedTasks.some((task) => researchTaskIsActive(task))
? 1000
: false
},
})
const researchTasks = useMemo(() => {
const byId = new Map(
Object.values(admittedResearchTasks).map((task) => [task.id, task] as const),
)
for (const persisted of researchTasksQuery.data?.data ?? []) {
const admitted = byId.get(persisted.id)
if (!admitted || persisted.updated_at >= admitted.updated_at)
byId.set(persisted.id, persisted)
}
return [...byId.values()]
}, [admittedResearchTasks, researchTasksQuery.data?.data])
const records = useMemo(
() => retrievalTestRecords(tracesQuery.data?.data ?? [], researchTasksQuery.data?.data ?? []),
[researchTasksQuery.data?.data, tracesQuery.data?.data],
() => retrievalTestRecords(tracesQuery.data?.data ?? [], researchTasks),
[researchTasks, tracesQuery.data?.data],
)
const localRecord: RetrievalTestRecord | undefined =
localRun && localRun.status !== 'running'
@ -1113,6 +1132,17 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
records.some((record) => record.kind === 'trace' && record.id === localRun.traceId),
)
const displayRecords = localRecord && !traceAlreadyListed ? [localRecord, ...records] : records
const requestedSelection: SelectedRun | undefined = linkedResearchId
? { id: linkedResearchId, kind: 'research' }
: linkedTraceId
? { id: linkedTraceId, kind: 'trace' }
: localSelected
const newestRecord = displayRecords[0]
const selected: SelectedRun | undefined =
requestedSelection ??
(newestRecord ? { id: newestRecord.id, kind: newestRecord.kind } : undefined)
const selectedHistoryKey =
selected && selected.kind !== 'local' ? `${selected.kind}:${selected.id}` : undefined
const selectedRecord = records.find(
(record) => record.id === selected?.id && record.kind === selected.kind,
)
@ -1130,7 +1160,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
: (selectedHistoryRecord?.mode ?? 'fast')
const selectedResearchTask =
selected?.kind === 'research'
? researchTasksQuery.data?.data.find((task) => task.id === selected.id)
? researchTasks.find((task) => task.id === selected.id)
: undefined
const selectedResearchActive = researchTaskIsActive(selectedResearchTask)
const selectedResearchActiveRef = useRef(selectedResearchActive)
@ -1222,6 +1252,27 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
event,
),
}))
setAdmittedResearchTasks((current) => {
const task = current[selectedResearchTaskId]
if (!task) return current
const eventTime = Date.parse(event.createdAt)
const updatedAt = Number.isFinite(eventTime)
? Math.max(task.updated_at, Math.floor(eventTime / 1000))
: task.updated_at
return {
...current,
[selectedResearchTaskId]: {
...task,
...(event.stage === 'canceled' ||
event.stage === 'completed' ||
event.stage === 'failed'
? { completed_at: updatedAt }
: {}),
stage: event.stage,
updated_at: updatedAt,
},
}
})
const terminal =
event.stage === 'canceled' ||
event.stage === 'completed' ||
@ -1409,7 +1460,8 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
const runFastQuery = async () => {
const cleanQuery = query.trim()
if (!cleanQuery) return
if (!cleanQuery || runInFlightRef.current) return
runInFlightRef.current = true
queryAbortControllerRef.current?.abort()
const controller = new AbortController()
queryAbortControllerRef.current = controller
@ -1494,12 +1546,14 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
} finally {
if (queryAbortControllerRef.current === controller)
queryAbortControllerRef.current = undefined
runInFlightRef.current = false
}
}
const startResearch = async () => {
const cleanQuery = query.trim()
if (!cleanQuery) return
if (!cleanQuery || runInFlightRef.current) return
runInFlightRef.current = true
try {
const plan = await consoleClient.knowledgeFs.spaces.byControlSpaceId.researchTasks.plan.post({
body: { mode: 'research', query: cleanQuery },
@ -1514,6 +1568,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
},
params: { control_space_id: knowledgeSpaceId },
})
setAdmittedResearchTasks((current) => ({ ...current, [task.id]: task }))
setResearchPlans((current) => ({ ...current, [task.id]: plan }))
setResearchExpanded((current) => ({ ...current, [task.id]: true }))
setComposerDraft({
@ -1530,6 +1585,8 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
await researchTasksQuery.refetch()
} catch {
toast.error(t(($) => $['newKnowledge.retrievalTest.failedDescription']))
} finally {
runInFlightRef.current = false
}
}

View File

@ -430,6 +430,7 @@ export function WebsiteCrawlPreview({
const { t } = useTranslation('dataset')
const router = useRouter()
const rootUrlErrorId = useId()
const pageLimitErrorId = useId()
const [rootUrl, setRootUrl] = useState(initialDraft?.rootUrl ?? '')
const [sourceName, setSourceName] = useState(initialDraft?.sourceName ?? '')
const [urlTouched, setUrlTouched] = useState(false)
@ -568,23 +569,26 @@ export function WebsiteCrawlPreview({
}, [])
const normalizedURL = useMemo(() => normalizeWebsiteSourceUrl(rootUrl), [rootUrl])
const normalizedLimit =
typeof pageLimit === 'number'
? Math.min(Math.max(Math.trunc(pageLimit) || 1, 1), MAX_PAGE_LIMIT)
: DEFAULT_PAGE_LIMIT
const pageLimitValid =
typeof pageLimit === 'number' &&
Number.isInteger(pageLimit) &&
pageLimit >= 1 &&
pageLimit <= MAX_PAGE_LIMIT
const configuration = useMemo<CrawlConfiguration | undefined>(
() =>
normalizedURL &&
sourceName.trim() &&
sourceName.trim().length <= NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH
sourceName.trim().length <= NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH &&
typeof pageLimit === 'number' &&
pageLimitValid
? {
includeSubpages,
limit: normalizedLimit,
limit: pageLimit,
name: sourceName.trim(),
url: normalizedURL.toString(),
}
: undefined,
[includeSubpages, normalizedLimit, normalizedURL, sourceName],
[includeSubpages, pageLimit, pageLimitValid, normalizedURL, sourceName],
)
const currentConfigurationKey = configuration ? configurationKey(configuration) : undefined
const previewConfigurationMatches = Boolean(
@ -1354,7 +1358,7 @@ export function WebsiteCrawlPreview({
? $['newKnowledge.booleanTrue']
: $['newKnowledge.booleanFalse'],
)} · ${t(($) => $['newKnowledge.maxPages'])}: ${
pageLimit || DEFAULT_PAGE_LIMIT
pageLimit === '' ? DEFAULT_PAGE_LIMIT : pageLimit
}`}
</span>
)}
@ -1365,35 +1369,44 @@ export function WebsiteCrawlPreview({
<Checkbox checked={includeSubpages} onCheckedChange={setIncludeSubpages} />
{t(($) => $['newKnowledge.includeSubpages'])}
</label>
<div className="flex items-center gap-2">
<span className="system-xs-regular text-text-secondary">
{t(($) => $['newKnowledge.maxPages'])}
</span>
<NumberField
min={1}
max={MAX_PAGE_LIMIT}
value={pageLimit === '' ? null : pageLimit}
onValueChange={(value) =>
setPageLimit(
value === null
? ''
: Math.min(Math.max(Math.trunc(value), 1), MAX_PAGE_LIMIT),
)
}
>
<NumberFieldGroup className="ml-auto w-28">
<NumberFieldInput
aria-label={t(($) => $['newKnowledge.maxPages'])}
onBlur={() => {
if (pageLimit === '') setPageLimit(DEFAULT_PAGE_LIMIT)
}}
/>
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="system-xs-regular text-text-secondary">
{t(($) => $['newKnowledge.maxPages'])}
</span>
<NumberField
allowOutOfRange
min={1}
max={MAX_PAGE_LIMIT}
step={1}
value={pageLimit === '' ? null : pageLimit}
onValueChange={(value) => setPageLimit(value === null ? '' : value)}
>
<NumberFieldGroup className="ml-auto w-28">
<NumberFieldInput
aria-label={t(($) => $['newKnowledge.maxPages'])}
aria-describedby={!pageLimitValid ? pageLimitErrorId : undefined}
aria-invalid={!pageLimitValid}
onBlur={() => {
if (pageLimit === '') setPageLimit(DEFAULT_PAGE_LIMIT)
}}
/>
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
{!pageLimitValid && (
<p
id={pageLimitErrorId}
className="text-right system-xs-regular text-text-destructive"
role="alert"
>
{t(($) => $['newKnowledge.maxPages'])}: 1{MAX_PAGE_LIMIT}
</p>
)}
</div>
</div>
</CollapsiblePanel>

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "نشط",
"newKnowledge.apiAccessInactive": "غير نشط",
"newKnowledge.apiAgentAccess": "الوصول إلى API",
"newKnowledge.apiCredentialDescription": "استخدم Authorization: Bearer kfs_… للمصادقة. بيانات اعتماد KnowledgeFS مخصّصة لمساحة المعرفة هذه ولا يمكن استبدالها بمفاتيح Dataset API القديمة.",
"newKnowledge.appsUnavailable": "— تطبيقات",
"newKnowledge.authKind.api-key": "مفتاح API",
"newKnowledge.authKind.endpoint": "نقطة النهاية",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "يتوفر المزيد من سجل المهام. تابع التحقق للعثور على حالة هذا المستند.",
"newKnowledge.documentUploadExclusion.batchLimit": "تم تجاوز الحد الأقصى لحجم الدفعة",
"newKnowledge.documentUploadExclusion.countLimit": "تم تجاوز الحد الأقصى لعدد الملفات",
"newKnowledge.documentUploadExclusion.fileEmpty": "الملف فارغ",
"newKnowledge.documentUploadExclusion.fileSize": "يتجاوز الحد الأقصى البالغ 15 ميغابايت",
"newKnowledge.documentUploadExclusion.fileType": "نوع الملف غير مدعوم أو غير صالح",
"newKnowledge.documentUploadExclusion.more": "و{{count}} أخرى",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Aktiv",
"newKnowledge.apiAccessInactive": "Inaktiv",
"newKnowledge.apiAgentAccess": "API-Zugriff",
"newKnowledge.apiCredentialDescription": "Authentifizieren Sie sich mit Authorization: Bearer kfs_…. KnowledgeFS-Anmeldedaten sind auf diesen Wissensbereich beschränkt und nicht mit älteren Dataset-API-Schlüsseln austauschbar.",
"newKnowledge.appsUnavailable": "— Apps",
"newKnowledge.authKind.api-key": "API-Schlüssel",
"newKnowledge.authKind.endpoint": "Endpunkt",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Weitere Aufgabenverläufe sind verfügbar. Prüfe weiter, um den Status dieses Dokuments zu finden.",
"newKnowledge.documentUploadExclusion.batchLimit": "Maximale Batchgröße überschritten",
"newKnowledge.documentUploadExclusion.countLimit": "Maximale Dateianzahl überschritten",
"newKnowledge.documentUploadExclusion.fileEmpty": "Die Datei ist leer",
"newKnowledge.documentUploadExclusion.fileSize": "Überschreitet das Limit von 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "Nicht unterstützter oder ungültiger Dateityp",
"newKnowledge.documentUploadExclusion.more": "und {{count}} weitere",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Active",
"newKnowledge.apiAccessInactive": "Inactive",
"newKnowledge.apiAgentAccess": "API Access",
"newKnowledge.apiCredentialDescription": "Authenticate with Authorization: Bearer kfs_…. KnowledgeFS credentials are scoped to this knowledge space and cannot be replaced by legacy Dataset API keys.",
"newKnowledge.appsUnavailable": "— apps",
"newKnowledge.authKind.api-key": "API key",
"newKnowledge.authKind.endpoint": "Endpoint",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "More task history is available. Continue checking to find this document's status.",
"newKnowledge.documentUploadExclusion.batchLimit": "batch size limit exceeded",
"newKnowledge.documentUploadExclusion.countLimit": "file count limit exceeded",
"newKnowledge.documentUploadExclusion.fileEmpty": "File is empty",
"newKnowledge.documentUploadExclusion.fileSize": "Exceeds 15 MB limit",
"newKnowledge.documentUploadExclusion.fileType": "unsupported or invalid file type",
"newKnowledge.documentUploadExclusion.more": "and {{count}} more",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Activo",
"newKnowledge.apiAccessInactive": "Inactivo",
"newKnowledge.apiAgentAccess": "Acceso a la API",
"newKnowledge.apiCredentialDescription": "Autentícate con Authorization: Bearer kfs_…. Las credenciales de KnowledgeFS están limitadas a este espacio de conocimiento y no son intercambiables con las claves antiguas de la API de Dataset.",
"newKnowledge.appsUnavailable": "— aplicaciones",
"newKnowledge.authKind.api-key": "Clave API",
"newKnowledge.authKind.endpoint": "Punto final",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Hay más historial de tareas disponible. Sigue comprobando para encontrar el estado de este documento.",
"newKnowledge.documentUploadExclusion.batchLimit": "se superó el límite de tamaño del lote",
"newKnowledge.documentUploadExclusion.countLimit": "se superó el límite de archivos",
"newKnowledge.documentUploadExclusion.fileEmpty": "El archivo está vacío",
"newKnowledge.documentUploadExclusion.fileSize": "Supera el límite de 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "tipo de archivo no compatible o no válido",
"newKnowledge.documentUploadExclusion.more": "y {{count}} más",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "فعال",
"newKnowledge.apiAccessInactive": "غیرفعال",
"newKnowledge.apiAgentAccess": "دسترسی API",
"newKnowledge.apiCredentialDescription": "برای احراز هویت از Authorization: Bearer kfs_… استفاده کنید. اعتبارنامه‌های KnowledgeFS فقط برای این فضای دانش هستند و با کلیدهای قدیمی Dataset API قابل جایگزینی نیستند.",
"newKnowledge.appsUnavailable": "— برنامه",
"newKnowledge.authKind.api-key": "کلید API",
"newKnowledge.authKind.endpoint": "نقطه پایانی",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "سابقه کار بیشتری موجود است. برای یافتن وضعیت این سند بررسی را ادامه دهید.",
"newKnowledge.documentUploadExclusion.batchLimit": "محدودیت اندازه دسته رد شده است",
"newKnowledge.documentUploadExclusion.countLimit": "محدودیت تعداد فایل رد شده است",
"newKnowledge.documentUploadExclusion.fileEmpty": "فایل خالی است",
"newKnowledge.documentUploadExclusion.fileSize": "از محدودیت ۱۵ مگابایت بیشتر است",
"newKnowledge.documentUploadExclusion.fileType": "نوع فایل پشتیبانی نمی‌شود یا نامعتبر است",
"newKnowledge.documentUploadExclusion.more": "و {{count}} مورد دیگر",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Actif",
"newKnowledge.apiAccessInactive": "Inactif",
"newKnowledge.apiAgentAccess": "Accès à lAPI",
"newKnowledge.apiCredentialDescription": "Authentifiez-vous avec Authorization: Bearer kfs_…. Les identifiants KnowledgeFS sont limités à cet espace de connaissances et ne sont pas interchangeables avec les anciennes clés dAPI Dataset.",
"newKnowledge.appsUnavailable": "— applications",
"newKnowledge.authKind.api-key": "Clé API",
"newKnowledge.authKind.endpoint": "Point de terminaison",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Un historique supplémentaire est disponible. Continuez la vérification pour trouver létat de ce document.",
"newKnowledge.documentUploadExclusion.batchLimit": "limite de taille du lot dépassée",
"newKnowledge.documentUploadExclusion.countLimit": "limite du nombre de fichiers dépassée",
"newKnowledge.documentUploadExclusion.fileEmpty": "Le fichier est vide",
"newKnowledge.documentUploadExclusion.fileSize": "Dépasse la limite de 15 Mo",
"newKnowledge.documentUploadExclusion.fileType": "type de fichier non pris en charge ou non valide",
"newKnowledge.documentUploadExclusion.more": "et {{count}} autres",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "सक्रिय",
"newKnowledge.apiAccessInactive": "निष्क्रिय",
"newKnowledge.apiAgentAccess": "API एक्सेस",
"newKnowledge.apiCredentialDescription": "Authorization: Bearer kfs_… से प्रमाणित करें। KnowledgeFS क्रेडेंशियल केवल इस नॉलेज स्पेस के लिए हैं और पुराने Dataset API keys से बदले नहीं जा सकते।",
"newKnowledge.appsUnavailable": "— ऐप",
"newKnowledge.authKind.api-key": "एपीआई कुंजी",
"newKnowledge.authKind.endpoint": "समापन बिंदु",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "अधिक कार्य इतिहास उपलब्ध है। इस दस्तावेज़ की स्थिति खोजने के लिए जाँच जारी रखें।",
"newKnowledge.documentUploadExclusion.batchLimit": "बैच आकार सीमा पार हो गई",
"newKnowledge.documentUploadExclusion.countLimit": "फ़ाइल संख्या सीमा पार हो गई",
"newKnowledge.documentUploadExclusion.fileEmpty": "फ़ाइल खाली है",
"newKnowledge.documentUploadExclusion.fileSize": "15 MB की सीमा से अधिक है",
"newKnowledge.documentUploadExclusion.fileType": "फ़ाइल प्रकार असमर्थित या अमान्य है",
"newKnowledge.documentUploadExclusion.more": "और {{count}} अन्य",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Aktif",
"newKnowledge.apiAccessInactive": "Tidak aktif",
"newKnowledge.apiAgentAccess": "Akses API",
"newKnowledge.apiCredentialDescription": "Autentikasi dengan Authorization: Bearer kfs_…. Kredensial KnowledgeFS hanya berlaku untuk ruang pengetahuan ini dan tidak dapat ditukar dengan kunci API Dataset lama.",
"newKnowledge.appsUnavailable": "— aplikasi",
"newKnowledge.authKind.api-key": "kunci API",
"newKnowledge.authKind.endpoint": "Titik akhir",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Riwayat tugas lainnya tersedia. Lanjutkan pemeriksaan untuk menemukan status dokumen ini.",
"newKnowledge.documentUploadExclusion.batchLimit": "batas ukuran batch terlampaui",
"newKnowledge.documentUploadExclusion.countLimit": "batas jumlah file terlampaui",
"newKnowledge.documentUploadExclusion.fileEmpty": "File kosong",
"newKnowledge.documentUploadExclusion.fileSize": "Melebihi batas 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "jenis file tidak didukung atau tidak valid",
"newKnowledge.documentUploadExclusion.more": "dan {{count}} lainnya",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Attivo",
"newKnowledge.apiAccessInactive": "Inattivo",
"newKnowledge.apiAgentAccess": "Accesso API",
"newKnowledge.apiCredentialDescription": "Autenticati con Authorization: Bearer kfs_…. Le credenziali KnowledgeFS sono limitate a questo spazio di conoscenza e non sono intercambiabili con le chiavi API Dataset precedenti.",
"newKnowledge.appsUnavailable": "— app",
"newKnowledge.authKind.api-key": "Chiave API",
"newKnowledge.authKind.endpoint": "Punto finale",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "È disponibile altra cronologia delle attività. Continua a controllare per trovare lo stato del documento.",
"newKnowledge.documentUploadExclusion.batchLimit": "limite delle dimensioni del batch superato",
"newKnowledge.documentUploadExclusion.countLimit": "limite del numero di file superato",
"newKnowledge.documentUploadExclusion.fileEmpty": "Il file è vuoto",
"newKnowledge.documentUploadExclusion.fileSize": "Supera il limite di 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "tipo di file non supportato o non valido",
"newKnowledge.documentUploadExclusion.more": "e altri {{count}}",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "有効",
"newKnowledge.apiAccessInactive": "無効",
"newKnowledge.apiAgentAccess": "API アクセス",
"newKnowledge.apiCredentialDescription": "Authorization: Bearer kfs_… で認証します。KnowledgeFS 認証情報はこのナレッジスペース専用であり、従来の Dataset API キーとは互換性がありません。",
"newKnowledge.appsUnavailable": "— 個のアプリ",
"newKnowledge.authKind.api-key": "APIキー",
"newKnowledge.authKind.endpoint": "エンドポイント",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "さらにタスク履歴があります。このドキュメントの状態を確認し続けてください。",
"newKnowledge.documentUploadExclusion.batchLimit": "バッチサイズの上限を超えています",
"newKnowledge.documentUploadExclusion.countLimit": "ファイル数の上限を超えています",
"newKnowledge.documentUploadExclusion.fileEmpty": "ファイルが空です",
"newKnowledge.documentUploadExclusion.fileSize": "15 MB の上限を超えています",
"newKnowledge.documentUploadExclusion.fileType": "サポートされていないか無効なファイル形式です",
"newKnowledge.documentUploadExclusion.more": "ほか {{count}} 件",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "활성",
"newKnowledge.apiAccessInactive": "비활성",
"newKnowledge.apiAgentAccess": "API 액세스",
"newKnowledge.apiCredentialDescription": "Authorization: Bearer kfs_…로 인증하세요. KnowledgeFS 자격 증명은 이 지식 공간에만 적용되며 기존 Dataset API 키와 호환되지 않습니다.",
"newKnowledge.appsUnavailable": "앱 —개",
"newKnowledge.authKind.api-key": "API 키",
"newKnowledge.authKind.endpoint": "종점",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "더 많은 작업 기록이 있습니다. 이 문서의 상태를 찾으려면 계속 확인하세요.",
"newKnowledge.documentUploadExclusion.batchLimit": "배치 크기 제한을 초과했습니다",
"newKnowledge.documentUploadExclusion.countLimit": "파일 수 제한을 초과했습니다",
"newKnowledge.documentUploadExclusion.fileEmpty": "파일이 비어 있습니다",
"newKnowledge.documentUploadExclusion.fileSize": "15MB 제한을 초과했습니다",
"newKnowledge.documentUploadExclusion.fileType": "지원되지 않거나 잘못된 파일 형식입니다",
"newKnowledge.documentUploadExclusion.more": "외 {{count}}개",

View File

@ -141,6 +141,7 @@
"newKnowledge.allDocumentStatuses": "ສະຖານະທັງໝົດ",
"newKnowledge.allSources": "ແຫຼ່ງຂໍ້ມູນທັງຫມົດ",
"newKnowledge.apiAgentAccess": "ການເຂົ້າເຖິງ API ແລະຕົວແທນ",
"newKnowledge.apiCredentialDescription": "ຢືນຢັນຕົວຕົນດ້ວຍ Authorization: Bearer kfs_…. ຂໍ້ມູນຮັບຮອງ KnowledgeFS ໃຊ້ໄດ້ສະເພາະກັບພື້ນທີ່ຄວາມຮູ້ນີ້ ແລະບໍ່ສາມາດໃຊ້ແທນຄີ Dataset API ແບບເກົ່າໄດ້.",
"newKnowledge.appsUnavailable": "— ແອັບ",
"newKnowledge.authKind.api-key": "ລະຫັດ API",
"newKnowledge.authKind.endpoint": "ຈຸດສິ້ນສຸດ",
@ -243,6 +244,7 @@
"newKnowledge.documentTaskLookupIncomplete": "ມີປະຫວັດໜ້າວຽກເພີ່ມເຕີມ. ສືບຕໍ່ກວດສອບເພື່ອຊອກຫາສະຖານະຂອງເອກະສານນີ້.",
"newKnowledge.documentUploadExclusion.batchLimit": "ເກີນຂີດຈຳກັດຂະໜາດຊຸດແລ້ວ",
"newKnowledge.documentUploadExclusion.countLimit": "ເກີນຂີດຈຳກັດຈຳນວນໄຟລ໌ແລ້ວ",
"newKnowledge.documentUploadExclusion.fileEmpty": "ໄຟລ໌ຫວ່າງເປົ່າ",
"newKnowledge.documentUploadExclusion.fileSize": "ໄຟລ໌ໃຫຍ່ເກີນໄປ",
"newKnowledge.documentUploadExclusion.fileType": "ປະເພດໄຟລ໌ທີ່ບໍ່ຮອງຮັບ ຫຼືບໍ່ຖືກຕ້ອງ",
"newKnowledge.documentUploadExclusion.more": "ແລະອີກ {{count}} ອັນ",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Actief",
"newKnowledge.apiAccessInactive": "Inactief",
"newKnowledge.apiAgentAccess": "API-toegang",
"newKnowledge.apiCredentialDescription": "Verifieer met Authorization: Bearer kfs_…. KnowledgeFS-referenties zijn beperkt tot deze kennisruimte en zijn niet uitwisselbaar met oudere Dataset API-sleutels.",
"newKnowledge.appsUnavailable": "— apps",
"newKnowledge.authKind.api-key": "API-sleutel",
"newKnowledge.authKind.endpoint": "Eindpunt",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Er is meer taakgeschiedenis beschikbaar. Blijf controleren om de status van dit document te vinden.",
"newKnowledge.documentUploadExclusion.batchLimit": "limiet voor batchgrootte overschreden",
"newKnowledge.documentUploadExclusion.countLimit": "limiet voor aantal bestanden overschreden",
"newKnowledge.documentUploadExclusion.fileEmpty": "Het bestand is leeg",
"newKnowledge.documentUploadExclusion.fileSize": "Overschrijdt de limiet van 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "niet-ondersteund of ongeldig bestandstype",
"newKnowledge.documentUploadExclusion.more": "en nog {{count}}",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Aktywny",
"newKnowledge.apiAccessInactive": "Nieaktywny",
"newKnowledge.apiAgentAccess": "Dostęp do API",
"newKnowledge.apiCredentialDescription": "Uwierzytelniaj za pomocą Authorization: Bearer kfs_…. Poświadczenia KnowledgeFS są ograniczone do tej przestrzeni wiedzy i nie można ich zastąpić starszymi kluczami API Dataset.",
"newKnowledge.appsUnavailable": "— aplikacji",
"newKnowledge.authKind.api-key": "Klucz API",
"newKnowledge.authKind.endpoint": "Punkt końcowy",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Dostępna jest dalsza historia zadań. Kontynuuj sprawdzanie, aby znaleźć stan tego dokumentu.",
"newKnowledge.documentUploadExclusion.batchLimit": "przekroczono limit rozmiaru partii",
"newKnowledge.documentUploadExclusion.countLimit": "przekroczono limit liczby plików",
"newKnowledge.documentUploadExclusion.fileEmpty": "Plik jest pusty",
"newKnowledge.documentUploadExclusion.fileSize": "Przekracza limit 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "nieobsługiwany lub nieprawidłowy typ pliku",
"newKnowledge.documentUploadExclusion.more": "i jeszcze {{count}}",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Ativo",
"newKnowledge.apiAccessInactive": "Inativo",
"newKnowledge.apiAgentAccess": "Acesso à API",
"newKnowledge.apiCredentialDescription": "Autentique-se com Authorization: Bearer kfs_…. As credenciais do KnowledgeFS são restritas a este espaço de conhecimento e não são intercambiáveis com chaves antigas da API de Dataset.",
"newKnowledge.appsUnavailable": "— apps",
"newKnowledge.authKind.api-key": "Chave de API",
"newKnowledge.authKind.endpoint": "Ponto final",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Há mais histórico de tarefas disponível. Continue verificando para encontrar o status deste documento.",
"newKnowledge.documentUploadExclusion.batchLimit": "limite de tamanho do lote excedido",
"newKnowledge.documentUploadExclusion.countLimit": "limite de quantidade de arquivos excedido",
"newKnowledge.documentUploadExclusion.fileEmpty": "O arquivo está vazio",
"newKnowledge.documentUploadExclusion.fileSize": "Excede o limite de 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "tipo de arquivo não suportado ou inválido",
"newKnowledge.documentUploadExclusion.more": "e mais {{count}}",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Activ",
"newKnowledge.apiAccessInactive": "Inactiv",
"newKnowledge.apiAgentAccess": "Acces API",
"newKnowledge.apiCredentialDescription": "Autentificați-vă cu Authorization: Bearer kfs_…. Credențialele KnowledgeFS sunt limitate la acest spațiu de cunoștințe și nu pot fi înlocuite cu cheile API Dataset vechi.",
"newKnowledge.appsUnavailable": "— aplicații",
"newKnowledge.authKind.api-key": "cheie API",
"newKnowledge.authKind.endpoint": "Punct final",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Este disponibil mai mult istoric al activităților. Continuă verificarea pentru a găsi starea documentului.",
"newKnowledge.documentUploadExclusion.batchLimit": "limita dimensiunii lotului a fost depășită",
"newKnowledge.documentUploadExclusion.countLimit": "limita numărului de fișiere a fost depășită",
"newKnowledge.documentUploadExclusion.fileEmpty": "Fișierul este gol",
"newKnowledge.documentUploadExclusion.fileSize": "Depășește limita de 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "tip de fișier neacceptat sau nevalid",
"newKnowledge.documentUploadExclusion.more": "și încă {{count}}",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Активен",
"newKnowledge.apiAccessInactive": "Неактивен",
"newKnowledge.apiAgentAccess": "Доступ к API",
"newKnowledge.apiCredentialDescription": "Используйте Authorization: Bearer kfs_… для аутентификации. Учетные данные KnowledgeFS действуют только для этого пространства знаний и несовместимы со старыми ключами Dataset API.",
"newKnowledge.appsUnavailable": "— приложений",
"newKnowledge.authKind.api-key": "API-ключ",
"newKnowledge.authKind.endpoint": "Конечная точка",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Доступна дополнительная история задач. Продолжите проверку, чтобы найти статус документа.",
"newKnowledge.documentUploadExclusion.batchLimit": "превышен предел размера пакета",
"newKnowledge.documentUploadExclusion.countLimit": "превышен предел количества файлов",
"newKnowledge.documentUploadExclusion.fileEmpty": "Файл пуст",
"newKnowledge.documentUploadExclusion.fileSize": "Превышает ограничение в 15 МБ",
"newKnowledge.documentUploadExclusion.fileType": "неподдерживаемый или недопустимый тип файла",
"newKnowledge.documentUploadExclusion.more": "и ещё {{count}}",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Aktivno",
"newKnowledge.apiAccessInactive": "Neaktivno",
"newKnowledge.apiAgentAccess": "Dostop do API-ja",
"newKnowledge.apiCredentialDescription": "Preverite pristnost z Authorization: Bearer kfs_…. Poverilnice KnowledgeFS so omejene na ta prostor znanja in niso zamenljive s starejšimi ključi API Dataset.",
"newKnowledge.appsUnavailable": "— aplikacij",
"newKnowledge.authKind.api-key": "API ključ",
"newKnowledge.authKind.endpoint": "Končna točka",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Na voljo je več zgodovine opravil. Nadaljujte preverjanje, da najdete stanje dokumenta.",
"newKnowledge.documentUploadExclusion.batchLimit": "omejitev velikosti paketa je presežena",
"newKnowledge.documentUploadExclusion.countLimit": "omejitev števila datotek je presežena",
"newKnowledge.documentUploadExclusion.fileEmpty": "Datoteka je prazna",
"newKnowledge.documentUploadExclusion.fileSize": "Presega omejitev 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "nepodprta ali neveljavna vrsta datoteke",
"newKnowledge.documentUploadExclusion.more": "in še {{count}}",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "ใช้งานอยู่",
"newKnowledge.apiAccessInactive": "ไม่ได้ใช้งาน",
"newKnowledge.apiAgentAccess": "การเข้าถึง API",
"newKnowledge.apiCredentialDescription": "ยืนยันตัวตนด้วย Authorization: Bearer kfs_… ข้อมูลประจำตัว KnowledgeFS ใช้ได้เฉพาะกับพื้นที่ความรู้นี้และไม่สามารถใช้แทนคีย์ Dataset API แบบเดิมได้",
"newKnowledge.appsUnavailable": "— แอป",
"newKnowledge.authKind.api-key": "คีย์ API",
"newKnowledge.authKind.endpoint": "จุดสิ้นสุด",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "ยังมีประวัติงานเพิ่มเติม โปรดตรวจสอบต่อเพื่อค้นหาสถานะของเอกสารนี้",
"newKnowledge.documentUploadExclusion.batchLimit": "เกินขีดจำกัดขนาดแบตช์",
"newKnowledge.documentUploadExclusion.countLimit": "เกินขีดจำกัดจำนวนไฟล์",
"newKnowledge.documentUploadExclusion.fileEmpty": "ไฟล์ว่างเปล่า",
"newKnowledge.documentUploadExclusion.fileSize": "เกินขีดจำกัด 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "ประเภทไฟล์ไม่รองรับหรือไม่ถูกต้อง",
"newKnowledge.documentUploadExclusion.more": "และอีก {{count}} รายการ",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Etkin",
"newKnowledge.apiAccessInactive": "Etkin değil",
"newKnowledge.apiAgentAccess": "API Erişimi",
"newKnowledge.apiCredentialDescription": "Authorization: Bearer kfs_… ile kimlik doğrulayın. KnowledgeFS kimlik bilgileri bu bilgi alanıyla sınırlıdır ve eski Dataset API anahtarlarıyla değiştirilemez.",
"newKnowledge.appsUnavailable": "— uygulama",
"newKnowledge.authKind.api-key": "API anahtarı",
"newKnowledge.authKind.endpoint": "Uç nokta",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Daha fazla görev geçmişi var. Bu belgenin durumunu bulmak için kontrol etmeye devam edin.",
"newKnowledge.documentUploadExclusion.batchLimit": "toplu iş boyutu sınırııldı",
"newKnowledge.documentUploadExclusion.countLimit": "dosya sayısı sınırııldı",
"newKnowledge.documentUploadExclusion.fileEmpty": "Dosya boş",
"newKnowledge.documentUploadExclusion.fileSize": "15 MB sınırınııyor",
"newKnowledge.documentUploadExclusion.fileType": "desteklenmeyen veya geçersiz dosya türü",
"newKnowledge.documentUploadExclusion.more": "ve {{count}} tane daha",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Активний",
"newKnowledge.apiAccessInactive": "Неактивний",
"newKnowledge.apiAgentAccess": "Доступ до API",
"newKnowledge.apiCredentialDescription": "Автентифікуйтеся за допомогою Authorization: Bearer kfs_…. Облікові дані KnowledgeFS діють лише для цього простору знань і несумісні зі старими ключами Dataset API.",
"newKnowledge.appsUnavailable": "— застосунків",
"newKnowledge.authKind.api-key": "API ключ",
"newKnowledge.authKind.endpoint": "Кінцева точка",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Доступна додаткова історія завдань. Продовжте перевірку, щоб знайти стан документа.",
"newKnowledge.documentUploadExclusion.batchLimit": "перевищено обмеження розміру пакета",
"newKnowledge.documentUploadExclusion.countLimit": "перевищено обмеження кількості файлів",
"newKnowledge.documentUploadExclusion.fileEmpty": "Файл порожній",
"newKnowledge.documentUploadExclusion.fileSize": "Перевищує обмеження 15 МБ",
"newKnowledge.documentUploadExclusion.fileType": "непідтримуваний або недійсний тип файлу",
"newKnowledge.documentUploadExclusion.more": "і ще {{count}}",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "Đang hoạt động",
"newKnowledge.apiAccessInactive": "Không hoạt động",
"newKnowledge.apiAgentAccess": "Quyền truy cập API",
"newKnowledge.apiCredentialDescription": "Xác thực bằng Authorization: Bearer kfs_…. Thông tin xác thực KnowledgeFS chỉ áp dụng cho không gian kiến thức này và không thể thay thế bằng khóa Dataset API cũ.",
"newKnowledge.appsUnavailable": "— ứng dụng",
"newKnowledge.authKind.api-key": "Khóa API",
"newKnowledge.authKind.endpoint": "Điểm cuối",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "Còn thêm lịch sử tác vụ. Hãy tiếp tục kiểm tra để tìm trạng thái của tài liệu này.",
"newKnowledge.documentUploadExclusion.batchLimit": "đã vượt quá giới hạn kích thước lô",
"newKnowledge.documentUploadExclusion.countLimit": "đã vượt quá giới hạn số lượng tệp",
"newKnowledge.documentUploadExclusion.fileEmpty": "Tệp trống",
"newKnowledge.documentUploadExclusion.fileSize": "Vượt quá giới hạn 15 MB",
"newKnowledge.documentUploadExclusion.fileType": "loại tệp không được hỗ trợ hoặc không hợp lệ",
"newKnowledge.documentUploadExclusion.more": "và {{count}} mục khác",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "已启用",
"newKnowledge.apiAccessInactive": "未启用",
"newKnowledge.apiAgentAccess": "API 访问",
"newKnowledge.apiCredentialDescription": "使用 Authorization: Bearer kfs_… 进行认证。KnowledgeFS 凭据仅适用于此知识空间,不能与旧版 Dataset API 密钥互换。",
"newKnowledge.appsUnavailable": "— 个应用",
"newKnowledge.authKind.api-key": "API 密钥",
"newKnowledge.authKind.endpoint": "端点",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "还有更多任务记录,请继续检查以确认此文档的状态。",
"newKnowledge.documentUploadExclusion.batchLimit": "超出批次大小限制",
"newKnowledge.documentUploadExclusion.countLimit": "超出文件数量限制",
"newKnowledge.documentUploadExclusion.fileEmpty": "文件为空",
"newKnowledge.documentUploadExclusion.fileSize": "超过 15 MB 限制",
"newKnowledge.documentUploadExclusion.fileType": "不支持或无效的文件类型",
"newKnowledge.documentUploadExclusion.more": "另有 {{count}} 个",

View File

@ -145,6 +145,7 @@
"newKnowledge.apiAccessActive": "已啟用",
"newKnowledge.apiAccessInactive": "未啟用",
"newKnowledge.apiAgentAccess": "API 存取",
"newKnowledge.apiCredentialDescription": "使用 Authorization: Bearer kfs_… 進行驗證。KnowledgeFS 憑證僅適用於此知識空間,不能與舊版 Dataset API 金鑰互換。",
"newKnowledge.appsUnavailable": "— 個應用程式",
"newKnowledge.authKind.api-key": "API 金鑰",
"newKnowledge.authKind.endpoint": "端點",
@ -250,6 +251,7 @@
"newKnowledge.documentTaskLookupIncomplete": "還有更多任務歷史記錄。繼續檢查以尋找此文件的狀態。",
"newKnowledge.documentUploadExclusion.batchLimit": "超出批次大小限制",
"newKnowledge.documentUploadExclusion.countLimit": "超出檔案數量限制",
"newKnowledge.documentUploadExclusion.fileEmpty": "檔案為空",
"newKnowledge.documentUploadExclusion.fileSize": "超過 15 MB 限制",
"newKnowledge.documentUploadExclusion.fileType": "不支援或無效的檔案類型",
"newKnowledge.documentUploadExclusion.more": "以及另外 {{count}} 個",

View File

@ -135,7 +135,7 @@ export const createExternalAPI = ({
}: {
body: CreateExternalAPIReq
}): Promise<ExternalAPIItem> => {
return post<ExternalAPIItem>('/datasets/external-knowledge-api', { body })
return post<ExternalAPIItem>('/datasets/external-knowledge-api', { body }, { silent: true })
}
export const createExternalKnowledgeBase = ({