Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge

This commit is contained in:
FFXN 2026-08-13 16:29:19 +08:00
commit 08910f8d79
56 changed files with 1259 additions and 247 deletions

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 5,
"subtreeTree": "67bd568555a900179301ec69121b276a88332aa3",
"subtreeTree": "7250e700e493b975bf070a97d5424c5a0c7def62",
"openapiSha256": "47936a7d9ffdc27e2b2b8982a90e1936dc3bf59a64c316f452a6912ec1d2fcd6",
"capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7",
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",

View File

@ -70,6 +70,7 @@ import {
isTerminalKnowledgeSpaceProfileMigrationError,
} from "./knowledge-space-profile-migration";
import {
type KnowledgeSpaceProfileMigrationPrincipal,
type KnowledgeSpaceProfileMigrationService,
KnowledgeSpaceProfileMigrationServiceError,
toPublicKnowledgeSpaceProfileMigration,
@ -1223,37 +1224,11 @@ export function registerKnowledgeSpaceHandlers({
409,
);
}
const candidateRevision = await nextPublishedRetrievalCandidateRevision(profiles, {
activeRevision: actualRevision,
knowledgeSpaceId,
tenantId: subject.tenantId,
});
const candidateSnapshot = createKnowledgeSpaceRetrievalProfile(
body.profile,
candidateRevision,
);
const capabilitySnapshot = {
reasoning: capabilitySnapshots.reasoning ?? null,
rerank: capabilitySnapshots.rerank ?? null,
verification: "verified",
} as const;
let candidate: KnowledgeSpaceProfileRevision;
try {
candidate = await getOrCreateSettingsProfileCandidate(profiles, {
capabilitySnapshot,
createdBySubjectId: subject.subjectId,
kind: "retrieval",
knowledgeSpaceId,
now: now(),
snapshot: candidateSnapshot,
tenantId: subject.tenantId,
});
} catch (error) {
if (error instanceof SettingsProfileCandidateConflictError) {
return context.json({ code: error.code, error: error.message }, 409);
}
throw error;
}
const authenticatedApiKey = context.get("authenticatedApiKey");
const capabilityGrantId = context.get("capabilityV2Grant")?.grantId;
const migrationPrincipal = {
@ -1264,7 +1239,21 @@ export function registerKnowledgeSpaceHandlers({
subject,
} as const;
try {
let migration = await profileMigrations.request({
const candidate = await getOrSupersedeRetrievalSettingsCandidate({
activeRevision: actualRevision,
capabilitySnapshot,
createdBySubjectId: subject.subjectId,
migrationPrincipal,
migrations: profileMigrations,
now,
profile: body.profile,
profiles,
});
let migration = await profileMigrations.findByCandidate({
...migrationPrincipal,
candidateProfileId: candidate.id,
});
migration ??= await profileMigrations.request({
...migrationPrincipal,
candidateRevision: candidate.revision,
changedKind: "retrieval",
@ -1291,6 +1280,9 @@ export function registerKnowledgeSpaceHandlers({
}
return context.json(toPublicKnowledgeSpaceProfileMigration(migration), 202);
} catch (error) {
if (error instanceof SettingsProfileCandidateConflictError) {
return context.json({ code: error.code, error: error.message }, 409);
}
if (error instanceof KnowledgeSpaceProfileMigrationConflictError) {
return context.json({ code: error.code, error: error.message }, 409);
}
@ -2592,6 +2584,91 @@ class SettingsProfileCandidateConflictError extends Error {
}
}
async function getOrSupersedeRetrievalSettingsCandidate(input: {
readonly activeRevision: number;
readonly capabilitySnapshot: Readonly<Record<string, unknown>>;
readonly createdBySubjectId: string;
readonly migrationPrincipal: KnowledgeSpaceProfileMigrationPrincipal & {
readonly knowledgeSpaceId: string;
};
readonly migrations: KnowledgeSpaceProfileMigrationService;
readonly now: () => string;
readonly profile: KnowledgeSpaceRetrievalProfileInput;
readonly profiles: KnowledgeSpaceProfileRepository;
}): Promise<KnowledgeSpaceProfileRevision> {
for (;;) {
const candidateRevision = await nextPublishedRetrievalCandidateRevision(input.profiles, {
activeRevision: input.activeRevision,
knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId,
tenantId: input.migrationPrincipal.subject.tenantId,
});
const candidateInput = {
capabilitySnapshot: input.capabilitySnapshot,
createdBySubjectId: input.createdBySubjectId,
kind: "retrieval" as const,
knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId,
now: input.now(),
snapshot: createKnowledgeSpaceRetrievalProfile(input.profile, candidateRevision),
tenantId: input.migrationPrincipal.subject.tenantId,
};
try {
return await getOrCreateSettingsProfileCandidate(input.profiles, candidateInput);
} catch (error) {
if (!(error instanceof SettingsProfileCandidateConflictError)) throw error;
const existing = await input.profiles.getRevision({
kind: "retrieval",
knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId,
revision: candidateRevision,
tenantId: input.migrationPrincipal.subject.tenantId,
});
if (
!existing ||
existing.state !== "candidate" ||
existing.createdBySubjectId !== input.createdBySubjectId ||
existing.snapshotDigest === knowledgeSpaceProfileSnapshotDigest(candidateInput.snapshot)
) {
throw error;
}
const previousMigration = await input.migrations.findByCandidate({
...input.migrationPrincipal,
candidateProfileId: existing.id,
});
if (previousMigration) {
const canceled = await input.migrations.cancel({
...input.migrationPrincipal,
reason: "Superseded by a newer retrieval settings update",
runId: previousMigration.id,
});
if (!canceled || canceled.runState !== "canceled") throw error;
} else {
try {
await input.profiles.failCandidate({
errorCode: "PROFILE_SETTINGS_CANDIDATE_SUPERSEDED",
errorMessage: "Superseded by a newer retrieval settings update",
kind: "retrieval",
knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId,
now: input.now(),
revision: existing.revision,
tenantId: input.migrationPrincipal.subject.tenantId,
});
} catch (retirementError) {
if (retirementError instanceof KnowledgeSpaceProfileTransitionError) throw error;
throw retirementError;
}
}
const retired = await input.profiles.getRevision({
kind: "retrieval",
knowledgeSpaceId: input.migrationPrincipal.knowledgeSpaceId,
revision: existing.revision,
tenantId: input.migrationPrincipal.subject.tenantId,
});
if (!retired || retired.state === "candidate") throw error;
}
}
}
async function nextPublishedRetrievalCandidateRevision(
profiles: KnowledgeSpaceProfileRepository,
input: {

View File

@ -188,13 +188,13 @@ function retrievalUpdateBody() {
} as const;
}
function rerankOnlyRetrievalUpdateBody() {
function rerankOnlyRetrievalUpdateBody(rerankModel = RERANK_V2) {
return {
expectedRevision: 1,
profile: {
defaultMode: "deep",
reasoningModel: REASONING_V1,
rerank: { enabled: true, model: RERANK_V2 },
rerank: { enabled: true, model: rerankModel },
scoreThreshold: { enabled: false, stage: "mode-final" },
topK: 8,
},
@ -743,6 +743,7 @@ describe("knowledge-space profile handler behavior", () => {
knowledgeSpaceManifests: manifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request,
requiresMigration: async () => true,
@ -793,6 +794,7 @@ describe("knowledge-space profile handler behavior", () => {
knowledgeSpaceManifests: retrievalManifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request,
requiresMigration: async () => true,
@ -852,6 +854,121 @@ describe("knowledge-space profile handler behavior", () => {
).resolves.toMatchObject({ state: "candidate" });
});
it("supersedes an owned pending retrieval candidate when the rerank model changes", async () => {
const manifests = createInMemoryKnowledgeSpaceManifestRepository({
maxListLimit: 10,
maxManifests: 10,
});
const profiles = profileRepository();
const request = vi.fn(
async (input: { readonly candidateRevision: number }) =>
({
changedKind: "retrieval",
checkpoint: "queued",
createdAt: NOW,
id: `migration-retrieval-${input.candidateRevision}`,
knowledgeSpaceId: SPACE_ID,
rebuildScope: "clone-publication",
runState: "queued",
updatedAt: NOW,
}) as never,
);
const cancel = vi.fn(async (input: { readonly runId: string }) => {
expect(input.runId).toBe("migration-retrieval-2");
await profiles.failCandidate({
errorCode: "PROFILE_MIGRATION_CANCELED",
errorMessage: "Superseded by a newer retrieval settings update",
kind: "retrieval",
knowledgeSpaceId: SPACE_ID,
now: NOW,
revision: 2,
tenantId: "tenant-1",
});
return {
changedKind: "retrieval",
checkpoint: "queued",
completedAt: NOW,
createdAt: NOW,
id: input.runId,
knowledgeSpaceId: SPACE_ID,
rebuildScope: "clone-publication",
runState: "canceled",
updatedAt: NOW,
} as never;
});
const findByCandidate = vi.fn(async (input: { readonly candidateProfileId: string }) => {
const revision = await profiles.getRevision({
kind: "retrieval",
knowledgeSpaceId: SPACE_ID,
revision: 2,
tenantId: "tenant-1",
});
if (request.mock.calls.length === 0 || revision?.id !== input.candidateProfileId) return null;
return {
changedKind: "retrieval",
checkpoint: "queued",
createdAt: NOW,
id: "migration-retrieval-2",
knowledgeSpaceId: SPACE_ID,
rebuildScope: "clone-publication",
runState: "queued",
updatedAt: NOW,
} as never;
});
const app = publishedProfileApp(manifests, profiles, {
cancel,
findByCandidate,
get: async () => null,
request,
requiresMigration: async () => true,
retry: async () => null,
});
await createSpace(app);
await seedLegacyManifestWithoutRerank(manifests);
const first = await app.request(`/knowledge-spaces/${SPACE_ID}/retrieval-profile`, {
body: JSON.stringify(rerankOnlyRetrievalUpdateBody(RERANK_V1)),
headers: headers(),
method: "PUT",
});
expect(first.status).toBe(202);
const replacement = await app.request(`/knowledge-spaces/${SPACE_ID}/retrieval-profile`, {
body: JSON.stringify(rerankOnlyRetrievalUpdateBody(RERANK_V2)),
headers: headers(),
method: "PUT",
});
const replacementBody = await replacement.json();
expect(replacement.status, JSON.stringify(replacementBody)).toBe(202);
expect(replacementBody).toMatchObject({
id: "migration-retrieval-3",
runState: "queued",
});
expect(request).toHaveBeenCalledTimes(2);
expect(findByCandidate).toHaveBeenCalledTimes(3);
expect(cancel).toHaveBeenCalledOnce();
await expect(
profiles.getRevision({
kind: "retrieval",
knowledgeSpaceId: SPACE_ID,
revision: 2,
tenantId: "tenant-1",
}),
).resolves.toMatchObject({ state: "failed" });
await expect(
profiles.getRevision({
kind: "retrieval",
knowledgeSpaceId: SPACE_ID,
revision: 3,
tenantId: "tenant-1",
}),
).resolves.toMatchObject({
snapshot: { rerank: { enabled: true, model: RERANK_V2 } },
state: "candidate",
});
});
it("retries a nonterminal failed retrieval migration when settings are saved again", async () => {
const manifests = createInMemoryKnowledgeSpaceManifestRepository({
maxListLimit: 10,
@ -887,6 +1004,7 @@ describe("knowledge-space profile handler behavior", () => {
);
const app = publishedProfileApp(manifests, profiles, {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request,
requiresMigration: async () => true,
@ -921,21 +1039,20 @@ describe("knowledge-space profile handler behavior", () => {
maxManifests: 10,
});
const profiles = profileRepository();
const request = vi.fn(
async () =>
({
changedKind: "retrieval",
checkpoint: "queued",
createdAt: NOW,
id: "migration-retrieval",
knowledgeSpaceId: SPACE_ID,
rebuildScope: "clone-publication",
runState: "queued",
updatedAt: NOW,
}) as never,
);
const migration = {
changedKind: "retrieval",
checkpoint: "queued",
createdAt: NOW,
id: "migration-retrieval",
knowledgeSpaceId: SPACE_ID,
rebuildScope: "clone-publication",
runState: "queued",
updatedAt: NOW,
} as never;
const request = vi.fn(async () => migration);
const app = publishedProfileApp(manifests, profiles, {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request,
requiresMigration: async () => true,
@ -994,23 +1111,23 @@ describe("knowledge-space profile handler behavior", () => {
const profiles = profileRepository();
let checkedAt = NOW;
let capabilityDigest = `sha256:${"c".repeat(64)}`;
const request = vi.fn(
async () =>
({
changedKind: "retrieval",
checkpoint: "queued",
createdAt: NOW,
id: "migration-retrieval",
knowledgeSpaceId: SPACE_ID,
rebuildScope: "clone-publication",
runState: "queued",
updatedAt: NOW,
}) as never,
);
const migration = {
changedKind: "retrieval",
checkpoint: "queued",
createdAt: NOW,
id: "migration-retrieval",
knowledgeSpaceId: SPACE_ID,
rebuildScope: "clone-publication",
runState: "queued",
updatedAt: NOW,
} as never;
const request = vi.fn(async () => migration);
const findByCandidate = vi.fn(async () => (request.mock.calls.length === 0 ? null : migration));
const app = profileApp({
knowledgeSpaceManifests: manifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate,
get: async () => null,
request,
requiresMigration: async () => true,
@ -1061,7 +1178,8 @@ describe("knowledge-space profile handler behavior", () => {
changedKind: "retrieval",
id: "migration-retrieval",
});
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledOnce();
expect(findByCandidate).toHaveBeenCalledTimes(2);
capabilityDigest = `sha256:${"e".repeat(64)}`;
const changedCapability = await app.request(`/knowledge-spaces/${SPACE_ID}/retrieval-profile`, {
@ -1073,7 +1191,7 @@ describe("knowledge-space profile handler behavior", () => {
await expect(changedCapability.json()).resolves.toMatchObject({
code: "KNOWLEDGE_SPACE_SETTINGS_CANDIDATE_CONFLICT",
});
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledOnce();
});
it("carries an integrated settings Capability grant into the durable migration", async () => {
@ -1121,6 +1239,7 @@ describe("knowledge-space profile handler behavior", () => {
knowledgeSpaceManifests: manifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request,
requiresMigration: async () => true,
@ -1182,6 +1301,7 @@ describe("knowledge-space profile handler behavior", () => {
knowledgeSpaceManifests: manifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request,
requiresMigration: async () => true,
@ -2403,6 +2523,7 @@ describe("knowledge-space profile failure behavior", () => {
knowledgeSpaceManifests: manifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request: async () => ({}) as never,
requiresMigration: async () => true,
@ -2539,6 +2660,7 @@ describe("knowledge-space profile failure behavior", () => {
knowledgeSpaceManifests: manifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request: async () => ({}) as never,
requiresMigration: async () => true,
@ -2604,6 +2726,7 @@ describe("knowledge-space profile failure behavior", () => {
knowledgeSpaceManifests: manifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request: async () => ({}) as never,
requiresMigration: async () => true,
@ -2653,6 +2776,7 @@ describe("knowledge-space profile failure behavior", () => {
knowledgeSpaceManifests: manifests,
knowledgeSpaceProfileMigrations: {
cancel: async () => null,
findByCandidate: async () => null,
get: async () => null,
request: async () => ({}) as never,
requiresMigration: async () => true,

View File

@ -135,6 +135,42 @@ describe.each(["postgres", "tidb"] as const)(
).toBe(true);
});
it("looks up the latest candidate migration independently of authorization provenance", async () => {
const calls: DatabaseExecuteInput[] = [];
const execute = async (input: DatabaseExecuteInput): Promise<DatabaseExecuteResult> => {
calls.push(input);
return { rows: [runRow()], rowsAffected: 1 };
};
const database = createSchemaDatabaseAdapter({
executor: execute,
kind: dialect,
transaction: async (callback) => callback({ execute }),
});
const repository = createDatabaseKnowledgeSpaceProfileMigrationRepository({
database,
maxClaimBatchSize: 10,
});
await expect(
repository.findLatestByCandidate({
candidateProfileId: candidateId,
knowledgeSpaceId: spaceId,
tenantId,
}),
).resolves.toMatchObject({ id: runId, runState: "queued" });
expect(calls).toHaveLength(1);
expect(calls[0]?.params).toEqual([tenantId, spaceId, candidateId]);
expect(calls[0]?.sql).toContain(
dialect === "postgres"
? '"candidate_profile_revision_id"'
: "`candidate_profile_revision_id`",
);
expect(calls[0]?.sql).toContain(
dialect === "postgres" ? '"active_slot" IS NOT NULL' : "`active_slot` IS NOT NULL",
);
});
it("rejects malformed admissions and unsafe claim leases before database access", async () => {
const execute = async (): Promise<DatabaseExecuteResult> => {
throw new Error("database should not be called");

View File

@ -234,6 +234,7 @@ export function createDatabaseKnowledgeSpaceProfileMigrationRepository({
get: (id) => getById(database, database, id, false),
findByRequest: (input) => getByIdempotency(database, database, input, false),
findLatestByCandidate: (input) => getLatestByCandidate(database, database, input),
claim: async (input) => {
positiveInteger(input.limit, "claim.limit");
@ -1135,6 +1136,37 @@ async function getByIdempotency(
return replay;
}
async function getLatestByCandidate(
database: DatabaseAdapter,
executor: DatabaseExecutor,
input: {
readonly candidateProfileId: string;
readonly knowledgeSpaceId: string;
readonly tenantId: string;
},
): Promise<KnowledgeSpaceProfileMigrationRun | null> {
const result = await executor.execute({
maxRows: 1,
operation: "select",
params: [input.tenantId, input.knowledgeSpaceId, input.candidateProfileId],
sql: `SELECT * FROM ${q(database, runTable)} WHERE ${q(database, "tenant_id")} = ${p(
database,
1,
)} AND ${q(database, "knowledge_space_id")} = ${p(database, 2)} AND ${q(
database,
"candidate_profile_revision_id",
)} = ${p(database, 3)} ORDER BY CASE WHEN ${q(
database,
"active_slot",
)} IS NOT NULL THEN 0 ELSE 1 END ASC, ${q(database, "created_at")} DESC, ${q(
database,
"id",
)} DESC LIMIT 1;`,
tableName: runTable,
});
return result.rows[0] ? mapRun(result.rows[0]) : null;
}
function profileMigrationIdempotencyDigest(input: {
readonly capabilityGrantId?: string | undefined;
readonly idempotencyKey: string;

View File

@ -114,6 +114,35 @@ describe("knowledge-space profile migration service behavior", () => {
);
});
it("finds and cancels a candidate migration after capability grant rotation", async () => {
const fixture = serviceFixture();
const run = await fixture.service.request(capabilityRequest());
const rotatedCapabilityGrantId = "70000000-0000-4000-8000-000000000002";
await expect(
fixture.service.findByCandidate({
callerKind: "agent",
candidateProfileId: run.candidateProfile.id,
capabilityGrantId: rotatedCapabilityGrantId,
knowledgeSpaceId: spaceId,
subject,
}),
).resolves.toEqual(run);
await expect(
fixture.service.cancel({
callerKind: "agent",
capabilityGrantId: rotatedCapabilityGrantId,
knowledgeSpaceId: spaceId,
runId: run.id,
subject,
}),
).resolves.toMatchObject({
capabilityGrantId: rotatedCapabilityGrantId,
runState: "canceled",
});
expect(fixture.authorization.authorize).not.toHaveBeenCalled();
});
it("classifies retrieval migrations from reasoning-model compatibility", async () => {
const compatibleCandidate = profileRevision(
"retrieval",

View File

@ -54,6 +54,12 @@ export interface KnowledgeSpaceProfileMigrationService {
readonly runId: string;
},
): Promise<KnowledgeSpaceProfileMigrationRun | null>;
findByCandidate(
input: KnowledgeSpaceProfileMigrationPrincipal & {
readonly candidateProfileId: string;
readonly knowledgeSpaceId: string;
},
): Promise<KnowledgeSpaceProfileMigrationRun | null>;
requiresMigration(input: {
readonly knowledgeSpaceId: string;
readonly tenantId: string;
@ -241,6 +247,14 @@ export function createKnowledgeSpaceProfileMigrationService({
});
},
get: (input) => getAuthorized(input, input.knowledgeSpaceId, input.runId),
findByCandidate: async (input) => {
await authorize(input, input.knowledgeSpaceId);
return repository.findLatestByCandidate({
candidateProfileId: input.candidateProfileId,
knowledgeSpaceId: input.knowledgeSpaceId,
tenantId: input.subject.tenantId,
});
},
cancel: async (input) => {
const run = await getAuthorized(input, input.knowledgeSpaceId, input.runId);
if (!run) return null;

View File

@ -31,6 +31,20 @@ describe("knowledge-space profile migration durable repository", () => {
const input = startInput();
const first = await repository.start(input);
expect(await repository.start(input)).toEqual(first);
await expect(
repository.findLatestByCandidate({
candidateProfileId: input.candidateProfile.id,
knowledgeSpaceId: spaceId,
tenantId,
}),
).resolves.toEqual(first);
await expect(
repository.findLatestByCandidate({
candidateProfileId: input.candidateProfile.id,
knowledgeSpaceId: spaceId,
tenantId: "tenant-other",
}),
).resolves.toBeNull();
await expect(
repository.start({
...input,

View File

@ -149,6 +149,11 @@ export interface KnowledgeSpaceProfileMigrationRepository {
readonly requestedBySubjectId?: string | undefined;
readonly tenantId: string;
}): Promise<KnowledgeSpaceProfileMigrationRun | null>;
findLatestByCandidate(input: {
readonly candidateProfileId: string;
readonly knowledgeSpaceId: string;
readonly tenantId: string;
}): Promise<KnowledgeSpaceProfileMigrationRun | null>;
get(runId: string): Promise<KnowledgeSpaceProfileMigrationRun | null>;
heartbeat(
input: KnowledgeSpaceProfileMigrationFence & {
@ -270,6 +275,21 @@ export function createInMemoryKnowledgeSpaceProfileMigrationRepository({
const id = requestKeys.get(key);
return id ? (runs.get(id) ?? null) : null;
},
findLatestByCandidate: async (input) =>
[...runs.values()]
.filter(
(run) =>
run.tenantId === input.tenantId &&
run.knowledgeSpaceId === input.knowledgeSpaceId &&
run.candidateProfile.id === input.candidateProfileId,
)
.sort(
(left, right) =>
Number(right.runState === "queued" || right.runState === "running") -
Number(left.runState === "queued" || left.runState === "running") ||
right.createdAt.localeCompare(left.createdAt) ||
right.id.localeCompare(left.id),
)[0] ?? null,
claim: async (raw) => {
const now = validDate(raw.now, "claim.now");
const leaseExpiresAt = validDate(raw.leaseExpiresAt, "claim.leaseExpiresAt");

View File

@ -151,6 +151,8 @@ import {
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesResponse,
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPath,
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdResponse,
zGetKnowledgeFsSpacesByControlSpaceIdTagsPath,
zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse,
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsPath,
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsQuery,
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsResponse,
@ -327,6 +329,9 @@ import {
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyBody,
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyPath,
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponse,
zPutKnowledgeFsSpacesByControlSpaceIdTagsBody,
zPutKnowledgeFsSpacesByControlSpaceIdTagsPath,
zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse,
} from './zod.gen'
export const get = oc
@ -2299,6 +2304,38 @@ export const sources = {
}
export const get45 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getKnowledgeFsSpacesByControlSpaceIdTags',
path: '/knowledge-fs/spaces/{control_space_id}/tags',
tags: ['console'],
})
.input(z.object({ params: zGetKnowledgeFsSpacesByControlSpaceIdTagsPath }))
.output(zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse)
export const put5 = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'putKnowledgeFsSpacesByControlSpaceIdTags',
path: '/knowledge-fs/spaces/{control_space_id}/tags',
tags: ['console'],
})
.input(
z.object({
body: zPutKnowledgeFsSpacesByControlSpaceIdTagsBody,
params: zPutKnowledgeFsSpacesByControlSpaceIdTagsPath,
}),
)
.output(zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse)
export const tags = {
get: get45,
put: put5,
}
export const get46 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2315,10 +2352,10 @@ export const get45 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsResponse)
export const conflicts = {
get: get45,
get: get46,
}
export const get46 = oc
export const get47 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2335,10 +2372,10 @@ export const get46 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdEvidenceResponse)
export const evidence = {
get: get46,
get: get47,
}
export const get47 = oc
export const get48 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2355,10 +2392,10 @@ export const get47 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdMissingResponse)
export const missing = {
get: get47,
get: get48,
}
export const get48 = oc
export const get49 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2370,13 +2407,13 @@ export const get48 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdResponse)
export const byTraceId = {
get: get48,
get: get49,
conflicts,
evidence,
missing,
}
export const get49 = oc
export const get50 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2393,7 +2430,7 @@ export const get49 = oc
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesResponse)
export const traces = {
get: get49,
get: get50,
byTraceId,
}
@ -2531,7 +2568,7 @@ export const delete13 = oc
.input(z.object({ params: zDeleteKnowledgeFsSpacesByControlSpaceIdPath }))
.output(zDeleteKnowledgeFsSpacesByControlSpaceIdResponse)
export const get50 = oc
export const get51 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2560,7 +2597,7 @@ export const patch9 = oc
export const byControlSpaceId = {
delete: delete13,
get: get50,
get: get51,
patch: patch9,
appBindings,
backgroundTasks,
@ -2584,11 +2621,12 @@ export const byControlSpaceId = {
sourceProviders,
sourceWorkflows,
sources,
tags,
traces,
uploadSessions,
}
export const get51 = oc
export const get52 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -2612,7 +2650,7 @@ export const post40 = oc
.output(zPostKnowledgeFsSpacesResponse)
export const spaces = {
get: get51,
get: get52,
post: post40,
byControlSpaceId,
}

View File

@ -1084,6 +1084,14 @@ export type KnowledgeFsSourceWorkflowImportPayload =
kind: 'online-drive-import'
} & KnowledgeFsOnlineDriveWorkflowImportPayload)
export type KnowledgeFsSpaceTagListResponse = {
data: Array<KnowledgeFsSpaceTagResponse>
}
export type KnowledgeFsSpaceTagsReplacePayload = {
tag_ids?: Array<string>
}
export type KnowledgeFsTraceListResponse = {
data: Array<KnowledgeFsTraceResponse>
next_cursor?: string | null
@ -1220,6 +1228,7 @@ export type KnowledgeFsSpaceListItemResponse = {
permission_keys: Array<KnowledgeFsProductPermission>
resource_version: number
state: KnowledgeFsControlSpaceState
tags?: Array<KnowledgeFsSpaceTagResponse>
technical_status: 'available' | 'not_ready' | 'unavailable'
technical_summary?: KnowledgeFsTechnicalSummary | null
updated_at: string
@ -1905,6 +1914,12 @@ export type KnowledgeFsOnlineDriveWorkflowImportPayload = {
kind: 'online-drive-import'
}
export type KnowledgeFsSpaceTagResponse = {
id: string
name: string
type?: 'knowledge'
}
export type KnowledgeFsTraceResponse = {
completed: boolean
created_at: string
@ -2241,6 +2256,7 @@ export type GetKnowledgeFsSpacesData = {
limit?: number
page?: number
query?: string
tag_ids?: Array<string>
}
url: '/knowledge-fs/spaces'
}
@ -4012,6 +4028,38 @@ export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImport
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdTagsData = {
body?: never
path: {
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/tags'
}
export type GetKnowledgeFsSpacesByControlSpaceIdTagsResponses = {
200: KnowledgeFsSpaceTagListResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdTagsResponse =
GetKnowledgeFsSpacesByControlSpaceIdTagsResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdTagsResponses]
export type PutKnowledgeFsSpacesByControlSpaceIdTagsData = {
body: KnowledgeFsSpaceTagsReplacePayload
path: {
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/tags'
}
export type PutKnowledgeFsSpacesByControlSpaceIdTagsResponses = {
200: KnowledgeFsSpaceTagListResponse
}
export type PutKnowledgeFsSpacesByControlSpaceIdTagsResponse =
PutKnowledgeFsSpacesByControlSpaceIdTagsResponses[keyof PutKnowledgeFsSpacesByControlSpaceIdTagsResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdTracesData = {
body?: never
path: {

View File

@ -549,6 +549,13 @@ export const zKnowledgeFsSourceSyncPolicyPayload = z.object({
mode: z.enum(['custom', 'interval', 'manual', 'provider']),
})
/**
* KnowledgeFSSpaceTagsReplacePayload
*/
export const zKnowledgeFsSpaceTagsReplacePayload = z.object({
tag_ids: z.array(z.string()).max(100).optional(),
})
/**
* KnowledgeFSUploadSessionCreatePayload
*/
@ -867,34 +874,6 @@ export const zKnowledgeFsSpaceDetailResponse = z.object({
visibility: zKnowledgeFsControlSpaceVisibility,
})
/**
* KnowledgeFSSpaceListItemResponse
*/
export const zKnowledgeFsSpaceListItemResponse = z.object({
control_space_id: z.string(),
created_at: z.iso.datetime(),
knowledge_space_id: z.string().nullable(),
linked_apps: z.int().gte(0),
owner_account_id: z.string(),
permission_keys: z.array(zKnowledgeFsProductPermission),
resource_version: z.int(),
state: zKnowledgeFsControlSpaceState,
technical_status: z.enum(['available', 'not_ready', 'unavailable']),
technical_summary: zKnowledgeFsTechnicalSummary.nullish(),
updated_at: z.iso.datetime(),
visibility: zKnowledgeFsControlSpaceVisibility,
})
/**
* KnowledgeFSSpaceListResponse
*/
export const zKnowledgeFsSpaceListResponse = z.object({
data: z.array(zKnowledgeFsSpaceListItemResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
})
/**
* KnowledgeFSAppSpaceJoinType
*/
@ -1997,6 +1976,51 @@ export const zKnowledgeFsSourceImportFilesPayload = z.object({
files: z.array(zKnowledgeFsSourceImportFilePayload).min(1).max(200),
})
/**
* KnowledgeFSSpaceTagResponse
*/
export const zKnowledgeFsSpaceTagResponse = z.object({
id: z.string(),
name: z.string(),
type: z.literal('knowledge').optional().default('knowledge'),
})
/**
* KnowledgeFSSpaceTagListResponse
*/
export const zKnowledgeFsSpaceTagListResponse = z.object({
data: z.array(zKnowledgeFsSpaceTagResponse),
})
/**
* KnowledgeFSSpaceListItemResponse
*/
export const zKnowledgeFsSpaceListItemResponse = z.object({
control_space_id: z.string(),
created_at: z.iso.datetime(),
knowledge_space_id: z.string().nullable(),
linked_apps: z.int().gte(0),
owner_account_id: z.string(),
permission_keys: z.array(zKnowledgeFsProductPermission),
resource_version: z.int(),
state: zKnowledgeFsControlSpaceState,
tags: z.array(zKnowledgeFsSpaceTagResponse).optional(),
technical_status: z.enum(['available', 'not_ready', 'unavailable']),
technical_summary: zKnowledgeFsTechnicalSummary.nullish(),
updated_at: z.iso.datetime(),
visibility: zKnowledgeFsControlSpaceVisibility,
})
/**
* KnowledgeFSSpaceListResponse
*/
export const zKnowledgeFsSpaceListResponse = z.object({
data: z.array(zKnowledgeFsSpaceListItemResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
})
/**
* KnowledgeFSAnswerTraceStepResponse
*/
@ -2837,6 +2861,7 @@ export const zGetKnowledgeFsSpacesQuery = z.object({
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
query: z.string().max(255).optional(),
tag_ids: z.array(z.string().min(1).max(255)).max(100).optional(),
})
/**
@ -4182,6 +4207,26 @@ export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImpo
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponse =
zKnowledgeFsSourceWorkflowResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdTagsPath = z.object({
control_space_id: z.string(),
})
/**
* KnowledgeFS space tags
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse = zKnowledgeFsSpaceTagListResponse
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsBody = zKnowledgeFsSpaceTagsReplacePayload
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsPath = z.object({
control_space_id: z.string(),
})
/**
* KnowledgeFS space tags replaced
*/
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse = zKnowledgeFsSpaceTagListResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdTracesPath = z.object({
control_space_id: z.string(),
})

View File

@ -199,6 +199,7 @@ const taskApiResponse = vi.hoisted(() => (item: BackgroundTask) => ({
document_revision: item.documentRevision ?? null,
error_code: item.errorCode ?? null,
error_message: item.errorMessage ?? null,
failure: item.failure ?? null,
id: item.id,
knowledge_space_id: item.knowledgeSpaceId,
operation: item.operation ?? 'document_processing',
@ -1010,6 +1011,46 @@ describe('DocumentsPage', () => {
expect(screen.queryByText('Ready handbook.pdf')).not.toBeInTheDocument()
})
it('reveals the latest document task failure reason from the failed status', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document({ id: 'failed-document', title: 'Failed report.pdf' })] }],
}
tasksQuery.data = {
pages: [
{
items: [
task({
documentId: 'failed-document',
failure: {
action: 'configure_model',
category: 'configuration',
code: 'MODEL_SELECTION_NOT_FOUND',
message: 'Select another model.',
retryPolicy: 'after_configuration',
},
id: 'failed-task',
state: 'failed',
}),
],
},
],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const failedStatus = screen.getByRole('button', {
name: 'dataset.newKnowledge.documentStatus.failed: dataset.newKnowledge.taskFailure.configuration',
})
expect(screen.queryByText('dataset.newKnowledge.taskFailure.configuration')).toBeNull()
await user.hover(failedStatus)
expect(
await screen.findByText('dataset.newKnowledge.taskFailure.configuration'),
).toBeInTheDocument()
})
it('downloads the active revision from the document action menu', async () => {
const user = userEvent.setup()
documentsQuery.data = {

View File

@ -1264,7 +1264,7 @@ describe('KnowledgeSettingsForm', () => {
).not.toHaveAttribute('aria-disabled', 'true')
})
it('keeps API access available while model validation is pending', () => {
it('keeps API access available without showing an idle pending-validation status', () => {
renderForm({
settings: {
...settings,
@ -1280,7 +1280,7 @@ describe('KnowledgeSettingsForm', () => {
expect(apiAccessSwitch).toHaveAccessibleDescription(
'dataset.newKnowledge.settings.apiAccessDescription',
)
expect(screen.getByRole('status')).toHaveTextContent('common.provider.validating')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows a recovery alert when initial model validation fails', () => {

View File

@ -18,6 +18,7 @@ type KnowledgeSpaceList = {
revision: number
slug: string
tenantId: string
tags?: Array<{ id: string; name: string }>
updatedAt: string
documentCount?: number
}>
@ -33,6 +34,7 @@ type ListKnowledgeSpacesInfiniteOptions = {
limit: number
page: number
query?: string
tag_ids?: string[]
}
}
}
@ -57,6 +59,7 @@ const knowledgeSpaceApiResponse = vi.hoisted(
permission_keys: space.permissionKeys ?? ['knowledge_space_read'],
resource_version: space.revision,
state: 'active',
tags: space.tags?.map((tag) => ({ ...tag, type: 'knowledge' as const })),
technical_status: 'available',
technical_summary: {
description: space.description ?? null,
@ -213,6 +216,35 @@ vi.mock('@/features/system-features/state', () => ({
knowledgeFsUploadEnabledAtom: systemFeaturesStateMock.knowledgeFsUploadEnabledAtom,
}))
vi.mock('@/features/tag-management/components/tag-filter', () => ({
TagFilter: ({ onChange, value }: { onChange: (value: string[]) => void; value: string[] }) => (
<button
type="button"
aria-label="dataset.newKnowledge.tags"
onClick={() => onChange(value.length ? [] : ['tag-1', 'tag-2'])}
>
dataset.newKnowledge.tags
</button>
),
}))
vi.mock('@/features/tag-management/components/tag-management-modal', () => ({
TagManagementModal: () => null,
}))
vi.mock('../components/knowledge-space-card-tags', () => ({
KnowledgeSpaceCardTags: ({
knowledgeSpace,
}: {
knowledgeSpace: { tags?: Array<{ id: string; name: string }> }
}) => (
<div>
<span>dataset.newKnowledge.tags</span>
{knowledgeSpace.tags?.map((tag) => tag.name).join(', ')}
</div>
),
}))
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({}),
}))
@ -308,6 +340,10 @@ describe('NewKnowledgeList', () => {
name: 'Support knowledge',
revision: 1,
slug: 'support-knowledge',
tags: [
{ id: 'tag-1', name: 'Customer support' },
{ id: 'tag-2', name: 'Public docs' },
],
tenantId: 'tenant-1',
updatedAt: '2026-07-18T00:00:00Z',
},
@ -327,6 +363,8 @@ describe('NewKnowledgeList', () => {
const supportCard = within(list).getByRole('link', {
name: 'Support knowledge',
})
const supportCardItem = supportCard.closest('li')
expect(supportCardItem).not.toBeNull()
expect(supportCard).toHaveAttribute('href', '/datasets/new/space-1')
expect(supportCard).toBeInTheDocument()
expect(
@ -339,7 +377,8 @@ describe('NewKnowledgeList', () => {
expect(within(supportCard).getByLabelText('camera')).toBeInTheDocument()
expect(within(list).getAllByText('dataset.newKnowledge.cardType')).toHaveLength(2)
expect(within(list).getAllByText('dataset.newKnowledge.tags')).toHaveLength(2)
expect(within(supportCard).getByText('12')).toBeInTheDocument()
expect(within(list).getByText('Customer support, Public docs')).toBeInTheDocument()
expect(within(supportCardItem!).getByText('12')).toBeInTheDocument()
expect(supportCard).toHaveAccessibleDescription('dataset.newKnowledge.overview.linkedApps: 0')
expect(
within(list).getByRole('link', { name: 'Engineering handbook' }),
@ -453,7 +492,7 @@ describe('NewKnowledgeList', () => {
expect(toastMock.success).toHaveBeenCalledWith('dataset.datasetDeleted')
})
it('keeps backend-dependent metadata filters interactive and sends search to the collection API', async () => {
it('syncs tag filters to the URL and collection API while keeping search interactive', async () => {
const user = userEvent.setup()
setResolvedPage([
{
@ -493,13 +532,23 @@ describe('NewKnowledgeList', () => {
expect(tags).toBeEnabled()
expect(creators).toBeEnabled()
await user.click(tags)
expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable')
await waitFor(() => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tag_ids')).toBe('tag-1;tag-2')
})
let options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
expect(options?.input(1)).toEqual({
query: { limit: 30, page: 1, tag_ids: ['tag-1', 'tag-2'] },
})
await user.click(tags)
await waitFor(() => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('tag_ids')).toBe(false)
})
expect(search).toBeEnabled()
expect(create).toHaveAttribute('href', '/datasets/new/create')
await user.type(search, 'customer support')
await waitFor(() => {
const options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
expect(options?.input(1)).toEqual({
query: { limit: 30, page: 1, query: 'customer support' },
})
@ -507,6 +556,19 @@ describe('NewKnowledgeList', () => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('query')).toBe('customer support')
})
it('restores tag filters from the URL and sends match-any IDs to the collection API', () => {
setResolvedPage()
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />, {
searchParams: '?tag_ids=tag-1%3Btag-2',
})
const options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
expect(options?.input(1)).toEqual({
query: { limit: 30, page: 1, tag_ids: ['tag-1', 'tag-2'] },
})
})
it('restores server search from the URL and shows its empty state', async () => {
const user = userEvent.setup()
setResolvedPage()

View File

@ -426,7 +426,12 @@ describe('RetrievalTestPage', () => {
expect(apiMock.queryAdmission).not.toHaveBeenCalled()
expect(apiMock.planResearch).not.toHaveBeenCalled()
expect(apiMock.createResearch).not.toHaveBeenCalled()
const dialog = screen.getByRole('dialog', { name: 'common.provider.validating' })
const dialog = screen.getByRole('dialog', {
name: 'dataset.newKnowledge.overview.attention.modelReadiness.pendingTitle',
})
expect(dialog).toHaveTextContent(
'dataset.newKnowledge.overview.attention.modelReadiness.pendingDescription',
)
expect(dialog).not.toHaveTextContent(
'dataset.newKnowledge.overview.attention.modelReadiness.profilesMissing',
)

View File

@ -118,7 +118,7 @@ describe('KnowledgeModelReadinessBanner', () => {
)
})
it('shows validation progress while keeping the configuration route accessible', () => {
it('stays hidden while model validation is waiting for the first document', () => {
queryState.data = {
...queryState.data,
configuration_state: 'pending-validation',
@ -127,15 +127,7 @@ describe('KnowledgeModelReadinessBanner', () => {
render(<KnowledgeModelReadinessBanner knowledgeSpaceId="space-1" />)
expect(screen.getByRole('status')).toHaveTextContent('common.provider.validating')
expect(
screen.getByRole('link', {
name: 'dataset.newKnowledge.overview.attention.action.configureModels',
}),
).toHaveAttribute(
'href',
'/datasets/new/space-1/settings?returnTo=%2Fdatasets%2Fnew%2Fspace-1%2Fdocuments%3Fstatus%3Dfailed',
)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('keeps a readiness fetch failure separate and retryable', async () => {

View File

@ -0,0 +1,203 @@
import type { KnowledgeFsSpaceListItemResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { KnowledgeSpaceCardTags } from '../knowledge-space-card-tags'
const { invalidateQueries, putTags, toastMock } = vi.hoisted(() => ({
invalidateQueries: vi.fn(),
putTags: vi.fn(),
toastMock: {
error: vi.fn(),
success: vi.fn(),
},
}))
const workspacePermissionKeys = vi.hoisted(() => ({
value: ['dataset.tag.manage'] as string[],
}))
const tagQuery = vi.hoisted(() => ({
data: [] as Tag[],
}))
const knowledgeTags: Tag[] = [
{ binding_count: '1', id: 'tag-1', name: 'Frontend', type: 'knowledge' },
{ binding_count: '1', id: 'tag-2', name: 'Backend', type: 'knowledge' },
{ binding_count: '0', id: 'tag-3', name: 'Public docs', type: 'knowledge' },
]
vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMock }))
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
workspacePermissionKeys: workspacePermissionKeys.value,
}))
})
vi.mock('@/features/tag-management/hooks/use-tag-mutations', () => ({
useApplyTagBindingsMutation: () => ({ mutate: vi.fn() }),
}))
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>
onError?: () => void
onSettled?: () => void
onSuccess?: () => void
}) => ({
isPending: false,
mutate: (input: unknown) => {
Promise.resolve(options.mutationFn?.(input))
.then(
() => options.onSuccess?.(),
() => options.onError?.(),
)
.finally(() => options.onSettled?.())
},
}),
useQuery: () => ({ data: tagQuery.data }),
useQueryClient: () => ({ invalidateQueries }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
knowledgeFs: {
spaces: {
byControlSpaceId: {
tags: {
put: {
mutationOptions: () => ({ mutationFn: putTags }),
},
},
},
get: {
key: () => ['knowledge-fs', 'spaces'],
},
},
},
tags: {
get: {
key: () => ['tags'],
queryOptions: () => ({}),
},
post: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
},
},
}))
function createKnowledgeSpace(
permissionKeys: KnowledgeFsSpaceListItemResponse['permission_keys'],
): KnowledgeFsSpaceListItemResponse {
return {
control_space_id: 'space-1',
created_at: '2026-08-13T00:00:00Z',
knowledge_space_id: 'knowledge-space-1',
linked_apps: 0,
owner_account_id: 'account-1',
permission_keys: permissionKeys,
resource_version: 1,
state: 'active',
tags: [
{ id: 'tag-1', name: 'Frontend', type: 'knowledge' },
{ id: 'tag-2', name: 'Backend', type: 'knowledge' },
],
technical_status: 'available',
updated_at: '2026-08-13T00:00:00Z',
visibility: 'only_me',
}
}
describe('KnowledgeSpaceCardTags', () => {
beforeEach(() => {
vi.clearAllMocks()
putTags.mockResolvedValue({ data: [] })
invalidateQueries.mockResolvedValue(undefined)
workspacePermissionKeys.value = ['dataset.tag.manage']
tagQuery.data = knowledgeTags
})
it('shows tags returned with the knowledge space', () => {
tagQuery.data = []
render(
<KnowledgeSpaceCardTags
knowledgeSpace={createKnowledgeSpace(['knowledge_space_read'])}
onOpenTagManagement={vi.fn()}
/>,
)
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('Backend')).toBeInTheDocument()
})
it('submits the final tag set and refreshes the list and binding counts', async () => {
const user = userEvent.setup()
render(
<KnowledgeSpaceCardTags
knowledgeSpace={createKnowledgeSpace(['knowledge_space_edit', 'knowledge_space_read'])}
onOpenTagManagement={vi.fn()}
/>,
)
const trigger = screen.getByRole('combobox', { name: 'Frontend, Backend' })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: 'Frontend' }))
await user.click(screen.getByRole('option', { name: 'Public docs' }))
await user.click(trigger)
await waitFor(() => {
expect(putTags).toHaveBeenCalledWith({
body: { tag_ids: ['tag-2', 'tag-3'] },
params: { control_space_id: 'space-1' },
})
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['knowledge-fs', 'spaces'],
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['tags'] })
expect(toastMock.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
})
it('can clear every tag from an editable knowledge space', async () => {
const user = userEvent.setup()
render(
<KnowledgeSpaceCardTags
knowledgeSpace={createKnowledgeSpace(['knowledge_space_edit', 'knowledge_space_read'])}
onOpenTagManagement={vi.fn()}
/>,
)
const trigger = screen.getByRole('combobox', { name: 'Frontend, Backend' })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: 'Frontend' }))
await user.click(screen.getByRole('option', { name: 'Backend' }))
await user.click(trigger)
await waitFor(() => {
expect(putTags).toHaveBeenCalledWith({
body: { tag_ids: [] },
params: { control_space_id: 'space-1' },
})
})
})
it('does not allow binding changes without space edit permission', () => {
render(
<KnowledgeSpaceCardTags
knowledgeSpace={createKnowledgeSpace(['knowledge_space_read'])}
onOpenTagManagement={vi.fn()}
/>,
)
expect(screen.getByRole('combobox', { name: 'Frontend, Backend' })).toBeDisabled()
expect(putTags).not.toHaveBeenCalled()
})
})

View File

@ -34,10 +34,12 @@ export function KnowledgeModelReadinessBanner({
}),
)
const readiness = query.data
const isPendingValidation = readiness?.configuration_state === 'pending-validation'
const requestedCapabilityAvailable =
capability !== undefined && readiness?.capabilities[capability] === true
if (
query.isPending ||
isPendingValidation ||
(!query.isError &&
(requestedCapabilityAvailable ||
(capability === undefined &&
@ -46,21 +48,17 @@ export function KnowledgeModelReadinessBanner({
)
return null
const isPendingValidation = readiness?.configuration_state === 'pending-validation'
const isFailure = query.isError || readiness?.configuration_state === 'validation-failed'
const title = query.isError
? tCommon(($) => $['api.actionFailed'])
: isPendingValidation
? tCommon(($) => $['provider.validating'])
: readiness?.configuration_state === 'validation-failed'
? tCommon(($) => $['api.actionFailed'])
: t(($) => $['newKnowledge.overview.attention.modelReadiness.title'])
const description =
query.isError || isPendingValidation
? undefined
: readiness?.active_profile_available
? t(($) => $['newKnowledge.overview.attention.modelReadiness.description'])
: t(($) => $['newKnowledge.overview.attention.modelReadiness.profilesMissing'])
: readiness?.configuration_state === 'validation-failed'
? tCommon(($) => $['api.actionFailed'])
: t(($) => $['newKnowledge.overview.attention.modelReadiness.title'])
const description = query.isError
? undefined
: readiness?.active_profile_available
? t(($) => $['newKnowledge.overview.attention.modelReadiness.description'])
: t(($) => $['newKnowledge.overview.attention.modelReadiness.profilesMissing'])
return (
<KnowledgeModelReadinessNotice
@ -81,7 +79,7 @@ export function KnowledgeModelReadinessBanner({
className={className}
description={description}
title={title}
tone={isFailure ? 'destructive' : isPendingValidation ? 'progress' : 'warning'}
tone={isFailure ? 'destructive' : 'warning'}
/>
)
}

View File

@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
export type KnowledgeModelReadinessTone = 'destructive' | 'progress' | 'warning'
export type KnowledgeModelReadinessTone = 'destructive' | 'warning'
export const knowledgeModelReadinessActionClassName =
'shrink-0 cursor-pointer rounded-sm system-sm-medium text-text-accent outline-hidden hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid'
@ -20,17 +20,12 @@ export function KnowledgeModelReadinessNotice({
tone: KnowledgeModelReadinessTone
}) {
const isDestructive = tone === 'destructive'
const isProgress = tone === 'progress'
return (
<div
className={cn(
'flex items-center gap-2.5 overflow-hidden rounded-lg px-3.5 py-2.5',
isDestructive
? 'bg-state-destructive-hover'
: isProgress
? 'bg-state-accent-hover'
: 'bg-state-warning-hover',
isDestructive ? 'bg-state-destructive-hover' : 'bg-state-warning-hover',
className,
)}
role={isDestructive ? 'alert' : 'status'}
@ -39,11 +34,9 @@ export function KnowledgeModelReadinessNotice({
aria-hidden
className={cn(
'size-4 shrink-0',
isProgress
? 'i-ri-loader-2-line animate-spin text-text-accent motion-reduce:animate-none'
: isDestructive
? 'i-ri-error-warning-fill text-text-destructive'
: 'i-ri-error-warning-fill text-text-warning',
isDestructive
? 'i-ri-error-warning-fill text-text-destructive'
: 'i-ri-error-warning-fill text-text-warning',
)}
/>
<p className="min-w-0 flex-1 system-sm-regular text-text-secondary">

View File

@ -11,7 +11,7 @@ function readinessTitle(
tCommon: ReturnType<typeof useTranslation<'common'>>['t'],
) {
if (readiness?.configuration_state === 'pending-validation')
return tCommon(($) => $['provider.validating'])
return t(($) => $['newKnowledge.overview.attention.modelReadiness.pendingTitle'])
if (readiness?.configuration_state === 'validation-failed')
return tCommon(($) => $['api.actionFailed'])
return t(($) => $['newKnowledge.overview.attention.modelReadiness.title'])
@ -40,7 +40,7 @@ export function KnowledgeModelSetupDialog({
}
const pendingValidation = readiness?.configuration_state === 'pending-validation'
const description = pendingValidation
? undefined
? t(($) => $['newKnowledge.overview.attention.modelReadiness.pendingDescription'])
: readiness?.active_profile_available
? t(($) => $['newKnowledge.overview.attention.modelReadiness.description'])
: t(($) => $['newKnowledge.overview.attention.modelReadiness.profilesMissing'])

View File

@ -0,0 +1,67 @@
'use client'
import type { KnowledgeFsSpaceListItemResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { TagSelector } from '@/features/tag-management/components/tag-selector'
import { consoleQuery } from '@/service/client'
export function KnowledgeSpaceCardTags({
knowledgeSpace,
onOpenTagManagement,
}: {
knowledgeSpace: KnowledgeFsSpaceListItemResponse
onOpenTagManagement: () => void
}) {
const { t } = useTranslation('common')
const queryClient = useQueryClient()
const canEdit = knowledgeSpace.permission_keys.includes('knowledge_space_edit')
const tags = useMemo<Tag[]>(
() =>
(knowledgeSpace.tags ?? []).map((tag) => ({
binding_count: '',
id: tag.id,
name: tag.name,
type: 'knowledge',
})),
[knowledgeSpace.tags],
)
const replaceTagsMutation = useMutation({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.tags.put.mutationOptions(),
onError: () => toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'])),
onSuccess: () => toast.success(t(($) => $['actionMsg.modifiedSuccessfully'])),
onSettled: () => {
void queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.spaces.get.key(),
})
void queryClient.invalidateQueries({
queryKey: consoleQuery.tags.get.key({
type: 'query',
input: { query: { type: 'knowledge' } },
}),
})
},
})
return (
<TagSelector
type="knowledge"
targetId={knowledgeSpace.control_space_id}
value={tags}
canBindOrUnbindTags={canEdit}
requiresTargetEditPermission
showProvidedTagNames
onOpenTagManagement={onOpenTagManagement}
onApplyTags={(tagIds) =>
replaceTagsMutation.mutate({
params: { control_space_id: knowledgeSpace.control_space_id },
body: { tag_ids: tagIds },
})
}
className="relative z-1 mx-3 w-auto"
/>
)
}

View File

@ -5,6 +5,7 @@ import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { newKnowledgeOverviewPath } from '../routes'
import { KnowledgeSpaceActions } from './knowledge-space-actions'
import { KnowledgeSpaceCardTags } from './knowledge-space-card-tags'
import { KnowledgeSpaceIcon } from './knowledge-space-icon'
function getBuiltinIconName(iconRef: string | undefined) {
@ -14,13 +15,14 @@ function getBuiltinIconName(iconRef: string | undefined) {
export function KnowledgeSpaceCard({
knowledgeSpace,
onOpenTagManagement,
}: {
knowledgeSpace: KnowledgeFsSpaceListItemResponse
onOpenTagManagement: () => void
}) {
const { t } = useTranslation('dataset')
const { formatTimeFromNow } = useFormatTimeFromNow()
const linkedAppsDescriptionId = useId()
const unavailable = t(($) => $['cornerLabel.unavailable'])
const summary = knowledgeSpace.technical_summary
const name = summary?.name ?? knowledgeSpace.control_space_id
const linkedApps = knowledgeSpace.linked_apps
@ -31,14 +33,14 @@ export function KnowledgeSpaceCard({
: formatTimeFromNow(updatedAt)
return (
<li className="group relative">
<li className="group relative flex h-41.5 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs outline-hidden transition-shadow hover:shadow-md motion-reduce:transition-none">
<Link
href={newKnowledgeOverviewPath(knowledgeSpace.control_space_id)}
aria-label={name}
aria-describedby={linkedAppsDescriptionId}
className="relative flex h-41.5 w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs outline-hidden transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
className="block outline-hidden after:absolute after:inset-0 after:z-0 after:rounded-xl after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-state-accent-solid"
>
<div className="flex w-full items-center gap-3 px-4 pt-4 pb-1.5">
<div className="relative z-1 flex w-full items-center gap-3 px-4 pt-4 pb-1.5">
<div
aria-label={iconName ?? t(($) => $['newKnowledge.cardType'])}
title={iconName}
@ -53,40 +55,35 @@ export function KnowledgeSpaceCard({
</div>
</div>
</div>
<p className="line-clamp-2 min-h-8 w-full px-4 py-0.5 body-xs-regular text-text-tertiary">
<p className="relative z-1 line-clamp-2 min-h-8 w-full px-4 py-0.5 body-xs-regular text-text-tertiary">
{summary?.description || t(($) => $['newKnowledge.noDescription'])}
</p>
<div
aria-label={`${t(($) => $['newKnowledge.tags'])}. ${unavailable}`}
className="mt-1 flex min-w-0 items-center gap-1 px-4"
>
<span className="rounded-md bg-background-section px-1.5 py-0.5 system-2xs-medium-uppercase text-text-disabled">
{t(($) => $['newKnowledge.tags'])}
</span>
<span className="system-2xs-regular text-text-disabled">{unavailable}</span>
</div>
<div className="mt-auto flex w-full min-w-0 items-center gap-2 px-4 pt-1 pb-2.5 system-xs-regular text-text-tertiary">
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
<span aria-hidden className="i-ri-file-text-line size-3.5" />
<span>{summary?.document_count ?? 0}</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
<span aria-hidden className="i-ri-robot-2-line size-3.5" />
<span aria-hidden>{linkedApps}</span>
<span id={linkedAppsDescriptionId} className="sr-only">
{t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps}
</span>
</span>
<span aria-hidden className="text-divider-deep">
/
</span>
<span className="ml-auto min-w-0 truncate text-right">
{t(($) => $['newKnowledge.updated'], {
date: formattedUpdatedAt,
})}
</span>
</div>
</Link>
<KnowledgeSpaceCardTags
knowledgeSpace={knowledgeSpace}
onOpenTagManagement={onOpenTagManagement}
/>
<div className="pointer-events-none relative z-1 mt-auto flex w-full min-w-0 items-center gap-2 px-4 pt-1 pb-2.5 system-xs-regular text-text-tertiary">
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
<span aria-hidden className="i-ri-file-text-line size-3.5" />
<span>{summary?.document_count ?? 0}</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
<span aria-hidden className="i-ri-robot-2-line size-3.5" />
<span aria-hidden>{linkedApps}</span>
<span id={linkedAppsDescriptionId} className="sr-only">
{t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps}
</span>
</span>
<span aria-hidden className="text-divider-deep">
/
</span>
<span className="ml-auto min-w-0 truncate text-right">
{t(($) => $['newKnowledge.updated'], {
date: formattedUpdatedAt,
})}
</span>
</div>
<KnowledgeSpaceActions knowledgeSpace={knowledgeSpace} />
</li>
)

View File

@ -16,6 +16,12 @@ import {
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
Select,
SelectContent,
@ -55,6 +61,59 @@ const statusTextClass: Record<DocumentDisplayStatus, string> = {
disabled: 'font-medium text-text-tertiary',
}
function DocumentStatus({
failureReason,
status,
}: {
failureReason?: string
status: DocumentDisplayStatus
}) {
const { t } = useTranslation('dataset')
const statusLabel = t(($) => $[`newKnowledge.documentStatus.${status}`])
const content = (
<>
<span
aria-hidden
className={cn(status === 'processing' ? 'size-4' : 'size-3.5', statusIconClass[status])}
/>
{statusLabel}
</>
)
const className = cn(
'inline-flex items-center gap-1.5 text-xs leading-4',
statusTextClass[status],
)
if (status !== 'failed' || !failureReason) return <span className={className}>{content}</span>
return (
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
render={
<button
type="button"
aria-label={`${statusLabel}: ${failureReason}`}
className={cn(
className,
'rounded-sm text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)}
>
{content}
</button>
}
/>
<PopoverContent placement="top" popupClassName="max-w-80 px-3 py-2">
<PopoverDescription className="system-xs-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{failureReason}
</PopoverDescription>
</PopoverContent>
</Popover>
)
}
function TaskTrigger({
activeTaskCount,
attentionTaskBadge,
@ -113,6 +172,7 @@ const DocumentRow = memo(
({
document,
documentHref,
failureReason,
formatTimeFromNow,
canDownload,
onDownload,
@ -135,6 +195,7 @@ const DocumentRow = memo(
canDownload: boolean
document: LogicalDocument
documentHref: string
failureReason?: string
formatTimeFromNow: (time: number) => string
onDownload: (documentId: string) => Promise<boolean>
onRemove: (documentId: string) => Promise<boolean>
@ -216,21 +277,7 @@ const DocumentRow = memo(
<span className="sr-only">{tCommon(($) => $.loading)}</span>
</span>
) : (
<span
className={cn(
'inline-flex items-center gap-1.5 text-xs leading-4',
statusTextClass[status],
)}
>
<span
aria-hidden
className={cn(
status === 'processing' ? 'size-4' : 'size-3.5',
statusIconClass[status],
)}
/>
{t(($) => $[`newKnowledge.documentStatus.${status}`])}
</span>
<DocumentStatus failureReason={failureReason} status={status} />
)}
</td>
<td className="hidden w-43.5 pr-6 align-middle system-xs-regular text-text-tertiary lg:table-cell">
@ -325,6 +372,7 @@ export function DocumentsList({
canUpload,
completingResults,
documents,
failureReasons,
filter,
getDocumentHref,
hasNextPage,
@ -374,6 +422,7 @@ export function DocumentsList({
canUpload: boolean
completingResults: boolean
documents: LogicalDocument[]
failureReasons: Map<string, string>
filter: DocumentFilter
getDocumentHref: (documentId: string) => string
hasNextPage: boolean
@ -562,6 +611,7 @@ export function DocumentsList({
canDownload={canDownload}
document={document}
documentHref={getDocumentHref(document.id)}
failureReason={failureReasons.get(document.id)}
formatTimeFromNow={formatTimeFromNow}
onDownload={onDownloadDocument}
onRemove={onRemoveDocument}

View File

@ -59,6 +59,7 @@ import {
} from './document-models'
import { DocumentUploadForm } from './document-upload-form'
import { documentUploadIssue } from './document-upload-policy'
import { knowledgeFsTaskFailureMessageKey } from './knowledge-fs-task-error'
import {
discardKnowledgeFsStagedUpload,
stageKnowledgeFsDocument,
@ -785,6 +786,29 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
),
[documents, taskByDocument],
)
const documentFailureReasons = useMemo(
() =>
new Map(
documents.flatMap((document) => {
if (documentStatuses.get(document.id) !== 'failed') return []
const task = taskByDocument.get(document.id)
const messageKey =
knowledgeFsTaskFailureMessageKey(
task?.failure,
task?.errorCode ?? (task?.errorMessage ? 'LEGACY_TASK_FAILURE' : undefined),
) ?? 'newKnowledge.taskFailure.internal'
const message = t(($) => $[messageKey])
const reference =
task?.failure?.traceId && task.failure.category === 'internal'
? t(($) => $['newKnowledge.taskFailure.reference'], {
traceId: task.failure.traceId,
})
: undefined
return [[document.id, reference ? `${message}\n${reference}` : message] as const]
}),
),
[documentStatuses, documents, t, taskByDocument],
)
const filterActive = filter !== 'all' || Boolean(search.trim())
const statusFilterActive = filter !== 'all'
const availableDocumentIds = useMemo(
@ -2849,6 +2873,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
canUpload={canUpload}
completingResults={completingFilteredResults}
documents={filteredDocuments}
failureReasons={documentFailureReasons}
filter={filter}
getDocumentHref={(documentId) =>
newKnowledgeDocumentDetailPath(knowledgeSpaceId, documentId)

View File

@ -853,32 +853,25 @@ export function KnowledgeSettingsForm({
</div>
)}
{(settings.configuration_state !== 'active' || settings.issues.length > 0) && (
<KnowledgeModelReadinessNotice
className="mb-3"
description={
settings.issues.length > 0
? settings.issues.map(({ field }) => readinessFieldLabel(field)).join(' · ')
: settings.active_profile_available
? t(($) => $['newKnowledge.overview.attention.modelReadiness.description'])
: undefined
}
title={
settings.configuration_state === 'pending-validation'
? tCommon(($) => $['provider.validating'])
: settings.configuration_state === 'validation-failed'
{settings.configuration_state !== 'pending-validation' &&
(settings.configuration_state !== 'active' || settings.issues.length > 0) && (
<KnowledgeModelReadinessNotice
className="mb-3"
description={
settings.issues.length > 0
? settings.issues.map(({ field }) => readinessFieldLabel(field)).join(' · ')
: settings.active_profile_available
? t(($) => $['newKnowledge.overview.attention.modelReadiness.description'])
: undefined
}
title={
settings.configuration_state === 'validation-failed'
? tCommon(($) => $['api.actionFailed'])
: tCommon(($) => $['modelProvider.toBeConfigured'])
}
tone={
settings.configuration_state === 'pending-validation'
? 'progress'
: settings.configuration_state === 'validation-failed'
? 'destructive'
: 'warning'
}
/>
)}
}
tone={settings.configuration_state === 'validation-failed' ? 'destructive' : 'warning'}
/>
)}
{saveErrorSlice && (
<div

View File

@ -1,8 +1,7 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useAtomValue } from 'jotai'
import { createParser, parseAsString, useQueryState } from 'nuqs'
@ -13,6 +12,8 @@ import ExternalAPIPanel from '@/app/components/datasets/external-api/external-ap
import ServiceApi from '@/app/components/datasets/extra-info/service-api'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
@ -32,6 +33,8 @@ import {
} from './components/new-knowledge-list-states'
const PAGE_SIZE = 30
const TAG_FILTER_MAX_ID_LENGTH = 255
const TAG_FILTER_MAX_SELECTION = 100
const searchParser = parseAsString.withDefault('').withOptions({
history: 'replace',
})
@ -51,6 +54,21 @@ const creatorIdsParser = createParser<string[]>({
.withDefault([])
.withOptions({ history: 'push' })
function normalizeTagIds(tagIds: string[]) {
return [...new Set(tagIds)]
.filter((tagId) => tagId.length > 0 && tagId.length <= TAG_FILTER_MAX_ID_LENGTH)
.slice(0, TAG_FILTER_MAX_SELECTION)
}
const tagIdsParser = createParser<string[]>({
eq: (left, right) =>
left.length === right.length && left.every((tagId, index) => tagId === right[index]),
parse: (query) => normalizeTagIds(query.split(';')),
serialize: (tagIds) => normalizeTagIds(tagIds).join(';'),
})
.withDefault([])
.withOptions({ history: 'push' })
function isUnavailableError(error: unknown) {
if (!error || typeof error !== 'object') return false
const status = 'status' in error ? error.status : undefined
@ -62,19 +80,6 @@ function isUnavailableError(error: unknown) {
return dataStatus === 404 || dataStatus === 503
}
function MetadataFilter({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Button
variant="tertiary"
className="gap-0 border-0 bg-components-input-bg-normal px-2 font-normal text-text-tertiary shadow-none hover:bg-components-input-bg-hover"
onClick={onClick}
>
<span className="px-1 system-sm-regular">{label}</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
</Button>
)
}
export function NewKnowledgeList({
view,
onViewChange,
@ -84,18 +89,19 @@ export function NewKnowledgeList({
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
const [showExternalApiPanel, setShowExternalApiPanel] = useState(false)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom)
const canCreate = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
const canConnect = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
const filtersUnavailable = t(($) => $['newKnowledge.filtersUnavailable'])
const showFilterBoundary = () => toast.info(filtersUnavailable)
const createLabel = tCommon(($) => $['operation.create'])
const [searchValue, setSearchValue] = useQueryState('query', searchParser)
const debouncedSearchValue = useDebounce(searchValue.trim(), { wait: 300 })
const [creatorIds, setCreatorIds] = useQueryState('creator_ids', creatorIdsParser)
const [tagIds, setTagIds] = useQueryState('tag_ids', tagIdsParser)
const knowledgeSpacesQuery = useInfiniteQuery(
consoleQuery.knowledgeFs.spaces.get.infiniteOptions({
input: (pageParam) => ({
@ -103,6 +109,7 @@ export function NewKnowledgeList({
limit: PAGE_SIZE,
page: pageParam,
...(creatorIds.length > 0 ? { creator_ids: creatorIds } : {}),
...(tagIds.length > 0 ? { tag_ids: tagIds } : {}),
...(debouncedSearchValue ? { query: debouncedSearchValue } : {}),
},
}),
@ -145,7 +152,12 @@ export function NewKnowledgeList({
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<MetadataFilter label={t(($) => $['newKnowledge.tags'])} onClick={showFilterBoundary} />
<TagFilter
type="knowledge"
value={tagIds}
onChange={(nextTagIds) => void setTagIds(nextTagIds)}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
<CreatorFilter
value={creatorIds}
onChange={(nextCreatorIds) => void setCreatorIds(nextCreatorIds)}
@ -209,7 +221,7 @@ export function NewKnowledgeList({
}
/>
</div>
) : knowledgeSpaces.length === 0 && creatorIds.length === 0 ? (
) : knowledgeSpaces.length === 0 && creatorIds.length === 0 && tagIds.length === 0 ? (
<NewKnowledgeEmptyState
canConnect={canConnect}
canCreate={canCreate}
@ -218,7 +230,7 @@ export function NewKnowledgeList({
) : knowledgeSpaces.length === 0 ? (
<div className="flex min-h-105 items-center justify-center px-6 text-center text-text-tertiary">
{tCommon(($) => $['operation.noSearchResults'], {
content: t(($) => $['newKnowledge.creators']),
content: t(($) => $.knowledge),
})}
</div>
) : (
@ -228,6 +240,7 @@ export function NewKnowledgeList({
<KnowledgeSpaceCard
key={knowledgeSpace.control_space_id}
knowledgeSpace={knowledgeSpace}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
))}
</ul>
@ -251,6 +264,16 @@ export function NewKnowledgeList({
</div>
)}
</div>
<TagManagementModal
type="knowledge"
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}
onTagsChange={() => {
void queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.spaces.get.key(),
})
}}
/>
{showExternalApiPanel && canConnect && (
<ExternalAPIPanel
canManageExternalKnowledgeApi={canConnect}

View File

@ -139,6 +139,7 @@ export const TagFilter = ({
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
canBindOrUnbindTags
onOpenTagManagement={onOpenTagManagement}
onClose={() => setOpen(false)}
/>

View File

@ -26,6 +26,7 @@ type TagSearchContentProps = {
onOpenTagManagement?: () => void
onClose?: () => void
canBindOrUnbindTags?: boolean
requiresTargetEditPermission?: boolean
}
export const TagSearchContent = ({
@ -35,10 +36,14 @@ export const TagSearchContent = ({
onOpenTagManagement,
onClose,
canBindOrUnbindTags = false,
requiresTargetEditPermission = false,
}: TagSearchContentProps) => {
const { t } = useTranslation()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
const canChangeBindings = requiresTargetEditPermission
? canBindOrUnbindTags
: canBindOrUnbindTags || canManageTags
const filteredItems = useComboboxFilteredItems<TagComboboxItem>()
const realItemCount = filteredItems.filter((tag) => !isCreateTagOption(tag)).length
const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || ''
@ -93,11 +98,7 @@ export const TagSearchContent = ({
}
return (
<ComboboxItem
key={tag.id}
value={tag}
disabled={!canBindOrUnbindTags && !canManageTags}
>
<ComboboxItem key={tag.id} value={tag} disabled={!canChangeBindings}>
<ComboboxItemText title={tag.name}>{tag.name}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>

View File

@ -64,8 +64,11 @@ export type TagSelectorProps = TagSelectorRootProps &
type: TagType
value: Tag[]
canBindOrUnbindTags?: boolean
requiresTargetEditPermission?: boolean
showProvidedTagNames?: boolean
onOpenTagManagement?: () => void
onTagsChange?: () => void
onApplyTags?: (tagIds: string[]) => void
}
export const TagSelector = ({
@ -73,10 +76,13 @@ export const TagSelector = ({
type,
value,
canBindOrUnbindTags,
requiresTargetEditPermission = false,
showProvidedTagNames = false,
className,
onClick,
onOpenTagManagement = () => {},
onTagsChange,
onApplyTags,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
@ -92,6 +98,9 @@ export const TagSelector = ({
const [inputValue, setInputValue] = useState('')
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
const canChangeBindings = requiresTargetEditPermission
? !!canBindOrUnbindTags
: !!canBindOrUnbindTags || canManageTags
const applyTagBindingsMutation = useApplyTagBindingsMutation()
const { isPending: isCreatingTag, mutate: createTag } = useMutation(
@ -113,10 +122,10 @@ export const TagSelector = ({
const tagNameById = new Map(tagList.map((tag) => [tag.id, tag.name]))
return value.flatMap((tag) => {
const tagName = tagNameById.get(tag.id)
const tagName = tagNameById.get(tag.id) ?? (showProvidedTagNames ? tag.name : undefined)
return tagName ? [tagName] : []
})
}, [tagList, value])
}, [showProvidedTagNames, tagList, value])
const emptyTriggerLabel = canBindOrUnbindTags
? t(($) => $['tag.addTag'], { ns: 'common' })
: t(($) => $['tag.noTag'], { ns: 'common' })
@ -159,6 +168,11 @@ export const TagSelector = ({
if (!tagSelectionChanged) return
if (onApplyTags) {
onApplyTags(draftTagIds)
return
}
const toastId = `tag-bindings-${type}-${targetId}`
applyTagBindingsMutation.mutate(
@ -190,7 +204,16 @@ export const TagSelector = ({
},
},
)
}, [applyTagBindingsMutation, draftTags, onTagsChange, selectedTagIds, t, targetId, type])
}, [
applyTagBindingsMutation,
draftTags,
onApplyTags,
onTagsChange,
selectedTagIds,
t,
targetId,
type,
])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
@ -259,7 +282,7 @@ export const TagSelector = ({
isItemEqualToValue={isSameTag}
>
<ComboboxTrigger
disabled={!canManageTags && !canBindOrUnbindTags}
disabled={!canChangeBindings}
aria-label={triggerLabel}
className={cn(
'group/tag-area relative h-auto w-full cursor-pointer rounded-lg border-0 bg-transparent p-1 hover:bg-state-base-hover focus-visible:bg-transparent data-disabled:bg-transparent data-disabled:opacity-50 data-disabled:hover:bg-transparent data-popup-open:bg-state-base-hover data-popup-open:hover:bg-state-base-hover',
@ -291,6 +314,7 @@ export const TagSelector = ({
inputValue={inputValue}
onInputValueChange={setInputValue}
canBindOrUnbindTags={canBindOrUnbindTags}
requiresTargetEditPermission={requiresTargetEditPermission}
onOpenTagManagement={onOpenTagManagement}
onClose={() => handleOpenChange(false)}
/>

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "جودة الاستعلام تحتاج إلى مراجعة",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "الفهرس المنشور غير مرتبط بتهيئة النموذج النشطة. أعد إنشاء الفهرس أو نشره.",
"newKnowledge.overview.attention.modelReadiness.description": "ملفات تعريف النماذج المطلوبة مفقودة أو غير مرتبطة بالفهرس المنشور حاليًا.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "سيتم التحقق من النماذج المحددة عند معالجة المستند الأول.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "تكوين النموذج في انتظار التحقق",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "هيّئ نماذج التضمين والاستدلال وإعادة الترتيب المطلوبة وانشرها.",
"newKnowledge.overview.attention.modelReadiness.title": "تهيئة النموذج غير مكتملة",
"newKnowledge.overview.attention.staleSource.description": "لم تتم مزامنة هذا المصدر بنجاح من قبل، أو مضى أكثر من 7 أيام على آخر مزامنة ناجحة.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Abfragequalität muss geprüft werden",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Der veröffentlichte Index ist nicht mit der aktiven Modellkonfiguration verknüpft. Erstellen oder veröffentlichen Sie den Index erneut.",
"newKnowledge.overview.attention.modelReadiness.description": "Erforderliche Modellprofile fehlen oder sind nicht mit dem aktuell veröffentlichten Index verknüpft.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Die ausgewählten Modelle werden beim Verarbeiten des ersten Dokuments validiert.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Die Modellkonfiguration wartet auf die Validierung",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Konfigurieren und veröffentlichen Sie die erforderlichen Embedding-, Reasoning- und Rerank-Modelle.",
"newKnowledge.overview.attention.modelReadiness.title": "Modellkonfiguration ist unvollständig",
"newKnowledge.overview.attention.staleSource.description": "Diese Quelle wurde noch nie erfolgreich synchronisiert oder die letzte erfolgreiche Synchronisierung liegt mehr als 7 Tage zurück.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Query quality needs review",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "The published index is not bound to the active model configuration; rebuild or republish the index.",
"newKnowledge.overview.attention.modelReadiness.description": "Required model profiles are missing or are not bound to the current published index.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "The selected models will be validated when the first document is processed.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Model configuration is awaiting validation",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Configure and publish the required embedding, reasoning, and rerank models.",
"newKnowledge.overview.attention.modelReadiness.title": "Model configuration is incomplete",
"newKnowledge.overview.attention.staleSource.description": "This source has never synced successfully or its last successful sync was more than 7 days ago.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "La calidad de la consulta requiere revisión",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "El índice publicado no está vinculado a la configuración de modelos activa. Vuelve a generar o publicar el índice.",
"newKnowledge.overview.attention.modelReadiness.description": "Faltan perfiles de modelo obligatorios o no están vinculados al índice publicado actual.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Los modelos seleccionados se validarán al procesar el primer documento.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "La configuración del modelo está pendiente de validación",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Configura y publica los modelos obligatorios de embedding, razonamiento y rerank.",
"newKnowledge.overview.attention.modelReadiness.title": "La configuración de modelos está incompleta",
"newKnowledge.overview.attention.staleSource.description": "Esta fuente nunca se ha sincronizado correctamente o su última sincronización correcta fue hace más de 7 días.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "کیفیت پرس‌وجو نیاز به بررسی دارد",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "نمایه منتشرشده به پیکربندی فعال مدل متصل نیست. نمایه را دوباره بسازید یا منتشر کنید.",
"newKnowledge.overview.attention.modelReadiness.description": "پروفایل‌های مدل ضروری وجود ندارند یا به نمایه منتشرشده فعلی متصل نیستند.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "مدل‌های انتخاب‌شده هنگام پردازش اولین سند اعتبارسنجی می‌شوند.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "پیکربندی مدل در انتظار اعتبارسنجی است",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "مدل‌های ضروری تعبیه‌سازی، استدلال و بازرتبه‌بندی را پیکربندی و منتشر کنید.",
"newKnowledge.overview.attention.modelReadiness.title": "پیکربندی مدل ناقص است",
"newKnowledge.overview.attention.staleSource.description": "این منبع هرگز با موفقیت همگام‌سازی نشده یا بیش از ۷ روز از آخرین همگام‌سازی موفق آن گذشته است.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "La qualité de la requête doit être vérifiée",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Lindex publié nest pas lié à la configuration de modèles active. Reconstruisez ou republiez lindex.",
"newKnowledge.overview.attention.modelReadiness.description": "Des profils de modèles requis sont absents ou ne sont pas liés à lindex actuellement publié.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Les modèles sélectionnés seront validés lors du traitement du premier document.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "La configuration des modèles est en attente de validation",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Configurez et publiez les modèles dembedding, de raisonnement et de rerank requis.",
"newKnowledge.overview.attention.modelReadiness.title": "La configuration des modèles est incomplète",
"newKnowledge.overview.attention.staleSource.description": "Cette source na jamais été synchronisée avec succès ou sa dernière synchronisation réussie date de plus de 7 jours.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "क्वेरी की गुणवत्ता की समीक्षा आवश्यक है",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "प्रकाशित इंडेक्स सक्रिय मॉडल कॉन्फ़िगरेशन से जुड़ा नहीं है। इंडेक्स को फिर से बनाएँ या प्रकाशित करें।",
"newKnowledge.overview.attention.modelReadiness.description": "आवश्यक मॉडल प्रोफ़ाइल मौजूद नहीं हैं या वर्तमान प्रकाशित इंडेक्स से जुड़ी नहीं हैं।",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "पहले दस्तावेज़ के प्रोसेस होने पर चुने गए मॉडल सत्यापित किए जाएंगे।",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "मॉडल कॉन्फ़िगरेशन सत्यापन की प्रतीक्षा में है",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "आवश्यक एम्बेडिंग, रीजनिंग और रीरैंक मॉडल कॉन्फ़िगर और प्रकाशित करें।",
"newKnowledge.overview.attention.modelReadiness.title": "मॉडल कॉन्फ़िगरेशन अधूरा है",
"newKnowledge.overview.attention.staleSource.description": "यह स्रोत कभी सफलतापूर्वक सिंक नहीं हुआ या पिछला सफल सिंक 7 दिन से अधिक पहले हुआ था।",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Kualitas kueri perlu ditinjau",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Indeks yang diterbitkan tidak terhubung ke konfigurasi model aktif. Bangun atau terbitkan ulang indeks.",
"newKnowledge.overview.attention.modelReadiness.description": "Profil model wajib tidak tersedia atau tidak terhubung ke indeks yang saat ini diterbitkan.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Model yang dipilih akan divalidasi saat dokumen pertama diproses.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Konfigurasi model menunggu validasi",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Konfigurasikan dan terbitkan model embedding, penalaran, dan rerank yang wajib.",
"newKnowledge.overview.attention.modelReadiness.title": "Konfigurasi model belum lengkap",
"newKnowledge.overview.attention.staleSource.description": "Sumber ini belum pernah berhasil disinkronkan atau sinkronisasi terakhir yang berhasil terjadi lebih dari 7 hari lalu.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "La qualità della query richiede una verifica",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Lindice pubblicato non è associato alla configurazione dei modelli attiva. Ricrea o ripubblica lindice.",
"newKnowledge.overview.attention.modelReadiness.description": "Mancano profili di modello obbligatori oppure non sono associati allindice attualmente pubblicato.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "I modelli selezionati verranno convalidati durante lelaborazione del primo documento.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "La configurazione dei modelli è in attesa di convalida",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Configura e pubblica i modelli di embedding, ragionamento e rerank obbligatori.",
"newKnowledge.overview.attention.modelReadiness.title": "La configurazione dei modelli è incompleta",
"newKnowledge.overview.attention.staleSource.description": "Questa fonte non è mai stata sincronizzata correttamente oppure lultima sincronizzazione riuscita risale a più di 7 giorni fa.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "クエリ品質の確認が必要です",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "公開済みインデックスが現在のモデル設定に関連付けられていません。インデックスを再構築または再公開してください。",
"newKnowledge.overview.attention.modelReadiness.description": "必須のモデルプロファイルが不足しているか、現在の公開済みインデックスに関連付けられていません。",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "選択したモデルは、最初のドキュメントを処理するときに検証されます。",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "モデル設定は検証待ちです",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "必須の Embedding、推論、Rerank モデルを設定して公開してください。",
"newKnowledge.overview.attention.modelReadiness.title": "モデル設定が未完了です",
"newKnowledge.overview.attention.staleSource.description": "このソースは一度も正常に同期されていないか、最後の正常な同期から 7 日以上経過しています。",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "쿼리 품질 검토 필요",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "게시된 인덱스가 활성 모델 구성에 연결되어 있지 않습니다. 인덱스를 다시 빌드하거나 게시하세요.",
"newKnowledge.overview.attention.modelReadiness.description": "필수 모델 프로필이 없거나 현재 게시된 인덱스에 연결되어 있지 않습니다.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "선택한 모델은 첫 번째 문서를 처리할 때 검증됩니다.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "모델 구성이 검증을 기다리고 있습니다",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "필수 임베딩, 추론 및 리랭크 모델을 구성하고 게시하세요.",
"newKnowledge.overview.attention.modelReadiness.title": "모델 구성이 완료되지 않음",
"newKnowledge.overview.attention.staleSource.description": "이 소스는 동기화에 성공한 적이 없거나 마지막 성공 이후 7일이 지났습니다.",

View File

@ -330,6 +330,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "ຄຸນນະພາບຄຳຖາມຕ້ອງການການກວດສອບ",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "ດັດຊະນີທີ່ເຜີຍແຜ່ຍັງບໍ່ໄດ້ເຊື່ອມກັບການຕັ້ງຄ່າໂມເດວທີ່ໃຊ້ງານ. ສ້າງ ຫຼືເຜີຍແຜ່ດັດຊະນີອີກຄັ້ງ.",
"newKnowledge.overview.attention.modelReadiness.description": "ໂປຣໄຟລ໌ໂມເດວທີ່ຈຳເປັນຂາດຫາຍ ຫຼືຍັງບໍ່ໄດ້ເຊື່ອມກັບດັດຊະນີທີ່ເຜີຍແຜ່ປັດຈຸບັນ.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "ໂມເດວທີ່ເລືອກຈະຖືກກວດສອບເມື່ອປະມວນຜົນເອກະສານທຳອິດ.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "ການກຳນົດຄ່າໂມເດວກຳລັງລໍຖ້າການກວດສອບ",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "ຕັ້ງຄ່າ ແລະເຜີຍແຜ່ໂມເດວ embedding, reasoning ແລະ rerank ທີ່ຈຳເປັນ.",
"newKnowledge.overview.attention.modelReadiness.title": "ການຕັ້ງຄ່າໂມເດວຍັງບໍ່ສຳເລັດ",
"newKnowledge.overview.attention.staleSource.description": "ແຫຼ່ງຂໍ້ມູນນີ້ບໍ່ເຄີຍຊິງຄ໌ສຳເລັດ ຫຼືການຊິງຄ໌ສຳເລັດລ່າສຸດຜ່ານມາຫຼາຍກວ່າ 7 ມື້.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Querykwaliteit moet worden gecontroleerd",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "De gepubliceerde index is niet gekoppeld aan de actieve modelconfiguratie. Bouw of publiceer de index opnieuw.",
"newKnowledge.overview.attention.modelReadiness.description": "Vereiste modelprofielen ontbreken of zijn niet gekoppeld aan de huidige gepubliceerde index.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "De geselecteerde modellen worden gevalideerd wanneer het eerste document wordt verwerkt.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "De modelconfiguratie wacht op validatie",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Configureer en publiceer de vereiste embedding-, redeneer- en rerankmodellen.",
"newKnowledge.overview.attention.modelReadiness.title": "Modelconfiguratie is onvolledig",
"newKnowledge.overview.attention.staleSource.description": "Deze bron is nooit met succes gesynchroniseerd of de laatste succesvolle synchronisatie was meer dan 7 dagen geleden.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Jakość zapytania wymaga sprawdzenia",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Opublikowany indeks nie jest powiązany z aktywną konfiguracją modeli. Ponownie utwórz lub opublikuj indeks.",
"newKnowledge.overview.attention.modelReadiness.description": "Brakuje wymaganych profili modeli lub nie są one powiązane z obecnie opublikowanym indeksem.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Wybrane modele zostaną zweryfikowane podczas przetwarzania pierwszego dokumentu.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Konfiguracja modeli oczekuje na weryfikację",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Skonfiguruj i opublikuj wymagane modele embeddingu, wnioskowania i rerankingu.",
"newKnowledge.overview.attention.modelReadiness.title": "Konfiguracja modeli jest niepełna",
"newKnowledge.overview.attention.staleSource.description": "To źródło nigdy nie zostało pomyślnie zsynchronizowane lub od ostatniej udanej synchronizacji minęło ponad 7 dni.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "A qualidade da consulta precisa de revisão",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "O índice publicado não está vinculado à configuração de modelos ativa. Reconstrua ou publique o índice novamente.",
"newKnowledge.overview.attention.modelReadiness.description": "Perfis de modelo obrigatórios estão ausentes ou não estão vinculados ao índice publicado atual.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Os modelos selecionados serão validados quando o primeiro documento for processado.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "A configuração do modelo aguarda validação",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Configure e publique os modelos obrigatórios de embedding, raciocínio e rerank.",
"newKnowledge.overview.attention.modelReadiness.title": "A configuração de modelos está incompleta",
"newKnowledge.overview.attention.staleSource.description": "Esta fonte nunca foi sincronizada com sucesso ou a última sincronização bem-sucedida ocorreu há mais de 7 dias.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Calitatea interogării trebuie verificată",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Indexul publicat nu este asociat configurării active a modelelor. Reconstruiește sau republică indexul.",
"newKnowledge.overview.attention.modelReadiness.description": "Profilurile de model obligatorii lipsesc sau nu sunt asociate indexului publicat în prezent.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Modelele selectate vor fi validate la procesarea primului document.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Configurația modelelor așteaptă validarea",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Configurează și publică modelele obligatorii de embedding, raționament și rerank.",
"newKnowledge.overview.attention.modelReadiness.title": "Configurarea modelelor este incompletă",
"newKnowledge.overview.attention.staleSource.description": "Această sursă nu s-a sincronizat niciodată cu succes sau ultima sincronizare reușită a avut loc acum mai mult de 7 zile.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Качество запроса требует проверки",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Опубликованный индекс не связан с активной конфигурацией моделей. Перестройте или повторно опубликуйте индекс.",
"newKnowledge.overview.attention.modelReadiness.description": "Обязательные профили моделей отсутствуют или не связаны с текущим опубликованным индексом.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Выбранные модели будут проверены при обработке первого документа.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Конфигурация моделей ожидает проверки",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Настройте и опубликуйте обязательные модели эмбеддингов, рассуждений и реранжирования.",
"newKnowledge.overview.attention.modelReadiness.title": "Конфигурация моделей не завершена",
"newKnowledge.overview.attention.staleSource.description": "Этот источник ни разу не синхронизировался успешно или с последней успешной синхронизации прошло более 7 дней.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Kakovost poizvedbe je treba pregledati",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Objavljeni indeks ni povezan z aktivno konfiguracijo modelov. Znova zgradite ali objavite indeks.",
"newKnowledge.overview.attention.modelReadiness.description": "Zahtevani profili modelov manjkajo ali niso povezani s trenutno objavljenim indeksom.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Izbrani modeli bodo preverjeni ob obdelavi prvega dokumenta.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Konfiguracija modelov čaka na preverjanje",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Konfigurirajte in objavite zahtevane modele za embedding, sklepanje in rerank.",
"newKnowledge.overview.attention.modelReadiness.title": "Konfiguracija modelov ni dokončana",
"newKnowledge.overview.attention.staleSource.description": "Ta vir še nikoli ni bil uspešno sinhroniziran ali pa je od zadnje uspešne sinhronizacije minilo več kot 7 dni.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "คุณภาพคิวรีต้องได้รับการตรวจสอบ",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "ดัชนีที่เผยแพร่ไม่ได้เชื่อมโยงกับการกำหนดค่าโมเดลที่ใช้งานอยู่ โปรดสร้างหรือเผยแพร่ดัชนีอีกครั้ง",
"newKnowledge.overview.attention.modelReadiness.description": "โปรไฟล์โมเดลที่จำเป็นขาดหายไปหรือไม่ได้เชื่อมโยงกับดัชนีที่เผยแพร่ในปัจจุบัน",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "โมเดลที่เลือกจะได้รับการตรวจสอบเมื่อประมวลผลเอกสารแรก",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "การกำหนดค่าโมเดลกำลังรอการตรวจสอบ",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "กำหนดค่าและเผยแพร่โมเดล embedding, reasoning และ rerank ที่จำเป็น",
"newKnowledge.overview.attention.modelReadiness.title": "การกำหนดค่าโมเดลยังไม่สมบูรณ์",
"newKnowledge.overview.attention.staleSource.description": "แหล่งข้อมูลนี้ไม่เคยซิงค์สำเร็จ หรือการซิงค์สำเร็จครั้งล่าสุดผ่านมานานกว่า 7 วันแล้ว",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Sorgu kalitesinin incelenmesi gerekiyor",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Yayımlanan dizin etkin model yapılandırmasına bağlı değil. Dizini yeniden oluşturun veya yayımlayın.",
"newKnowledge.overview.attention.modelReadiness.description": "Gerekli model profilleri eksik veya şu anda yayımlanan dizine bağlı değil.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Seçilen modeller ilk belge işlendiğinde doğrulanacak.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Model yapılandırması doğrulama bekliyor",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Gerekli embedding, akıl yürütme ve rerank modellerini yapılandırıp yayımlayın.",
"newKnowledge.overview.attention.modelReadiness.title": "Model yapılandırması tamamlanmadı",
"newKnowledge.overview.attention.staleSource.description": "Bu kaynak hiç başarıyla eşitlenmedi veya son başarılı eşitlemenin üzerinden 7 günden fazla zaman geçti.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Якість запиту потребує перевірки",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Опублікований індекс не пов’язаний з активною конфігурацією моделей. Перебудуйте або повторно опублікуйте індекс.",
"newKnowledge.overview.attention.modelReadiness.description": "Обов’язкові профілі моделей відсутні або не пов’язані з поточним опублікованим індексом.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Вибрані моделі буде перевірено під час обробки першого документа.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Конфігурація моделей очікує на перевірку",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Налаштуйте й опублікуйте обов’язкові моделі ембедингів, міркування та реранжування.",
"newKnowledge.overview.attention.modelReadiness.title": "Конфігурацію моделей не завершено",
"newKnowledge.overview.attention.staleSource.description": "Це джерело ще жодного разу не синхронізувалося успішно або від останньої успішної синхронізації минуло понад 7 днів.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "Chất lượng truy vấn cần được xem xét",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "Chỉ mục đã xuất bản chưa được liên kết với cấu hình mô hình đang hoạt động. Hãy tạo lại hoặc xuất bản lại chỉ mục.",
"newKnowledge.overview.attention.modelReadiness.description": "Thiếu hồ sơ mô hình bắt buộc hoặc các hồ sơ này chưa được liên kết với chỉ mục hiện đã xuất bản.",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "Các mô hình đã chọn sẽ được xác thực khi tài liệu đầu tiên được xử lý.",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "Cấu hình mô hình đang chờ xác thực",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "Hãy cấu hình và xuất bản các mô hình embedding, suy luận và rerank bắt buộc.",
"newKnowledge.overview.attention.modelReadiness.title": "Cấu hình mô hình chưa hoàn tất",
"newKnowledge.overview.attention.staleSource.description": "Nguồn này chưa từng đồng bộ thành công hoặc lần đồng bộ thành công gần nhất đã cách đây hơn 7 ngày.",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "查询质量需要检查",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "已发布索引尚未绑定当前模型配置,请重建或重新发布索引。",
"newKnowledge.overview.attention.modelReadiness.description": "缺少必需的模型配置,或模型配置尚未绑定当前已发布索引。",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "处理首个文档时将验证所选模型。",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "模型配置等待验证",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "请配置并发布必需的 Embedding、推理和 Rerank 模型。",
"newKnowledge.overview.attention.modelReadiness.title": "模型配置未完成",
"newKnowledge.overview.attention.staleSource.description": "该数据源从未成功同步,或距离上次成功同步已超过 7 天。",

View File

@ -351,6 +351,8 @@
"newKnowledge.overview.attention.lowQualityQuery.title": "查詢品質需要檢查",
"newKnowledge.overview.attention.modelReadiness.bindingMissing": "已發佈索引尚未綁定目前模型設定,請重建或重新發佈索引。",
"newKnowledge.overview.attention.modelReadiness.description": "缺少必要的模型設定,或模型設定尚未綁定目前已發佈索引。",
"newKnowledge.overview.attention.modelReadiness.pendingDescription": "處理第一份文件時將驗證所選模型。",
"newKnowledge.overview.attention.modelReadiness.pendingTitle": "模型設定正在等待驗證",
"newKnowledge.overview.attention.modelReadiness.profilesMissing": "請設定並發佈必要的 Embedding、推理和 Rerank 模型。",
"newKnowledge.overview.attention.modelReadiness.title": "模型設定未完成",
"newKnowledge.overview.attention.staleSource.description": "此資料來源從未成功同步,或距離上次成功同步已超過 7 天。",

View File

@ -504,6 +504,18 @@ describe('normalizeConsoleOpenAPIURL', () => {
expect(searchParams.has('creator_ids[0]')).toBe(false)
})
it('should serialize KnowledgeFS list query arrays as repeated params', () => {
const url = normalizeConsoleOpenAPIURL(
'https://example.com/console/api/knowledge-fs/spaces?tag_ids%5B0%5D=tag-1&creator_ids%5B0%5D=user-1',
)
const searchParams = new URL(url).searchParams
expect(searchParams.getAll('tag_ids')).toEqual(['tag-1'])
expect(searchParams.getAll('creator_ids')).toEqual(['user-1'])
expect(searchParams.has('tag_ids[0]')).toBe(false)
expect(searchParams.has('creator_ids[0]')).toBe(false)
})
it('should serialize snippet list query arrays as repeated params', () => {
const url = normalizeConsoleOpenAPIURL(
'https://example.com/console/api/workspaces/current/customized-snippets?tag_ids%5B0%5D=tag-1&creators%5B0%5D=user-1',

View File

@ -15,7 +15,7 @@ const repeatedQueryArrayRules: readonly QueryArrayCompatibilityRule[] = [
{ path: /\/datasets$/, fields: ['ids', 'tag_ids'] },
{ path: /\/datasets\/[^/]+\/documents\/[^/]+\/segment\/[^/]+$/, fields: ['segment_id'] },
{ path: /\/datasets\/[^/]+\/documents\/[^/]+\/segments$/, fields: ['segment_id', 'status'] },
{ path: /\/knowledge-fs\/spaces$/, fields: ['creator_ids'] },
{ path: /\/knowledge-fs\/spaces$/, fields: ['creator_ids', 'tag_ids'] },
{ path: /\/trial-apps\/[^/]+\/datasets$/, fields: ['ids'] },
{ path: /\/workspaces\/current\/customized-snippets$/, fields: ['tag_ids', 'creators'] },
{ path: /\/workspaces\/current\/plugin\/[^/]+\/list$/, fields: ['tags'] },