mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
Update application components and supporting logic
This commit is contained in:
parent
cb62c52255
commit
14e5afa54d
@ -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")
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"}]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
@ -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(
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -0,0 +1,345 @@
|
||||
# KnowledgeFS 详细功能测试用例
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 通用前置条件
|
||||
|
||||
1. Chrome 已登录 `https://new-rag.dify.dev`。
|
||||
2. 当前用户对 workspace 至少有 owner 权限;权限矩阵用例需额外的 editor 和 viewer 账号。
|
||||
3. 新版入口为 `/datasets?view=new`。
|
||||
4. 破坏性用例仅在 `KF-QA-*` 专用空间执行,不使用业务数据。
|
||||
5. 合成文件上传、临时开启/关闭 QA API Access 已获用户确认;新建持久凭据、撤销密钥和永久删除仍需在执行前单独确认。
|
||||
|
||||
### 测试数据
|
||||
|
||||
- QA 空间:`KF-QA-20260811-066145`
|
||||
- 唯一检索标记:`KF_QA_MARKER_20260811_X9Z`
|
||||
- 网站:`https://example.com`
|
||||
- 本地合成文件:`test-data/` 目录
|
||||
|
||||
### 结果符号
|
||||
|
||||
- `PASS`:本轮实测符合预期
|
||||
- `FAIL`:本轮稳定复现缺陷
|
||||
- `ENV`:流程已执行,但被模型/provider/额度等环境条件阻塞
|
||||
- `ENV-BLOCKED`:已就绪但被浏览器、模型、provider 或服务端依赖阻塞,无法继续验证下游链路
|
||||
- `PENDING-CONFIRM`:涉及新建/撤销持久凭据或永久删除,等待用户在操作前确认
|
||||
- `NOT-RUN`:需额外账号、provider 凭据或大批量数据
|
||||
- `STATIC-RISK`:代码审查发现的候选风险,未当作已确认缺陷
|
||||
|
||||
## A. 入口、路由与导航
|
||||
|
||||
| ID | P | 测试场景/步骤 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-NAV-001 | P0 | 直接访问 `/datasets?view=new` | 进入新版 Knowledge 列表,New 按钮为 pressed | PASS |
|
||||
| TC-NAV-002 | P0 | 在列表点击“旧版”,再切回“新版” | 路由、选中状态和列表内容同步;新版恢复 `view=new` | PASS |
|
||||
| TC-NAV-003 | P0 | 在概览/Sources/Documents/Retrieval/Quality/Settings 刷新 | 深链可独立恢复,不回到列表或旧版 | PASS |
|
||||
| TC-NAV-004 | P0 | 打开合法 space ID 的深链 | 左侧导航、名称、模式和页面内容加载正常 | PASS |
|
||||
| TC-NAV-005 | P0 | 打开不存在的 UUID 空间 | 显示本地化的不存在/无权页,不泄露后端信息 | FAIL,见 KF-BUG-004 |
|
||||
| TC-NAV-006 | P1 | 点击“欢迎使用新版知识库”,关闭后再进入 | 引导弹层可关闭,不阻断页面操作 | PASS |
|
||||
| TC-NAV-007 | P1 | 左侧导航折叠/展开后逐项访问 | 每个链接路由正确,焦点与选中项一致 | PARTIAL,路由已验证 |
|
||||
| TC-NAV-008 | P1 | 390×844 视口访问所有二级导航 | 所有页签有明确滚动/折叠可达性,不遮挡内容 | FAIL-CANDIDATE,见 KF-BUG-012 |
|
||||
| TC-NAV-009 | P1 | 使用 Tab/Shift+Tab/Enter/Escape 访问页签、菜单、抽屉和对话框 | 键盘可完成主流程,焦点不丢失 | NOT-RUN |
|
||||
| TC-NAV-010 | P1 | 用无 new-rag feature flag 的 workspace 访问 | 不显示新版入口或给出明确说明 | NOT-RUN |
|
||||
|
||||
## B. 列表、搜索和筛选
|
||||
|
||||
| ID | P | 测试场景/步骤 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-LI-001 | P0 | 加载有数据的新版列表 | 每张卡显示名称、描述、文档/应用计数、更新时间 | PASS |
|
||||
| TC-LI-002 | P0 | 输入名称子串 `ces` | 只显示 `ceshi` | PASS |
|
||||
| TC-LI-003 | P0 | 输入大写 `CESHI` | 搜索大小写不敏感,显示 `ceshi` | PASS |
|
||||
| TC-LI-004 | P1 | 输入不存在的唯一字符串 | 显示“无搜索结果”及清除条件入口 | FAIL,见 KF-BUG-011 |
|
||||
| TC-LI-005 | P1 | 清空搜索框 | 恢复全部列表 | PASS |
|
||||
| TC-LI-006 | P1 | 打开“创建者”下拉 | 显示成员搜索、列表和重置入口 | PASS |
|
||||
| TC-LI-007 | P1 | 搜索不存在的创建者 | 显示“没有找到创建者” | PASS |
|
||||
| TC-LI-008 | P1 | 选中当前创建者 | 条件显示“创建者: 1”,结果与成员匹配 | PASS |
|
||||
| TC-LI-009 | P1 | 重置创建者筛选 | 标签和列表恢复 | PASS |
|
||||
| TC-LI-010 | P1 | 创建新空间后回列表 | 新空间出现在列表前部,描述和计数正确 | PASS |
|
||||
| TC-LI-011 | P1 | 点击“标签” | 如后端未提供则给出明确不可用提示 | PASS,提示 KnowledgeFS 待提供 metadata |
|
||||
| TC-LI-012 | P1 | 点击“外部知识库 API” | 打开管理抽屉,无凭据泄露 | PASS |
|
||||
| TC-LI-013 | P1 | 点击“服务 API” | 显示 endpoint 和 key 管理入口 | PASS |
|
||||
| TC-LI-014 | P1 | 超过一页数据时搜索只在末页出现的名称 | 搜索全数据集,不只过滤已加载卡片 | STATIC-RISK,数据不足未实证 |
|
||||
| TC-LI-015 | P2 | 连续快速改变搜索和创建者条件 | 旧请求不覆盖新请求,无闪回 | NOT-RUN |
|
||||
|
||||
## C. 创建知识库
|
||||
|
||||
| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-CR-001 | P0 | 点击“创建” | 打开创建对话框,默认从空白开始/only_me | PASS |
|
||||
| TC-CR-002 | P0 | 名称留空 | 创建按钮禁用或显示必填错误 | PASS |
|
||||
| TC-CR-003 | P0 | 名称仅空格 | 视为空值,禁止创建 | PASS |
|
||||
| TC-CR-004 | P0 | 名称 1 字 | 创建成功 | NOT-RUN |
|
||||
| TC-CR-005 | P0 | 名称精确 40 字 | 允许提交并保存成功 | PASS,在 QA Settings 验证同约束 |
|
||||
| TC-CR-006 | P0 | 名称 41 字 | 字段级拦截,不请求后端 | FAIL,见 KF-BUG-002 |
|
||||
| TC-CR-007 | P0 | 描述精确 2000 字 | 允许保存 | PASS,在 QA Settings 实测 |
|
||||
| TC-CR-008 | P0 | 描述 2001 字 | 字段级拦截,不请求后端 | FAIL,Settings 同约束见 KF-BUG-003 |
|
||||
| TC-CR-009 | P0 | 从空白开始 + 合法名称 | 只创建 1 个空间,202/provisioning 后进入 active Sources | PASS |
|
||||
| TC-CR-010 | P0 | 在加载中连续双击“创建知识库” | 幂等,只创建 1 个空间 | NOT-RUN |
|
||||
| TC-CR-011 | P0 | 提交后制造超时,再点重试 | 通过幂等 key 收敛到同一 space | NOT-RUN |
|
||||
| TC-CR-012 | P1 | 可见权限选择 only_me | 只有 owner 可见,创建成功 | PASS |
|
||||
| TC-CR-013 | P1 | 可见权限选择 all_team_members | 所有有效成员可以 viewer 身份查看 | NOT-RUN,缺多用户 |
|
||||
| TC-CR-014 | P1 | 选择 partial_members 但不选成员 | 显示“至少一名成员”并禁止保存 | PASS,Settings 实测 |
|
||||
| TC-CR-015 | P1 | 连接数据源方式创建 | 创建空间后进入 provider 配置,取消不留垃圾数据 | NOT-RUN |
|
||||
| TC-CR-016 | P1 | 上传文件方式创建 | 创建与 staged upload claim 原子收敛,不重复文档 | ENV-BLOCKED,Documents 入口已证实 staging 服务异常,本轮未再创建空间 |
|
||||
| TC-CR-017 | P1 | 模型配置 pending/fail/retry | 显示可解释状态和重试,不重复创建 | NOT-RUN |
|
||||
| TC-CR-018 | P1 | 同 slug 不同创建意图/幂等载荷 | 返回可理解 409/4xx,不出现 500 | STATIC-RISK |
|
||||
|
||||
## D. 数据源与网站抓取
|
||||
|
||||
| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-SO-001 | P0 | 空间打开 Sources | 显示空状态、常用 provider 和“添加数据源” | PASS |
|
||||
| TC-SO-002 | P0 | 打开添加数据源 | 显示 Website/Online Documents/Online Drive 三类 | PASS |
|
||||
| TC-SO-003 | P0 | 检查 provider 清单 | Firecrawl/Jina/WaterCrawl、Notion/Google Docs/Confluence、Drive/OneDrive/S3 入口正确 | PASS |
|
||||
| TC-SO-004 | P0 | Website 根 URL 输入 `not-a-url` | 提示必须为 http(s),抓取按钮禁用 | PASS |
|
||||
| TC-SO-005 | P0 | Website 根 URL 输入 `https://example.com` | URL 校验通过,名称有效后可抓取 | PASS |
|
||||
| TC-SO-006 | P0 | 最大页数输入 0 | 提示最小为 1,禁止抓取 | FAIL,可见 0/实际 1 |
|
||||
| TC-SO-007 | P0 | 最大页数输入 1 | 允许抓取 1 页 | PASS |
|
||||
| TC-SO-008 | P0 | 最大页数输入 1.5 | 提示只能是整数,禁止抓取 | FAIL,可见 1.5/实际 1 |
|
||||
| TC-SO-009 | P0 | 最大页数输入 200 | 允许 | PASS |
|
||||
| TC-SO-010 | P0 | 最大页数输入 1001 | 提示最大 200,禁止抓取 | FAIL,可见 1001/实际 200 |
|
||||
| TC-SO-011 | P0 | Firecrawl 连接状态 | 显示已连接时才允许预览 | PASS |
|
||||
| TC-SO-012 | P0 | Firecrawl 对 example.com 抓取预览 | 在可接受时间内返回页列表或明确失败 | ENV,36+ 秒仍 0 页 |
|
||||
| TC-SO-013 | P0 | 抓取进行中点击 Stop | 进入 stopping,最终 stopped,表单可再操作 | PASS |
|
||||
| TC-SO-014 | P0 | stopped 后点 Retry | 仅新建 1 个后续工作流,进度重置 | NOT-RUN,只检查入口 |
|
||||
| TC-SO-015 | P0 | 抓取返回页面后全选/反选/单选 | 选中数量、最多 200 和添加按钮状态正确 | ENV |
|
||||
| TC-SO-016 | P0 | 选择页面后“添加数据源” | 创建 source,文档进入解析/索引,不留临时 workflow | ENV |
|
||||
| TC-SO-017 | P1 | 有草稿时点击取消/离开 | 弹出“放弃数据源更改”确认 | PASS |
|
||||
| TC-SO-018 | P1 | 在确认对话框点“取消” | 草稿保留,返回配置页 | PASS |
|
||||
| TC-SO-019 | P1 | 在确认对话框点“放弃草稿” | 临时配置/预览被清理,列表无 source | PENDING-CONFIRM |
|
||||
| TC-SO-020 | P1 | 选 Jina Reader | 未安装时明确显示安装入口 | PASS,环境显示未安装 |
|
||||
| TC-SO-021 | P1 | 选 WaterCrawl 并验证凭据 | 未安装/未配置时给出可操作的状态 | NOT-RUN |
|
||||
| TC-SO-022 | P1 | 同时提供 connectionId 和 credentials | 请求被 422 拒绝,不创建 source | NOT-RUN |
|
||||
| TC-SO-023 | P1 | Source PATCH 为空 payload | 拒绝无效操作,无 revision 变化 | NOT-RUN |
|
||||
| TC-SO-024 | P1 | sync policy custom interval = 3599/3600/2592000/2592001 | 只允许 3600–2592000 秒 | NOT-RUN |
|
||||
| TC-SO-025 | P1 | manual mode 同时提交 customInterval | 拒绝矛盾组合,不静默保留无效值 | STATIC-RISK |
|
||||
| TC-SO-026 | P1 | stale expectedRevision/sourceVersion 保存数据源 | 返回 409,UI 提示刷新后重试 | NOT-RUN |
|
||||
| TC-SO-027 | P1 | 删除 source 选 `documents=keep` | source 删除,文档和检索仍可用 | PENDING-CONFIRM |
|
||||
| TC-SO-028 | P1 | 删除 source 选 `documents=cascade` | source 和所属文档按 durable job 完成清理 | PENDING-CONFIRM |
|
||||
|
||||
## E. 文档上传、列表与任务
|
||||
|
||||
| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-DO-001 | P0 | 空知识库打开 Documents | 显示“暂无文档”和上传入口 | PASS |
|
||||
| TC-DO-002 | P0 | 点击“添加文档” | 打开上传表单,显示支持格式和 15 MB 限制 | PASS |
|
||||
| TC-DO-003 | P0 | 上传 TXT/MD/CSV/JSONL/HTML/PDF/DOCX/XLSX 小文件 | staged upload 成功,claim 一次,文档进入 processing→ready | FAIL,3 个合法 TXT/MD 均显示 `Internal Server Error`,0 文档 |
|
||||
| TC-DO-004 | P0 | 上传 Unicode 文件名和中文内容 | 文件名、内容、分块和下载名不乱码 | FAIL-BLOCKED,中文文件名本地显示正常,但 staging 500,无法验证解析 |
|
||||
| TC-DO-005 | P0 | 上传空文件 | 返回 422,UI 明确说明空文件,无空文档 | FAIL,0 B 被计为有效并启用提交,后端 422 仅显示英文通用错误 |
|
||||
| TC-DO-006 | P0 | 上传不支持 `.exe` | 客户端或服务端拒绝,无 staged upload 残留 | PASS,本地显示“不支持或无效的文件类型”,0/1 有效且提交禁用 |
|
||||
| TC-DO-007 | P0 | 上传精确 15 MiB 文件 | 根据合同允许边界值,不会 413 | PARTIAL/FAIL,前端正确接收,但 43+ 秒停留“正在上传…”;取消后无文档 |
|
||||
| TC-DO-008 | P0 | 上传 15 MiB + 1 byte | 返回 413,UI 提示超限,无计费/索引任务 | PASS,本地标记“超过 15 MB 限制”,0/1 有效且未 staging |
|
||||
| TC-DO-009 | P0 | 同时上传合法、空、超限和不支持文件 | 每个文件独立结果,部分失败不丢成功项,可重试失败项 | PARTIAL/FAIL,合法文件逐个报错并另有汇总错误;未能进入 claim 部分成功 |
|
||||
| TC-DO-010 | P0 | 对同一 staged upload 网络重试 | 同 upload ID 仅被 claim 一次,无重复文档 | ENV-BLOCKED,staging 未成功返回 upload ID |
|
||||
| TC-DO-011 | P0 | claim 前 abort staged upload | 上传被废弃,不可再 claim | PARTIAL,取消 15 MiB in-flight 上传后表单关闭且 0 文档,但请求随后仍弹失败提示 |
|
||||
| TC-DO-012 | P0 | claim 后再 abort | 拒绝废弃,已创建文档状态不被破坏 | ENV-BLOCKED,无可 claim 的 upload ID |
|
||||
| TC-DO-013 | P0 | 不同用户或不同 space 试图 claim upload | 统一返回无权/不存在,不能跨用户移动文件 | NOT-RUN |
|
||||
| TC-DO-014 | P1 | 上传同内容不同文件名 | 产品按规则创建两个 logical document 或明确去重,不静默丢失 | ENV-BLOCKED,两个文件均 staging 500,未创建 logical document |
|
||||
| TC-DO-015 | P0 | 现有文档列表加载 | 显示名称、来源、状态、修订、更新时间 | PASS,7 条 |
|
||||
| TC-DO-016 | P0 | 状态筛选 Ready | 只显示 ready 文档 | PASS,4 条 |
|
||||
| TC-DO-017 | P0 | 状态筛选 Failed | 只显示 failed 文档 | PASS,3 条 |
|
||||
| TC-DO-018 | P1 | 文档名大小写不敏感搜索 | 返回匹配项,清空后恢复 | PASS |
|
||||
| TC-DO-019 | P1 | 搜索无匹配 | 显示明确无结果空状态 | PASS |
|
||||
| TC-DO-020 | P0 | 打开 Tasks 抽屉 | 显示需关注/失败任务、原因和可用操作 | PASS,5 条失败/中断 |
|
||||
| TC-DO-021 | P0 | 对失败任务点 Retry | 只创建一个 retry attempt,状态可跟踪 | PENDING-CONFIRM,不重试既有业务文档 |
|
||||
| TC-DO-022 | P1 | 对进行中任务 Cancel | 任务进入 canceling/canceled,无半索引残留 | PENDING-CONFIRM |
|
||||
| TC-DO-023 | P1 | 任务 SSE 断网再连 | 续接或 polling 收敛,不倒退、不重复任务 | NOT-RUN |
|
||||
| TC-DO-024 | P1 | 打开 Metadata schema 对话框 | 显示现有 schema;能力未开放时按钮禁用且有说明 | PASS,Add Metadata 禁用 |
|
||||
| TC-DO-025 | P1 | 勾选一个文档 | 显示批量操作条和正确选中计数 | PASS |
|
||||
| TC-DO-026 | P1 | 选中文档后查看 Reindex/Download/Delete 状态 | 按权限和实现能力准确启用/禁用 | PASS,Reindex 可用,Download/Delete 禁用 |
|
||||
| TC-DO-027 | P0 | 批量 Reindex 选择 1/1000/1001 文档 | 只允许 1–1000;单一 durable job,不重复 | PENDING-CONFIRM |
|
||||
| TC-DO-028 | P0 | Reindex payload 同时包含 `all=true` 和 documentIds | 返回 422,不运行任务 | NOT-RUN |
|
||||
| TC-DO-029 | P0 | 批量删除含存在/不存在 ID | 事件结果可追踪,幂等重放不多删 | PENDING-CONFIRM |
|
||||
| TC-DO-030 | P1 | 行菜单打开 | 根据状态显示 rename/reindex/disable/archive/download/delete | PASS |
|
||||
| TC-DO-031 | P1 | Rename 留空 | 保存禁用或字段必填 | PASS |
|
||||
| TC-DO-032 | P1 | Rename 输入 304 字 | 按后端合同限制,超限时前端拦截 | FAIL-CANDIDATE,保存仍可用,未提交 |
|
||||
| TC-DO-033 | P1 | Ready/Failed/Processing 文档的禁用、归档、下载按钮 | 按状态机正确启用,禁用项有解释 | PARTIAL,已检查当前状态 |
|
||||
| TC-DO-034 | P1 | 两标签页并发 metadata PATCH,使用 stale rowVersion | 第二个返回 409,UI 刷新后可重试 | NOT-RUN |
|
||||
| TC-DO-035 | P0 | 删除文档时重复使用同 Idempotency-Key/不同 payload | 同 payload 重放同结果,不同 payload 返回冲突 | PENDING-CONFIRM |
|
||||
|
||||
## F. 文档详情、修订、分块与 Metadata
|
||||
|
||||
| ID | P | 测试场景/步骤 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-DD-001 | P0 | 打开 Ready 文档详情 | 标题、内容、来源、状态正常加载,无错误 toast | FAIL,内容成功但 3 条 404 toast |
|
||||
| TC-DD-002 | P0 | 详情页不展示未授权原始内容 | viewer 仅读且内容遵循 workspace 隔离 | NOT-RUN |
|
||||
| TC-DD-003 | P1 | 点击 Metadata “编辑” | 打开编辑器或明确提示不支持,按钮可恢复 | FAIL,见 KF-BUG-007 |
|
||||
| TC-DD-004 | P1 | 查看 revisions 列表 | 按时间倒序,active revision 明确标识 | PENDING-CONFIRM,需自有文档 |
|
||||
| TC-DD-005 | P1 | 切换不同 revision | 内容、chunk 和 outline 与修订一致,无版本竞态 | PENDING-CONFIRM |
|
||||
| TC-DD-006 | P1 | 查看 revision chunks | 分页/游标无重复丢失,chunk 内容与解析结果一致 | PENDING-CONFIRM |
|
||||
| TC-DD-007 | P1 | 打开单个 chunk 详情 | 显示标识、位置、内容和 metadata,新版不提供编辑假入口 | PENDING-CONFIRM |
|
||||
| TC-DD-008 | P1 | 查看 outline | 层级顺序与原文档匹配,空 outline 有空状态 | PENDING-CONFIRM |
|
||||
| TC-DD-009 | P1 | schema 名称 1/255/256 字及重名 | 允许 1–255,拒绝 256/重名/内建名 | PENDING-CONFIRM |
|
||||
| TC-DD-010 | P1 | schema type 为 string/number/time | 每种类型保存和文档值校验正确 | PENDING-CONFIRM |
|
||||
| TC-DD-011 | P1 | stale schema expectedRowVersion 更新/删除 | 返回 409,不静默覆盖 | PENDING-CONFIRM |
|
||||
| TC-DD-012 | P1 | metadata value 与 schema type 不匹配 | 字段级拒绝,其他值不受影响 | PENDING-CONFIRM |
|
||||
|
||||
## G. 概览与可观测性
|
||||
|
||||
| ID | P | 测试场景/步骤 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-OV-001 | P0 | 新建空间打开 Overview | 显示添加第一个数据源 onboarding,指标为— | PASS |
|
||||
| TC-OV-002 | P0 | 有数据空间查看 24h/30d 指标 | 时间范围切换后查询数、回答率和对比值一致 | PASS,24h 0,30d 11/100% |
|
||||
| TC-OV-003 | P1 | 需要关注列表分页 | 前后翻页稳定,不重复丢失 | PASS,2 页 |
|
||||
| TC-OV-004 | P1 | 打开最近活动抽屉 | Today/30d 和活动事件显示正常 | PASS |
|
||||
| TC-OV-005 | P1 | 最近活动按 operator 筛选 | 只显示对应 actor/System 事件 | PASS |
|
||||
| TC-OV-006 | P1 | 资产概况文档/实体/关系/覆盖数 | 与后端 inventory 一致 | PASS,4/398/380/104 |
|
||||
| TC-OV-007 | P1 | Documents 列表包含 failed 文档时核对 Overview “文档” | 标签说明是 total 还是 ready,各页口径一致 | AMBIGUOUS,Overview=4,列表/卡片=7 |
|
||||
| TC-OV-008 | P1 | 新上传文档完成后不刷新查看指标 | 按设计实时/轮询更新,不长期过期 | PENDING-CONFIRM |
|
||||
| TC-OV-009 | P1 | 删除文档/source/space 后核对 stats | durable job 完成后计数无残留 | PENDING-CONFIRM |
|
||||
|
||||
## H. Fast / Deep / Research 检索
|
||||
|
||||
| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-RE-001 | P0 | 空库打开 Retrieval | 显示输入框、三种模式和空记录状态 | PASS |
|
||||
| TC-RE-002 | P0 | 问题留空或仅空格 | Start 禁用 | PASS |
|
||||
| TC-RE-003 | P0 | 问题精确 2000 字 | UI 允许,后端 query 长度约束不冲突 | PARTIAL,已核对 maxLength=2000 |
|
||||
| TC-RE-004 | P0 | 问题 2001 字 | UI 拦截/字段错误,不静默截断 | NOT-RUN |
|
||||
| TC-RE-005 | P0 | 单次 Fast Start | 只产生 1 个 admission、1 个 SSE 任务、1 条记录 | PASS,QA 空库 813 ms 返回 1 条记录、0 分段 |
|
||||
| TC-RE-006 | P0 | 单次 Fast Retry | 只生成 1 个新 attempt,与原 trace 关联 | FAIL,生成 2 条,KF-BUG-001 |
|
||||
| TC-RE-007 | P0 | 单次 Deep Start | 只产生 1 个任务和 1 条记录 | FAIL,生成 2 条,KF-BUG-001 |
|
||||
| TC-RE-008 | P0 | 单次 Research Start | 只产生 1 个 research task,阶段流式更新 | FAIL/PARTIAL,既有库可启动;QA 空库单击后 27+ 秒无任务、阶段或错误,见 KF-BUG-019 |
|
||||
| TC-RE-009 | P0 | Fast 对唯一 marker 检索 | 答案包含 marker,citation 定位到正确 chunk | PENDING-CONFIRM,需上传并有额度 |
|
||||
| TC-RE-010 | P0 | Deep 对同一 marker 检索 | 深度检索只生成一个结果,引用可回溯 | PENDING-CONFIRM |
|
||||
| TC-RE-011 | P0 | Research 对多文档问题 | 计划→检索→分析→结果完整,证据与冲突可查 | PENDING-CONFIRM |
|
||||
| TC-RE-012 | P0 | 无结果查询 | 明确“无充分证据”,不编造引用 | PASS,QA 空库 Fast/Deep 明确“未召回分段”,0 引用 |
|
||||
| TC-RE-013 | P0 | Fast/Deep 正常查询 | 流式完成且无错误,记录状态与答案一致 | PARTIAL,QA 空库正常 0 结果;既有库受模型额度阻塞 |
|
||||
| TC-RE-014 | P1 | Research 无召回 | 阶段和最终错误一致,可重试 | FAIL/PARTIAL,QA 空库静默回到 idle;既有库显示检索失败/未召回 |
|
||||
| TC-RE-015 | P1 | 有历史记录时加载 Retrieval | 不再显示“暂无测试记录” | FAIL,见 KF-BUG-010 |
|
||||
| TC-RE-016 | P1 | SSE 进行中断网、恢复网络 | 续接原 trace,不重复计费/任务 | NOT-RUN |
|
||||
| TC-RE-017 | P1 | 进行中取消查询 | 服务端停止,记录为 canceled,不持续消耗额度 | NOT-RUN |
|
||||
| TC-RE-018 | P1 | activeDocumentIds 100/101 | 允许 100,拒绝 101,UI 显示限制 | NOT-RUN |
|
||||
| TC-RE-019 | P1 | query images 1/4/5,重复 UUID | 最多 4,重复被去重或拒绝,5 张拒绝 | NOT-RUN |
|
||||
| TC-RE-020 | P1 | 图片 10 MB 边界、总 32 MB 边界、伪装 MIME | 仅 gif/jpeg/png/webp,严格限制单张/总大小 | NOT-RUN |
|
||||
| TC-RE-021 | P1 | 引用点击/键盘激活 | 定位正确文档、revision 和 chunk,焦点可返回 | PENDING-CONFIRM |
|
||||
| TC-RE-022 | P1 | Auto 模式通过 App/Workflow Knowledge Retrieval v2 运行 | 按 Settings 自动选 Fast/Deep/Research,运行和发布一致 | NOT-RUN |
|
||||
| TC-RE-023 | P1 | 用废弃 `/queries` 路由 | UI 不调用该路由;服务返回明确 503/deprecated | STATIC-VERIFIED |
|
||||
|
||||
## I. Quality:参考问题、问题案例与 Replay
|
||||
|
||||
| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-QA-001 | P0 | 空空间打开 Quality | 参考问题 0、问题案例 0,空状态正确 | PASS |
|
||||
| TC-QA-002 | P0 | 切换参考问题/问题案例 | tab 选中和 `?tab=bad-cases` 路由一致 | PASS |
|
||||
| TC-QA-003 | P0 | 创建问题与备注留空 | 在字段下显示必填错误,不创建记录 | PASS |
|
||||
| TC-QA-004 | P0 | 触发必填错误后输入合法问题/备注 | 错误和红色边框立即清除 | FAIL,见 KF-BUG-008 |
|
||||
| TC-QA-005 | P0 | 合法问题+备注,不选证据直接保存 | 创建 1 条 draft,列表计数+1 | PASS |
|
||||
| TC-QA-006 | P0 | 编辑已有 draft 备注并保存 | 列表内容和更新时间同步 | PASS |
|
||||
| TC-QA-007 | P1 | 勾选一条参考问题 | 显示“已选 1”、删除入口和清除选择 | PASS |
|
||||
| TC-QA-008 | P1 | 有效证据文本后点“查找证据” | 返回候选 chunk 或明确无匹配,不报未知错误 | FAIL/ENV,当前为“未知错误” |
|
||||
| TC-QA-009 | P0 | 导入合法 CSV(question/evidence/tags) | 显示行级预览和导入结果,未匹配证据保存草稿 | FAIL,2 行预览正确;提交后仅显示“未知错误”,刷新仍为 1 条、无部分成功 |
|
||||
| TC-QA-010 | P0 | CSV 使用中文表头、quoted commas/tags | 正确解析,不错列/乱码 | PARTIAL,英文表头与 quoted tags 正确预览;中文表头未执行 |
|
||||
| TC-QA-011 | P0 | CSV 500/501 行 | 允许 500,拒绝 501,告知上限 | NOT-RUN,基础 2 行导入已失败 |
|
||||
| TC-QA-012 | P0 | CSV 精确 1 MiB/1 MiB+1 | 允许边界,超限拒绝且无部分落库 | NOT-RUN,基础 2 行导入已失败 |
|
||||
| TC-QA-013 | P1 | CSV 包含合法与非法行 | 行级结果清晰,可重试失败行,不重复成功行 | NOT-RUN,基础 2 行导入已失败 |
|
||||
| TC-QA-014 | P1 | 执行 replay run | 重测每个问题,显示通过/失败/无证据和可追溯 trace | PENDING-CONFIRM |
|
||||
| TC-QA-015 | P1 | replay 进行中断网/刷新 | 状态可恢复,不重复执行同一问题 | NOT-RUN |
|
||||
| TC-QA-016 | P1 | 问题案例从真实查询转入 | 保留 query/trace/evidence 关联,列表计数及时更新 | NOT-RUN |
|
||||
| TC-QA-017 | P1 | 删除单个/批量参考问题 | 仅删除选中项,重复确认不多删 | PENDING-CONFIRM |
|
||||
|
||||
## J. Settings:基础信息、可见性与检索参数
|
||||
|
||||
| ID | P | 测试场景/输入 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-ST-001 | P0 | 打开 Settings | 基础信息、API、模型、检索和危险操作加载正常 | PASS |
|
||||
| TC-ST-002 | P0 | 名称留空/仅空格 | 显示必填错误,Save 禁用 | PASS |
|
||||
| TC-ST-003 | P0 | 名称精确 40 字保存 | 保存成功,侧边栏/列表同步 | PASS,后已恢复 |
|
||||
| TC-ST-004 | P0 | 名称 41 字 | 前端拦截和字段提示 | FAIL,创建页已证实 |
|
||||
| TC-ST-005 | P0 | 描述精确 2000 字 | 保存成功 | PASS,后已恢复 |
|
||||
| TC-ST-006 | P0 | 描述 2001 字 | 前端拦截,显示字段错误 | FAIL,见 KF-BUG-003 |
|
||||
| TC-ST-007 | P0 | 合法名称/描述保存后刷新 | 持久值正确,Save 回到禁用 | PASS |
|
||||
| TC-ST-008 | P1 | 改动后 Cancel | 恢复服务端值,不发 PATCH | PASS,可见性试改 |
|
||||
| TC-ST-009 | P0 | 可见性 only_me/all/partial 下拉 | 三种选项齐全,与隐藏值一致 | PASS |
|
||||
| TC-ST-010 | P0 | partial 不选任何成员 | 显示至少一人,禁止保存 | PASS |
|
||||
| TC-ST-011 | P1 | partial 添加/移除成员并保存 | 成员权限和 auth epoch 正确更新 | NOT-RUN,缺多用户 |
|
||||
| TC-ST-012 | P1 | API Access 开关 on/off | 立即保存或明确告知自动保存,侧边栏同步 | PASS,已恢复 off |
|
||||
| TC-ST-013 | P0 | 系统推理模型下拉 | 只显示可用模型,缺失/失效时可解释 | PARTIAL,当前 gpt-5.6 |
|
||||
| TC-ST-014 | P0 | Embedding 模型下拉 | 只显示可用 embedding,Fast/Deep 不允许缺失 | PARTIAL,当前 text-embedding-3-large |
|
||||
| TC-ST-015 | P0 | 同时修改 embedding 与 retrieval settings | 产品要求拆分操作,返回明确 422 而非部分保存 | NOT-RUN |
|
||||
| TC-ST-016 | P0 | embedding migration queued/running/succeeded/failed/canceled | 进度、checkpoint 和恢复入口清晰,激活前不破坏旧索引 | NOT-RUN |
|
||||
| TC-ST-017 | P0 | 启用 Rerank 但不选模型 | 禁止保存并提示必选模型 | NOT-RUN |
|
||||
| TC-ST-018 | P0 | Rerank 关闭时尝试启用 Fast/Deep Score threshold | threshold 禁用,规则清晰 | PASS,当前禁用 |
|
||||
| TC-ST-019 | P0 | Retrieval mode Fast→Deep→Fast | 每次只保存一次,选中状态持久 | PASS,已恢复 Fast |
|
||||
| TC-ST-020 | P0 | Retrieval mode Research | 模式保存并使 Retrieval 按钮语义变为开始研究 | PASS,在 Retrieval 已验证 |
|
||||
| TC-ST-021 | P0 | Top K = 0 | 拦截或明确 clamp 到 1,显示值与保存值一致 | PASS,显示和 slider 均为 1 |
|
||||
| TC-ST-022 | P0 | Top K = 1/10 | 允许边界值,持久后一致 | PASS |
|
||||
| TC-ST-023 | P0 | Top K = 11 | 拦截或明确 clamp 到 10,显示值与保存值一致 | PASS,显示和 slider 均为 10 |
|
||||
| TC-ST-024 | P0 | threshold = -0.01/0/1/1.01 | 只允许 0–1,保存值与显示一致 | NOT-RUN,rerank 未配置 |
|
||||
| TC-ST-025 | P0 | 两标签页使用相同 expectedRevision 并发保存 | 第二次 409,给出刷新/合并引导,不静默覆盖 | NOT-RUN |
|
||||
| TC-ST-026 | P1 | Save 返回 422/409/503 | 区分字段错误、冲突和服务不可用,不统一报网络 | FAIL,422 被误报网络 |
|
||||
| TC-ST-027 | P0 | 点击删除空间 | 必须二次确认,完成后立即从列表隐藏 | PENDING-CONFIRM |
|
||||
|
||||
## K. 权限、成员、API 和应用绑定
|
||||
|
||||
| ID | P | 测试场景/角色 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-AU-001 | P0 | owner 访问列表/详情/上传/设置/成员/API/删除 | 所有功能可用 | PARTIAL,owner 主流程通过 |
|
||||
| TC-AU-002 | P0 | editor 访问读/编辑/文档写/检索 | 允许;成员/API key/删库按合同禁止 | NOT-RUN |
|
||||
| TC-AU-003 | P0 | viewer 访问读/检索 | 只读和 query 允许;所有写操作隐藏/禁止 | NOT-RUN |
|
||||
| TC-AU-004 | P0 | only_me 下非 owner 访问深链 | 与不存在资源统一为 404 | NOT-RUN |
|
||||
| TC-AU-005 | P0 | all_team_members 的未显式成员 | 以 viewer 访问,不获得编辑权 | NOT-RUN |
|
||||
| TC-AU-006 | P0 | partial_members 的非选中成员 | 列表不可见,深链 404 | NOT-RUN |
|
||||
| TC-AU-007 | P0 | RBAC 查询异常 | fail closed,不使用本地角色绕过 | NOT-RUN |
|
||||
| TC-AU-008 | P0 | 成员被移除/可见性收窄时仍打开详情 | 页面下一次请求立即失效,旧 SSE/token 不能继续 | NOT-RUN |
|
||||
| TC-AU-009 | P0 | 用旧 API credential 在撤权后访问 | 立即 401/403/404,不等 TTL 过期 | NOT-RUN |
|
||||
| TC-AU-010 | P0 | External API endpoint 为 `not-a-url` | 只显示一条本地化字段错误,不保存 | FAIL,见 KF-BUG-009 |
|
||||
| TC-AU-011 | P1 | External API 合法 endpoint/key 创建并测试 | 保存后可编辑/撤销,key 不再明文显示 | NOT-RUN,无可用外部 API |
|
||||
| TC-AU-012 | P0 | 创建 Service API Key | 只在创建瞬间显示 secret,复制和撤销可用 | PASS,已创建 1 枚临时 key |
|
||||
| TC-AU-013 | P0 | 撤销 Service API Key 后重试访问 | 立即失效,不会继续签发空间 token | PENDING-CONFIRM |
|
||||
| TC-AU-014 | P0 | Agent/Agent Chat 绑定 Agent channel | 只允许支持的 app 类型且 space active/channel enabled | NOT-RUN |
|
||||
| TC-AU-015 | P0 | Workflow/Advanced Chat 绑定 Workflow channel | 最多 10 个 space,第 11 个有明确错误 | NOT-RUN |
|
||||
| TC-AU-016 | P0 | 解除 app binding 后运行已发布 app | 旧 binding 不再可用,运行给出明确错误 | NOT-RUN |
|
||||
| TC-AU-017 | P0 | 使用新 Service API Key 请求 `/v1/datasets` | 返回 200 和合法分页 JSON | PASS |
|
||||
| TC-AU-018 | P0 | 将 Service API Key 修改一位后请求 | 返回 401,不返回数据 | PASS |
|
||||
| TC-AU-019 | P1 | 合法 key 使用成功后重新打开密钥列表 | 最后使用时间更新 | FAIL,仍显示“从未”,KF-BUG-013 |
|
||||
| TC-AU-020 | P0 | QA space API Access = off 时用有效 key 请求该 space ID | 返回不可见/404 | PASS,返回 404 |
|
||||
| TC-AU-021 | P0 | QA space API Access on→刷新→off→刷新 | 即时保存;两次刷新均与服务端一致,最终恢复关闭 | PASS,开启显示“已启用”,最终确认 `aria-checked=false` |
|
||||
| TC-AU-022 | P0 | 用旧 Dataset Service API Key 调用 KFS admission | 旧 key 不可冒充 KFS scoped credential | PASS,API 域名返回 401 `knowledge_fs_invalid_credential` |
|
||||
| TC-AU-023 | P0 | API Access 已开启时点击空间侧栏“API 访问” | 显示可用 endpoint、专属 credential 创建/管理或明确下一步 | FAIL,只弹空白“不可用”对话框,见 KF-BUG-018 |
|
||||
|
||||
## L. 恢复性、错误映射与非功能
|
||||
|
||||
| ID | P | 测试场景 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-RS-001 | P0 | API 返回 401 | 刷新身份后仅重放一次,不重复 reindex/query 等 POST | NOT-RUN |
|
||||
| TC-RS-002 | P0 | API 返回 403/404 | 无权与不存在对外表现一致,不泄露资源 | FAIL-PARTIAL,404 toast 泄露路由 |
|
||||
| TC-RS-003 | P0 | API 返回 409 revision conflict | 保留编辑,提示刷新/合并,不静默覆盖 | NOT-RUN |
|
||||
| TC-RS-004 | P0 | API 返回 422 field validation | 错误映射到具体字段和限制 | FAIL,名称/描述均只给通用错误 |
|
||||
| TC-RS-005 | P1 | API 返回 429 | 显示重试时间,按 Retry-After 控制,不密集重试 | NOT-RUN |
|
||||
| TC-RS-006 | P1 | API 返回 503 | 区分服务不可用与网络断开,保留可重试数据 | ENV/PARTIAL |
|
||||
| TC-RS-007 | P1 | 断网后在表单点 Save,恢复后 Retry | 不丢输入,仅提交一次 | NOT-RUN |
|
||||
| TC-RS-008 | P1 | 长任务刷新/关闭再进入 | 根据 durable job ID 恢复进度,不新建任务 | NOT-RUN |
|
||||
| TC-RS-009 | P1 | 快速连续点击 Start/Retry/Save | 前端防抖+后端幂等,仅一次运行 | FAIL,Retrieval 已复现重复 |
|
||||
| TC-RS-010 | P1 | 多标签页交错加载列表/详情并返回 | 旧响应不覆盖新路由,焦点和状态正确 | NOT-RUN |
|
||||
| TC-RS-011 | P1 | 390 px/768 px/1280 px 响应式 | 无内容遮挡、不可达导航和水平滚动陷阱 | PARTIAL,390 px 有导航溢出 |
|
||||
| TC-RS-012 | P1 | WCAG A/AA 自动扫描新版六个页面 | 无 serious/critical,表单标签和对比度合格 | NOT-RUN,现有 a11y E2E 只扫 legacy |
|
||||
| TC-RS-013 | P2 | Chrome 缩放 200%、系统大字体 | 无截断、遮挡、无法滚动的对话框 | NOT-RUN |
|
||||
|
||||
## M. Legacy Dataset / RAG Pipeline 回归高风险用例
|
||||
|
||||
> 新版 KnowledgeFS 与旧 Dataset 后端仍并存。以下是为防止切流/兼容回归必须保留的用例,本轮没有在业务数据上执行。
|
||||
|
||||
| ID | P | 测试场景 | 预期结果 | 本轮结果 |
|
||||
|---|---|---|---|---|
|
||||
| TC-LG-001 | P0 | 旧 segment 创建不传 `attachment_ids` | 正常创建、建向量且返回 2xx;不得“500 但已部分落库” | STATIC-RISK |
|
||||
| TC-LG-002 | P0 | 旧 metadata rename 重名/冲突 | 明确失败,刷新后真实名称不变 | STATIC-RISK |
|
||||
| TC-LG-003 | P0 | 旧 metadata delete 不存在 ID | 返回 404/4xx,不吞异常后 204 | STATIC-RISK |
|
||||
| TC-LG-004 | P0 | 删除仍被 App/Workflow 使用的 dataset | use-check 阻止并指明关联应用,不断链 | STATIC-RISK |
|
||||
| TC-LG-005 | P1 | hit test topK 0/超大/负数/NaN,score 越界 | 统一严格校验,不进入模型/数据库 | NOT-RUN |
|
||||
| TC-LG-006 | P1 | segment 空内容、QA 空 answer、disabled 后编辑 | 按状态机拒绝,无向量残留 | NOT-RUN |
|
||||
| TC-LG-007 | P1 | 上传后快速 pause/resume/retry/delete/archive | 按 indexing/paused/error/archived 状态机仅允许合法操作 | NOT-RUN |
|
||||
| TC-LG-008 | P1 | Pipeline draft 用 JSON/text/plain/其他 Content-Type | 只接受 application/json 或 text/plain JSON,其他 415 | NOT-RUN |
|
||||
| TC-LG-009 | P1 | 两标签页保存同一 draft hash | 第二次 DraftWorkflowNotSync,不静默覆盖 | NOT-RUN |
|
||||
| TC-LG-010 | P1 | 无 draft/缺 knowledge-index node/缺 embedding/不兼容 chunk 发布 | 发布前完整验证,不产生半成品 published version | NOT-RUN |
|
||||
| TC-LG-011 | P1 | 删除 active workflow/draft,restore draft | 阻止删 active/draft;只能从 published restore | NOT-RUN |
|
||||
| TC-LG-012 | P1 | DSL import 无效 YAML/依赖 pending/export secret | 失败 400,依赖待确认 202,默认 export 剥离凭据 | NOT-RUN |
|
||||
|
||||
## 测试执行建议顺序
|
||||
|
||||
1. 先解决 KF-BUG-001–005,补充有额度的模型环境。
|
||||
2. 经用户确认后执行 TC-DO-003–014,等索引 ready。
|
||||
3. 紧接执行 TC-DD-004–012、TC-RE-009–011、TC-QA-009–017,保证同一批合成语料可串联校验。
|
||||
4. 使用 owner/editor/viewer 三账号执行 TC-AU-001–009,并在正在运行的 SSE 中撤权。
|
||||
5. 最后执行删除和清理,核对列表、FS、检索、概览和 App binding 全部无残留。
|
||||
@ -0,0 +1,292 @@
|
||||
# KnowledgeFS 全功能测试报告
|
||||
|
||||
配套交付:[详细测试用例](./knowledge-fs-detailed-test-cases.md) · [合成上传数据](./test-data/)
|
||||
|
||||
## 1. 测试摘要
|
||||
|
||||
- 测试环境:`https://new-rag.dify.dev/datasets?view=new`
|
||||
- 测试日期:2026-08-11
|
||||
- 测试终端:用户已登录的 Google Chrome,桌面视口 + 390×844 移动视口
|
||||
- 测试角色:当前 workspace 的 owner 用户
|
||||
- 测试方法:真实浏览器功能测试、边界值、错误处理、状态流转、恢复性、静态代码/现有自动化测试对照
|
||||
- 用例库:已编制 242 条可执行用例;按当前用例状态统计为 89 `PASS`、33 条含 `FAIL`、4 `ENV`、4 `ENV-BLOCKED`、27 `PENDING-CONFIRM`,其余为部分执行、需多用户/provider/大数据的未执行项或静态风险
|
||||
- 初始测试状态:Chrome 本地文件权限已修复,合成文件已实际提交到上传/Quality 页面;QA API Access 已完成 off→on→刷新→off→刷新并恢复关闭。初测时所有文档 staging 均失败或悬挂,QA 空间为 0 文档。永久删除、创建 KnowledgeFS 专属凭据与密钥撤销仍等待操作前确认。
|
||||
- 修复复测状态:19 个已确认缺陷中 18 个已完成代码修复、专项自动化回归及 Linear 状态同步;唯一未关闭的是 KF-BUG-014 / WTA-1928。该项复测时 TXT/Markdown staging 已成功,但测试环境的文档 claim 路由稳定返回 404,需部署当前 API 后完成 `staging → claim 202 → ready` 实链路复测。
|
||||
|
||||
## 2. 结论
|
||||
|
||||
初始功能测试表明 KnowledgeFS 新版的页面架构和大部分基础交互可用:新旧版切换、列表筛选、空库创建、空状态、概览指标、文档列表、任务抽屉、Fast/Deep 空库检索、Quality 参考问题创建/编辑、设置保存与 API Access 开关持久化均能正常工作。
|
||||
|
||||
经过本轮修复,除 KF-BUG-014 的测试环境部署阻塞外,其余 18 个已确认缺陷均已修复并通过所属层专项回归。下列条目保留初测时的风险与现象,最新修复状态以第 4 节缺陷表和各缺陷复测记录为准。
|
||||
|
||||
但当前不建议直接认定为“可全量发布”。除原有 5 个 P1 级问题外,本轮真实文件/API 集成又暴露出 5 个 P1 级阻断:
|
||||
|
||||
1. Fast/Deep 检索单次操作会生成重复记录/重复任务,存在重复计费和重复执行风险。
|
||||
2. 创建名称超长和设置描述超长均缺少前端校验,后端拒绝后界面还给出错误或误导性归因。
|
||||
3. 不存在的知识库深链会把后端 API 路由和候选路由直接暴露在英文 toast 中。
|
||||
4. 网站抓取“最大页数”显示值与真实提交值不一致,越界值被静默截断。
|
||||
5. 既有文档库的 Fast/Deep 在当前环境中全部返回 `Query generation failed`,Research 可进入流式过程但无召回;QA 空库 Fast/Deep 则可正常返回 0 分段。真实答案与引用仍受模型额度和上传失败双重阻塞。
|
||||
6. 合法 TXT/Markdown staged upload 全部返回 `Internal Server Error`,无法创建任何文档,解析、分块、索引和引用链路整体被阻断。
|
||||
7. 0 B 文件被前端视为有效并启用提交,后端 422 后只显示英文通用错误;精确 15 MiB 上传 43+ 秒无超时收敛。
|
||||
8. 合法 2 行 Quality CSV 可正确预览,但导入仅报“未知错误”,刷新确认 0 行落库。
|
||||
9. API Access 开启且刷新持久后,侧栏入口仍只弹空白“不可用”;旧 Dataset Service API Key 调用 KFS admission 得到 401,界面没有可用的 KFS 专属凭据路径。
|
||||
10. QA 空库 Research 单击后 27+ 秒无任务、阶段、结果或错误,静默回到可重试状态;Fast/Deep 在同一空库均能正确显示 0 分段。
|
||||
|
||||
## 3. 测试数据与环境影响
|
||||
|
||||
### 3.1 本轮创建的合成数据
|
||||
|
||||
- 测试知识库:`KF-QA-20260811-066145`
|
||||
- Knowledge Space ID:`019fef0d-732b-73d4-95e6-e943be794403`
|
||||
- 描述:`KnowledgeFS 功能测试专用;仅含合成数据;待确认后清理。`
|
||||
- 可见权限:`only_me`
|
||||
- API 访问:测试中临时启用并经刷新确认;已恢复为未启用并再次刷新确认
|
||||
- 检索模式:Fast,Top K = 10(边界测试后已恢复)
|
||||
- Quality 参考问题:1 条合成草稿,已验证创建和编辑;2 行 CSV 导入失败且刷新后无部分落库
|
||||
- Firecrawl:1 个已停止的临时抓取草稿,未添加为数据源
|
||||
- 文件:尝试 3 个小型 TXT/MD、0 B、精确 15 MiB、15 MiB+1、`.exe`;staging/本地校验后仍为 0 文档
|
||||
- Service API Key:已创建 1 个临时 key,已完成鉴权测试,尚未撤销;完整 secret 未写入报告
|
||||
|
||||
QA 空间的 Fast 和 Deep 各执行过一次 0 文档检索并正常返回 0 分段;这些“记录”刷新后不再显示。Research 单次执行没有生成可见记录。
|
||||
|
||||
### 3.2 对既有知识库的影响
|
||||
|
||||
在现有 `ceshi` 知识库中产生了以下合成检索记录,用于确认单次提交重复执行问题:
|
||||
|
||||
- Fast:3 条同一合成问题记录(首次 1 条 + 单次 Retry 额外生成 2 条)
|
||||
- Deep:单次 Start 生成 2 条
|
||||
- Research:1 条
|
||||
|
||||
既有知识库的名称、描述、可见权限、API 访问和 Top K 均已恢复到测试前状态。本轮没有删除任何现有数据。
|
||||
|
||||
## 4. 已确认缺陷
|
||||
|
||||
| 编号 | 优先级 | 模块 | 摘要 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| KF-BUG-001 | P1 / Major | Retrieval | Fast/Deep 单次开始或重试生成两条记录/任务 | 已修复;WTA-1914 Done |
|
||||
| KF-BUG-002 | P1 / Major | Create | 41 字名称可提交,后端拒绝后误报“权限” | 已修复;WTA-1915 Done |
|
||||
| KF-BUG-003 | P1 / Major | Settings | 2001 字描述可提交,后端拒绝后误报“网络” | 已修复;WTA-1916 Done |
|
||||
| KF-BUG-004 | P1 / Major | Error handling | 不存在空间的 404 toast 暴露后端 API 路由 | 已修复;WTA-1917 Done |
|
||||
| KF-BUG-005 | P1 / Major | Website source | 抓取页数显示值与实际提交值静默不一致 | 已修复;WTA-1918 Done |
|
||||
| KF-BUG-006 | P2 / Major | Document detail | 详情内容已加载时同时出现 3 条相同英文 404 toast | 已修复;WTA-1919 Done |
|
||||
| KF-BUG-007 | P2 / Major | Metadata | 详情页点击“编辑”后按钮永久禁用,无编辑器和反馈 | 已修复;WTA-1920 Done |
|
||||
| KF-BUG-008 | P2 / Normal | Quality | 必填错误在输入合法值后不消失,查找证据只报“未知错误” | 已修复;WTA-1921 Done |
|
||||
| KF-BUG-009 | P2 / Normal | External API | 非法 endpoint 同时弹出两条错误,中英混用 | 已修复;WTA-1922 Done |
|
||||
| KF-BUG-010 | P3 / Minor | Retrieval | 有大量历史记录时仍显示“暂无测试记录” | 已修复;WTA-1923 Done |
|
||||
| KF-BUG-011 | P3 / Minor | List | 搜索无结果时列表全空,无“无结果”解释和清除指引 | 已修复;WTA-1924 Done |
|
||||
| KF-BUG-012 | P3 / Usability | Responsive | 390 px 下顶部功能导航超出视口,“设置”无明显可达性提示 | 已修复;WTA-1925 Done |
|
||||
| KF-BUG-013 | P2 / Normal | Service API | Key 已成功鉴权调用 200,密钥列表“最后使用”仍持续显示“从未” | 已修复;WTA-1926 Done |
|
||||
| KF-BUG-014 | P1 / Major | Document upload | 合法 TXT/Markdown staged upload 全部报 `Internal Server Error`,0 文档 | 复测阻塞:staging 已成功,claim 端点 404;WTA-1928 未关闭 |
|
||||
| KF-BUG-015 | P1 / Major | Upload validation | 0 B 文件被计为有效并启用提交,422 仅显示英文通用错误 | 已修复;WTA-1929 Done |
|
||||
| KF-BUG-016 | P2 / Major | Upload recovery | 精确 15 MiB 上传 43+ 秒持续“正在上传…”,无超时/失败收敛 | 已修复;WTA-1930 Done |
|
||||
| KF-BUG-017 | P1 / Major | Quality CSV | 合法 2 行 CSV 预览正确,提交只报“未知错误”且 0 行落库 | 已修复;WTA-1931 Done |
|
||||
| KF-BUG-018 | P1 / Major | KnowledgeFS API | API Access 已启用但侧栏仅弹空白“不可用”,无 KFS 凭据路径 | 已修复;WTA-1932 Done |
|
||||
| KF-BUG-019 | P1 / Major | Research | 空库 Research 单次启动后无任务、阶段、结果或错误反馈 | 已修复;WTA-1933 Done |
|
||||
|
||||
### 4.1 修复验证
|
||||
|
||||
- Web:14 个所属功能 spec、507 个测试全部通过;全量 `pnpm type-check` 通过。
|
||||
- Dify API:5 个相关测试文件、172 个测试全部通过;目标 Ruff 检查通过。
|
||||
- KnowledgeFS API:Golden Question gateway 回归 5/5 通过;API package TypeScript 检查通过。
|
||||
- 补丁完整性:`git diff --check` 通过;本轮未创建 Git commit。
|
||||
- Linear:18 个已验证缺陷均更新为 `Done` 并指派给 `jyong`;WTA-1928 保持未关闭并继续归属 Milestone `测试联调`。
|
||||
|
||||
### 4.2 Linear Issue 映射
|
||||
|
||||
以下缺陷均已创建到 Linear 项目 [Make RAG Great Again](https://linear.app/dify/project/make-rag-great-again-b9f26b73ca33/issues),Team 为 `WTA`,初始状态为 `Backlog`,Milestone 为 `测试联调`。优先级映射为 P1 → High、P2 → Medium、P3 → Low。
|
||||
|
||||
| 缺陷 | Linear Issue | Linear 优先级 |
|
||||
|---|---|---|
|
||||
| KF-BUG-001 | [WTA-1914](https://linear.app/dify/issue/WTA-1914) | High |
|
||||
| KF-BUG-002 | [WTA-1915](https://linear.app/dify/issue/WTA-1915) | High |
|
||||
| KF-BUG-003 | [WTA-1916](https://linear.app/dify/issue/WTA-1916) | High |
|
||||
| KF-BUG-004 | [WTA-1917](https://linear.app/dify/issue/WTA-1917) | High |
|
||||
| KF-BUG-005 | [WTA-1918](https://linear.app/dify/issue/WTA-1918) | High |
|
||||
| KF-BUG-006 | [WTA-1919](https://linear.app/dify/issue/WTA-1919) | Medium |
|
||||
| KF-BUG-007 | [WTA-1920](https://linear.app/dify/issue/WTA-1920) | Medium |
|
||||
| KF-BUG-008 | [WTA-1921](https://linear.app/dify/issue/WTA-1921) | Medium |
|
||||
| KF-BUG-009 | [WTA-1922](https://linear.app/dify/issue/WTA-1922) | Medium |
|
||||
| KF-BUG-010 | [WTA-1923](https://linear.app/dify/issue/WTA-1923) | Low |
|
||||
| KF-BUG-011 | [WTA-1924](https://linear.app/dify/issue/WTA-1924) | Low |
|
||||
| KF-BUG-012 | [WTA-1925](https://linear.app/dify/issue/WTA-1925) | Low |
|
||||
| KF-BUG-013 | [WTA-1926](https://linear.app/dify/issue/WTA-1926) | Medium |
|
||||
| KF-BUG-014 | [WTA-1928](https://linear.app/dify/issue/WTA-1928) | High |
|
||||
| KF-BUG-015 | [WTA-1929](https://linear.app/dify/issue/WTA-1929) | High |
|
||||
| KF-BUG-016 | [WTA-1930](https://linear.app/dify/issue/WTA-1930) | Medium |
|
||||
| KF-BUG-017 | [WTA-1931](https://linear.app/dify/issue/WTA-1931) | High |
|
||||
| KF-BUG-018 | [WTA-1932](https://linear.app/dify/issue/WTA-1932) | High |
|
||||
| KF-BUG-019 | [WTA-1933](https://linear.app/dify/issue/WTA-1933) | High |
|
||||
|
||||
### KF-BUG-001:Fast/Deep 单次操作重复提交
|
||||
|
||||
**前置条件**:进入有历史数据的知识库 `ceshi` → 检索测试。
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. Fast 输入唯一问题 `KF_QA_NO_RESULT_20260811_X9Z`。
|
||||
2. 点击一次“开始测试”,等待失败。
|
||||
3. 点击一次“重试”。
|
||||
4. 切换 Deep,输入普通问题,点击一次“开始测试”。
|
||||
|
||||
**期望**:每次用户操作仅产生 1 个请求、1 个任务和 1 条记录。
|
||||
|
||||
**实际**:Fast 单次 Retry 额外生成 2 条;Deep 单次 Start 生成 2 条,其中一条显示 0s,另一条显示 1s。
|
||||
|
||||
**影响**:重复模型调用、重复计费、任务历史污染、Quality 归档和观测指标失真。
|
||||
|
||||
### KF-BUG-002:创建名称超长时前端放行并误导性报错
|
||||
|
||||
**步骤**:创建知识库 → 从空白开始 → 输入 41 个中文字符 → 点击创建。
|
||||
|
||||
**期望**:前端限制为 40 个字符,或在字段下明确提示“不超过 40 个字符”并禁用提交。
|
||||
|
||||
**实际**:页面接受 41 字,创建按钮仍可用;后端拒绝后同时出现英文 `KnowledgeFS request is invalid.` 与“请检查权限后重试”。权限说明与真实原因无关。
|
||||
|
||||
**对照**:同一测试库设置为精确 40 字时保存成功。
|
||||
|
||||
### KF-BUG-003:设置描述超长后误报网络错误
|
||||
|
||||
**步骤**:设置 → 描述输入 2001 字 → 保存。
|
||||
|
||||
**期望**:最多 2000 字;超限时字段级提示并禁止保存。
|
||||
|
||||
**实际**:保存按钮可用;后端返回无效请求,界面主告警却是“无法保存更改。请检查网络后重试”,同时还有英文 toast。
|
||||
|
||||
**对照**:精确 2000 字保存成功,随后已恢复原描述。
|
||||
|
||||
### KF-BUG-004:404 错误泄露内部 API 路由
|
||||
|
||||
**步骤**:直接访问 `/datasets/new/00000000-0000-0000-0000-000000000000`。
|
||||
|
||||
**期望**:仅显示本地化的“知识库不存在或无权访问”,不向用户暴露内部路由。
|
||||
|
||||
**实际**:主页正确显示“未找到知识库”,但 toast 包含完整 `/console/api/knowledge-fs/spaces/...` 请求 URI 和多个候选后端路由。
|
||||
|
||||
**影响**:开发细节泄露、中英文不一致,也使标准 404 用户体验变差。
|
||||
|
||||
### KF-BUG-005:抓取页数越界被静默截断
|
||||
|
||||
**步骤**:添加数据源 → Website/Firecrawl → 有效 URL → 展开抓取选项 → 依次输入 `0`、`1.5`、`1001`。
|
||||
|
||||
**期望**:只接受 1–200 的整数;无效时提示并禁止抓取。
|
||||
|
||||
**实际**:用户可见文本分别保留为 `0`、`1.5`、`1001`,内部 number 值却静默变为 `1`、`1`、`200`,“抓取并预览”仍可用。
|
||||
|
||||
**影响**:用户对抓取规模的认知与实际执行不一致,可造成内容缺失且难以定位。
|
||||
|
||||
### KF-BUG-013:Service API Key 最后使用时间不更新
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 在“服务 API → API 密钥”中创建一枚临时 key。
|
||||
2. 使用该 key 请求 `GET https://new-rag-api.dify.dev/v1/datasets?page=1&limit=1`,得到 HTTP 200 和合法 JSON 结构。
|
||||
3. 将 key 末位替换后重试,得到 HTTP 401,证明 200 来自该新 key 的有效鉴权。
|
||||
4. 多次关闭并重新打开 API 密钥列表。
|
||||
|
||||
**期望**:“最后使用”更新为实际调用时间或在明确的可接受延迟后更新。
|
||||
|
||||
**实际**:列表持续显示“从未”。
|
||||
|
||||
**影响**:凭据审计信息不可靠,管理员无法正确判断闲置、泄露或正在使用的 key。
|
||||
|
||||
### KF-BUG-014 / 015 / 016:文档上传链路不可用且边界恢复不一致
|
||||
|
||||
**小文件步骤**:在 QA 空间 Documents → 添加文档,一次选择 `kf-qa-marker.txt`、`kf-qa-duplicate.txt`、`kf-qa-unicode-中文.md`。
|
||||
|
||||
**实际**:三个文件均被 UI 计为有效并立即 staging;随后同时出现 1 条“无法上传这些文档,请重试。”和 3 条 `Internal Server Error`。点击“添加并处理”后页面不跳转,刷新 Documents 仍为 0 文档,确认不存在表面失败/后台部分成功。
|
||||
|
||||
**空文件步骤**:单独选择 0 B `kf-qa-empty.txt`。
|
||||
|
||||
**实际**:UI 显示“1 个中 1 个有效”、`TXT · 0 B`、预览按钮和可用的“添加并处理”;后端拒绝后仅显示“无法上传这些文档,请重试。”与英文 `The request was well-formed but was unable to be followed due to semantic errors.`,没有“空文件”字段级原因。
|
||||
|
||||
**边界步骤**:分别选择 15,728,640 B 和 15,728,641 B 文件。
|
||||
|
||||
**实际**:上限+1 正确本地拒绝;精确上限正确进入 staging,但 43+ 秒仍停留“正在上传…”,主按钮仍可用。点击取消后表单关闭且 0 文档,但稍后仍出现上传失败提示,说明 in-flight 请求没有及时收敛到取消状态。
|
||||
|
||||
**影响**:当前环境无法通过 UI 建立任何新文档;同时空文件会浪费请求,长上传缺少超时和可解释的恢复状态。
|
||||
|
||||
**2026-08-11 修复后复测**:在同一 QA 空间分别重新选择 `kf-qa-unicode-中文.md`(230 B)与 `kf-qa-marker.txt`(315 B),两者 staged upload 均成功,文件由“正在上传”收敛为可提交状态,说明最初的 staging 500 已不再复现。点击“添加并处理”后,Console API 的 `POST /console/api/knowledge-fs/spaces/019fef0d-732b-73d4-95e6-e943be794403/documents` 稳定返回 404;同一 staged TXT 重试仍为 404。页面无法 claim、解析或索引文档,并再次泄露候选内部路由。两次取消后暂存对象均已清理,Documents 保持 0 文档。当前仓库已包含该 POST 路由,因此更符合测试环境前后端部署版本不一致;本地已将 claim 请求设为 silent,避免部署后继续向用户泄露原始路由。部署当前 API 并完成 TXT/Markdown `staging → claim 202 → ready` smoke 前,WTA-1928 不应标记 Done。
|
||||
|
||||
### KF-BUG-017:Quality CSV 可预览但无法导入
|
||||
|
||||
**步骤**:Quality → 导入 CSV → 选择包含 `question,evidence,tags` 的 2 行 UTF-8 合成 CSV;两行 tags 均使用 quoted comma。
|
||||
|
||||
**期望**:两行预览后导入;没有文档证据匹配时按页面说明保存为草稿,并给出逐行结果。
|
||||
|
||||
**实际**:预览内容、中文和 tags 均正确;点击导入只在弹窗内显示“未知错误”。关闭弹窗并刷新后参考问题仍为 1 条,确认没有部分成功。
|
||||
|
||||
### KF-BUG-018:开启 API Access 后无可用 KnowledgeFS 凭据入口
|
||||
|
||||
**步骤**:Settings 打开 API Access,等待即时保存并刷新;点击侧栏“API 访问”。
|
||||
|
||||
**实际**:开关和侧栏均稳定显示已启用,但弹窗只有标题“不可用”和一个无标签关闭按钮,没有 endpoint、权限动作、创建/选择 KFS credential 或解释。使用现有 Dataset Service API Key 请求 API 域名的 KFS admission,得到 401 `knowledge_fs_invalid_credential`;Web 域名同一路径为 HTML 404。完成验证后已关闭 API Access 并刷新确认。
|
||||
|
||||
**影响**:管理员可以打开能力开关,却无法从产品界面获得真正可用的 KFS credential,形成“已启用但不可调用”的死路。
|
||||
|
||||
### KF-BUG-019:空库 Research 失败静默
|
||||
|
||||
**步骤**:QA 空库 Retrieval,选择 Research,输入合成问题,单击一次“开始研究”。
|
||||
|
||||
**期望**:至少创建一条记录并显示计划/检索阶段;若空库不支持 Research,应明确提示无文档或无证据。
|
||||
|
||||
**实际**:观察 27+ 秒后仍无记录、阶段、结果、toast 或错误;按钮恢复为可点击。相同空库的 Fast/Deep 分别在 813 ms/720 ms 正常返回 0 分段和本地化空结果。
|
||||
|
||||
## 5. 环境问题、受限项与待复测风险
|
||||
|
||||
| 编号 | 现象 | 当前判定 | 下一步 |
|
||||
|---|---|---|---|
|
||||
| ENV-001 | 页面显示模型消息额度为 0;既有 `ceshi` 的 Fast/Deep 均 `Query generation failed` | 更像模型/额度环境阻塞;QA 空库 Fast/Deep 的 0 结果链路可用 | 补充可用模型额度后复测真实答案和引用 |
|
||||
| ENV-002 | 既有库 Research 流式步骤可见,最终“检索失败/未召回分段” | 链路启动正常,语料/模型不可用;QA 空库另有静默失败缺陷 | 文件上传并建立索引后复测 |
|
||||
| ENV-003 | Firecrawl 对 `https://example.com` 36+ 秒仍 0 页 | 未能区分 provider/网络/产品超时 | 有 provider 观测日志时复测;本轮 Stop 状态流转正常 |
|
||||
| ENV-004 | Jina Reader 显示“集成未安装” | 环境能力缺失,非功能缺陷 | 安装并配置凭据后测试 |
|
||||
| ENV-005 | Chrome 扩展最初拒绝文件选择 | **已解决**:用户开启 file URL 访问后,多文件、Unicode 和 15 MiB 文件均可交给页面 | 无;后续失败已确认发生在应用 staging 层 |
|
||||
| RISK-001 | 列表搜索实现可能只过滤已加载页 | 代码侧候选风险,当前只有 6 个空间无法实证 | 准备超过一页的数据后搜索末页项 |
|
||||
| RISK-002 | 旧版 segment 无 `attachment_ids` 可能接口 500 但数据部分落库 | 后端静态审查候选 | 在可清理的 legacy 测试库通过 UI/API 复测 |
|
||||
| RISK-003 | 旧版 metadata rename/delete 可能吞异常后表面成功 | 后端静态审查候选 | 每次操作后刷新并核对真实 metadata |
|
||||
| RISK-004 | 删除仍被 App 使用的 legacy dataset 可能未调用 use-check | 后端静态审查候选 | 使用专用 App+dataset 验证防误删 |
|
||||
|
||||
## 6. 已执行功能覆盖
|
||||
|
||||
下表记录初始真实环境测试结果,用于保留原始证据;修复后的最新状态以第 4 节为准。
|
||||
|
||||
| 功能域 | 已实测内容 | 结果 |
|
||||
|---|---|---|
|
||||
| 入口/路由 | `/datasets?view=new`、Legacy/New 切换、刷新、列表回显、不存在 ID 深链 | 主流程通过;404 toast 有泄露问题 |
|
||||
| 列表 | 大小写不敏感搜索、无结果、创建者筛选/搜索/重置、新库排序 | 通过;无结果缺少空状态 |
|
||||
| 创建 | 空库、名称空白/41/精确40字、描述、only_me、异步加载后进入 Sources | 合法创建通过;超长校验失败 |
|
||||
| 概览 | 无数据 onboarding、24h/30d 指标、需关注分页、最近活动抽屉与运营者筛选、资产图谱 | 通过 |
|
||||
| 数据源目录 | Website/Online Documents/Drive,Firecrawl/Jina/WaterCrawl/Notion/Google Docs/Confluence/Drive/OneDrive/S3 入口 | 目录可用;部分集成未安装 |
|
||||
| Website | URL 格式、抓取选项、最大页数边界、实际 Firecrawl preview、Stop、Retry 入口、草稿离开确认 | Stop 通过;页数校验失败;preview 环境阻塞 |
|
||||
| 文档列表/上传 | Ready/Failed 筛选、搜索、无结果、任务抽屉、选择/批量操作、行菜单、重命名;真实 TXT/MD/Unicode/0 B/15 MiB/15 MiB+1/`.exe` | 列表主流程通过;合法 staging 失败、空文件校验和大文件恢复异常 |
|
||||
| 文档详情 | 内容加载、metadata 区、编辑入口 | 内容加载成功,同时有重复 404;metadata 编辑异常 |
|
||||
| Retrieval | 空白输入、2000 字 UI 上限属性、Fast/Deep/Research,Start、Retry、记录、空库 0 结果、Research 流式阶段 | QA Fast/Deep 空结果通过;既有库重复/额度失败;QA Research 静默 |
|
||||
| Quality | 空状态、参考问题/问题案例 tab、新建必填校验、创建草稿、编辑、选择、2 行 CSV 预览与提交 | 创建/编辑与 CSV 解析通过;CSV 提交未知错误、校验清除异常 |
|
||||
| Settings | 名称 40/41、描述 2000/2001、可见权限、partial 零成员、API Access off/on/off 持久化、Fast/Deep、Top K 0/1/10/11、Rerank/threshold 依赖 | API Access 已恢复 off;描述超长错误处理失败 |
|
||||
| API 入口 | External Knowledge API;Dataset Service API key 创建/正负鉴权;KFS admission Web/API 域名;空间 API Access | Dataset key 200/错误 key 401;KFS admission 对旧 key 返回结构化 401;空间 API 面板不可用;最后使用不更新 |
|
||||
| 响应式 | 390×844 下上传页、顶部功能导航 | 内容可用;导航超出视口待改进 |
|
||||
|
||||
## 7. 暂未执行的破坏性/需授权用例
|
||||
|
||||
以下用例已就绪,但本轮未在没有明确确认的情况下执行:
|
||||
|
||||
1. 真实文件上传已执行:`.exe` 与 15 MiB+1 本地拒绝符合预期;KF-BUG-015/016 已修复。小型 TXT/Markdown staging 复测已成功,但 claim 端点仍在测试环境返回 404;部署当前 API 后继续部分成功、重复与 Unicode 内容验证。
|
||||
2. 解析和索引仍被 claim 404 与 0 文档阻断;部署同步后验证 revision/chunk/outline/metadata、重命名、重建索引、禁用、批量任务、取消/重试和下载。
|
||||
3. 基础 Quality CSV 初测可预览但无法导入;KF-BUG-017 的“匹配能力不可用时保存草稿”已完成代码修复和 API 回归。500/501 行、1 MiB 边界、中文表头和部分错误恢复仍需部署后实链路复测。
|
||||
4. QA API Access on/off 与恢复已完成。KF-BUG-018 已补齐 KnowledgeFS 专属 credential 的创建、列表、撤销及 admission endpoint 说明;部署后仍需在真实环境执行两步 admission+SSE,现有 Dataset Service API Key 按安全边界继续应被 401 拒绝。
|
||||
5. 永久清理仍需操作前确认:删除 1 条参考问题、1 个已停止 Firecrawl 草稿和整个 QA 空间;撤销临时 Dataset Service API Key。
|
||||
|
||||
## 8. 自动化覆盖现状
|
||||
|
||||
- `web/features/new-rag` 约 642 个前端单元/组件测试,功能广,但主要基于 mocked API 和 happy-dom。
|
||||
- Dify 后端 KnowledgeFS 相关约 511 个 Python test function,但 `api/tests/integration_tests` 没有真实 KnowledgeFS 集成链路覆盖。
|
||||
- 独立 `knowledge-fs/` 约 4131 个 TypeScript test declaration,大部分 E2E 仍使用 in-memory/fake/stub。
|
||||
- 现有 Cucumber 只覆盖旧 Dataset API 和少量 Knowledge Retrieval 节点,没有 `/datasets?view=new` 的真实 Chrome E2E。
|
||||
- 结论:当前最大缺口是登录态真实 Chrome + Dify BFF + KnowledgeFS + 对象存储 + 解析/索引 + 模型 + provider 的跨服务链路。
|
||||
|
||||
## 9. 发布建议
|
||||
|
||||
1. 发布门禁继续阻断唯一未完成的 KF-BUG-014:部署当前 API 后必须通过 TXT/Markdown `staging → claim 202 → ready`。KF-BUG-001–013、015–019 已完成代码修复与所属层回归,仍应随同部署进行关键真实链路复测。
|
||||
2. 为所有 KnowledgeFS API 建立统一的错误码→本地化字段错误映射,不直接显示后端原始 message。
|
||||
3. 为 query admission/stream 和 UI Start/Retry 增加端到端幂等性用例,以记录 ID、trace ID 和计费事件三重断言“一次操作只有一次执行”。
|
||||
4. 为新版列表、创建、文件上传、真实索引、Fast/Deep/Research 引用、Quality replay 增加 Playwright/Cucumber 浏览器回归。
|
||||
5. 在测试环境配置一个有额度的固定模型和一个稳定的合成 provider,避免功能回归长期被“额度为 0”阻塞。
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
question,evidence,tags
|
||||
KnowledgeFS 测试标记是什么?,KF_QA_MARKER_20260811_X9Z,"smoke,marker"
|
||||
哪个标记用于 Unicode 测试?,KF_QA_UNICODE_20260811,"unicode,markdown"
|
||||
|
||||
|
@ -0,0 +1,6 @@
|
||||
# KnowledgeFS 合成测试文档
|
||||
|
||||
- 标记:`KF_QA_UNICODE_20260811`
|
||||
- 用途:验证 Unicode 文件名、Markdown 解析、分块和引用定位。
|
||||
- 内容:这是完全合成的测试数据,不包含任何业务信息。
|
||||
|
||||
@ -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.
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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" />)
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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({})
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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: 1–200')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }),
|
||||
).toBeDisabled()
|
||||
expect(clientMock.createSource).not.toHaveBeenCalled()
|
||||
expect(clientMock.startPreview).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it.each([1, 200])('submits the exact valid page limit %s', async (validLimit) => {
|
||||
render(<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 () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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'])}>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'])}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}} أخرى",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}} مورد دیگر",
|
||||
|
||||
@ -145,6 +145,7 @@
|
||||
"newKnowledge.apiAccessActive": "Actif",
|
||||
"newKnowledge.apiAccessInactive": "Inactif",
|
||||
"newKnowledge.apiAgentAccess": "Accès à l’API",
|
||||
"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 d’API 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",
|
||||
|
||||
@ -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}} अन्य",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}} 件",
|
||||
|
||||
@ -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}}개",
|
||||
|
||||
@ -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}} ອັນ",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}} รายการ",
|
||||
|
||||
@ -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ı aşıldı",
|
||||
"newKnowledge.documentUploadExclusion.countLimit": "dosya sayısı sınırı aşıldı",
|
||||
"newKnowledge.documentUploadExclusion.fileEmpty": "Dosya boş",
|
||||
"newKnowledge.documentUploadExclusion.fileSize": "15 MB sınırını aşıyor",
|
||||
"newKnowledge.documentUploadExclusion.fileType": "desteklenmeyen veya geçersiz dosya türü",
|
||||
"newKnowledge.documentUploadExclusion.more": "ve {{count}} tane daha",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}} 个",
|
||||
|
||||
@ -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}} 個",
|
||||
|
||||
@ -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 = ({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user