diff --git a/api/knowledge-fs-contract.lock.json b/api/knowledge-fs-contract.lock.json index 0d4db2b758e..dddcd62e890 100644 --- a/api/knowledge-fs-contract.lock.json +++ b/api/knowledge-fs-contract.lock.json @@ -1,6 +1,6 @@ { "schemaVersion": 5, - "subtreeTree": "67bd568555a900179301ec69121b276a88332aa3", + "subtreeTree": "7250e700e493b975bf070a97d5424c5a0c7def62", "openapiSha256": "47936a7d9ffdc27e2b2b8982a90e1936dc3bf59a64c316f452a6912ec1d2fcd6", "capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7", "capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3", diff --git a/knowledge-fs/packages/api/src/knowledge-space-handlers.ts b/knowledge-fs/packages/api/src/knowledge-space-handlers.ts index 86016b41105..a59dd907186 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-handlers.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-handlers.ts @@ -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>; + readonly createdBySubjectId: string; + readonly migrationPrincipal: KnowledgeSpaceProfileMigrationPrincipal & { + readonly knowledgeSpaceId: string; + }; + readonly migrations: KnowledgeSpaceProfileMigrationService; + readonly now: () => string; + readonly profile: KnowledgeSpaceRetrievalProfileInput; + readonly profiles: KnowledgeSpaceProfileRepository; +}): Promise { + 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: { diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-handler-behavior.test.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-handler-behavior.test.ts index 839e6cfac71..fb01ba386dd 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-handler-behavior.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-handler-behavior.test.ts @@ -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, diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.test.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.test.ts index 2295ae689d0..cd6314379d3 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.test.ts @@ -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 => { + 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 => { throw new Error("database should not be called"); diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.ts index 902dedffdcb..19f4edd8204 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-database-repository.ts @@ -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 { + 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; diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service-behavior.test.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service-behavior.test.ts index dbc9960853d..827af0f90eb 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service-behavior.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service-behavior.test.ts @@ -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", diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service.ts index dd0479e9075..2bbb1d14fbf 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration-service.ts @@ -54,6 +54,12 @@ export interface KnowledgeSpaceProfileMigrationService { readonly runId: string; }, ): Promise; + findByCandidate( + input: KnowledgeSpaceProfileMigrationPrincipal & { + readonly candidateProfileId: string; + readonly knowledgeSpaceId: string; + }, + ): Promise; 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; diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration.test.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration.test.ts index 37553908e6c..1109d5e35e4 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration.test.ts @@ -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, diff --git a/knowledge-fs/packages/api/src/knowledge-space-profile-migration.ts b/knowledge-fs/packages/api/src/knowledge-space-profile-migration.ts index 4f5e5422a58..2d76beef4ab 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-profile-migration.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-profile-migration.ts @@ -149,6 +149,11 @@ export interface KnowledgeSpaceProfileMigrationRepository { readonly requestedBySubjectId?: string | undefined; readonly tenantId: string; }): Promise; + findLatestByCandidate(input: { + readonly candidateProfileId: string; + readonly knowledgeSpaceId: string; + readonly tenantId: string; + }): Promise; get(runId: string): Promise; 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"); diff --git a/packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts index 26b6f7ddb7b..3c01e40dc55 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/orpc.gen.ts @@ -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, } diff --git a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts index 14cf22ada5a..75ca2570d25 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts @@ -1084,6 +1084,14 @@ export type KnowledgeFsSourceWorkflowImportPayload = kind: 'online-drive-import' } & KnowledgeFsOnlineDriveWorkflowImportPayload) +export type KnowledgeFsSpaceTagListResponse = { + data: Array +} + +export type KnowledgeFsSpaceTagsReplacePayload = { + tag_ids?: Array +} + export type KnowledgeFsTraceListResponse = { data: Array next_cursor?: string | null @@ -1220,6 +1228,7 @@ export type KnowledgeFsSpaceListItemResponse = { permission_keys: Array resource_version: number state: KnowledgeFsControlSpaceState + tags?: Array 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 } 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: { diff --git a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts index c123bdf5564..c16d1879c0f 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts @@ -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(), }) diff --git a/web/features/new-rag/__tests__/documents-page.spec.tsx b/web/features/new-rag/__tests__/documents-page.spec.tsx index fa197cb8fab..5a700528c92 100644 --- a/web/features/new-rag/__tests__/documents-page.spec.tsx +++ b/web/features/new-rag/__tests__/documents-page.spec.tsx @@ -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() + + 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 = { diff --git a/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx b/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx index 56ff4a101b2..2daa9135fab 100644 --- a/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx +++ b/web/features/new-rag/__tests__/knowledge-settings-form.spec.tsx @@ -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', () => { diff --git a/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx b/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx index 1daf17dbe47..108f9536f8a 100644 --- a/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx +++ b/web/features/new-rag/__tests__/new-knowledge-list.spec.tsx @@ -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[] }) => ( + + ), +})) + +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 }> } + }) => ( +
+ dataset.newKnowledge.tags + {knowledgeSpace.tags?.map((tag) => tag.name).join(', ')} +
+ ), +})) + 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(, { + 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() diff --git a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx index 222ec83a1d7..3ec9b826a1f 100644 --- a/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx +++ b/web/features/new-rag/__tests__/retrieval-test-page.spec.tsx @@ -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', ) diff --git a/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx b/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx index 34135b9b22c..68b72975228 100644 --- a/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx +++ b/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx @@ -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() - 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 () => { diff --git a/web/features/new-rag/components/__tests__/knowledge-space-card-tags.spec.tsx b/web/features/new-rag/components/__tests__/knowledge-space-card-tags.spec.tsx new file mode 100644 index 00000000000..c6089b1dfd5 --- /dev/null +++ b/web/features/new-rag/components/__tests__/knowledge-space-card-tags.spec.tsx @@ -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() + return { + ...original, + useMutation: (options: { + mutationFn?: (input: unknown) => Promise + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByRole('combobox', { name: 'Frontend, Backend' })).toBeDisabled() + expect(putTags).not.toHaveBeenCalled() + }) +}) diff --git a/web/features/new-rag/components/knowledge-model-readiness-banner.tsx b/web/features/new-rag/components/knowledge-model-readiness-banner.tsx index d992e04be84..6265d1be107 100644 --- a/web/features/new-rag/components/knowledge-model-readiness-banner.tsx +++ b/web/features/new-rag/components/knowledge-model-readiness-banner.tsx @@ -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 ( ) } diff --git a/web/features/new-rag/components/knowledge-model-readiness-notice.tsx b/web/features/new-rag/components/knowledge-model-readiness-notice.tsx index ca2181b46c4..cc532dac5be 100644 --- a/web/features/new-rag/components/knowledge-model-readiness-notice.tsx +++ b/web/features/new-rag/components/knowledge-model-readiness-notice.tsx @@ -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 (

diff --git a/web/features/new-rag/components/knowledge-model-setup-dialog.tsx b/web/features/new-rag/components/knowledge-model-setup-dialog.tsx index 97019f5d31e..6090780e0e4 100644 --- a/web/features/new-rag/components/knowledge-model-setup-dialog.tsx +++ b/web/features/new-rag/components/knowledge-model-setup-dialog.tsx @@ -11,7 +11,7 @@ function readinessTitle( tCommon: ReturnType>['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']) diff --git a/web/features/new-rag/components/knowledge-space-card-tags.tsx b/web/features/new-rag/components/knowledge-space-card-tags.tsx new file mode 100644 index 00000000000..15f36a57c97 --- /dev/null +++ b/web/features/new-rag/components/knowledge-space-card-tags.tsx @@ -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( + () => + (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 ( + + replaceTagsMutation.mutate({ + params: { control_space_id: knowledgeSpace.control_space_id }, + body: { tag_ids: tagIds }, + }) + } + className="relative z-1 mx-3 w-auto" + /> + ) +} diff --git a/web/features/new-rag/components/knowledge-space-card.tsx b/web/features/new-rag/components/knowledge-space-card.tsx index c3c9b98bb23..4b8e91b40c0 100644 --- a/web/features/new-rag/components/knowledge-space-card.tsx +++ b/web/features/new-rag/components/knowledge-space-card.tsx @@ -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 ( -

  • +
  • -
    +
    $['newKnowledge.cardType'])} title={iconName} @@ -53,40 +55,35 @@ export function KnowledgeSpaceCard({
    -

    +

    {summary?.description || t(($) => $['newKnowledge.noDescription'])}

    -
    $['newKnowledge.tags'])}. ${unavailable}`} - className="mt-1 flex min-w-0 items-center gap-1 px-4" - > - - {t(($) => $['newKnowledge.tags'])} - - {unavailable} -
    -
    - - - {summary?.document_count ?? 0} - - - - {linkedApps} - - {t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps} - - - - / - - - {t(($) => $['newKnowledge.updated'], { - date: formattedUpdatedAt, - })} - -
    + +
    + + + {summary?.document_count ?? 0} + + + + {linkedApps} + + {t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps} + + + + / + + + {t(($) => $['newKnowledge.updated'], { + date: formattedUpdatedAt, + })} + +
  • ) diff --git a/web/features/new-rag/document-list.tsx b/web/features/new-rag/document-list.tsx index 57b3a54cbf7..9e22907741b 100644 --- a/web/features/new-rag/document-list.tsx +++ b/web/features/new-rag/document-list.tsx @@ -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 = { 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 = ( + <> + + {statusLabel} + + ) + const className = cn( + 'inline-flex items-center gap-1.5 text-xs leading-4', + statusTextClass[status], + ) + + if (status !== 'failed' || !failureReason) return {content} + + return ( + + + {content} + + } + /> + + + {failureReason} + + + + ) +} + 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 onRemove: (documentId: string) => Promise @@ -216,21 +277,7 @@ const DocumentRow = memo( {tCommon(($) => $.loading)} ) : ( - - - {t(($) => $[`newKnowledge.documentStatus.${status}`])} - + )} @@ -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 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} diff --git a/web/features/new-rag/documents-page.tsx b/web/features/new-rag/documents-page.tsx index 46a62422a62..650c5e087db 100644 --- a/web/features/new-rag/documents-page.tsx +++ b/web/features/new-rag/documents-page.tsx @@ -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) diff --git a/web/features/new-rag/knowledge-settings-form.tsx b/web/features/new-rag/knowledge-settings-form.tsx index a07db9f8694..6bd3c516ad7 100644 --- a/web/features/new-rag/knowledge-settings-form.tsx +++ b/web/features/new-rag/knowledge-settings-form.tsx @@ -853,32 +853,25 @@ export function KnowledgeSettingsForm({
    )} - {(settings.configuration_state !== 'active' || settings.issues.length > 0) && ( - 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) && ( + 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 && (
    ({ .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({ + 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 ( - - ) -} - 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({
    - $['newKnowledge.tags'])} onClick={showFilterBoundary} /> + void setTagIds(nextTagIds)} + onOpenTagManagement={() => setShowTagManagementModal(true)} + /> void setCreatorIds(nextCreatorIds)} @@ -209,7 +221,7 @@ export function NewKnowledgeList({ } />
    - ) : knowledgeSpaces.length === 0 && creatorIds.length === 0 ? ( + ) : knowledgeSpaces.length === 0 && creatorIds.length === 0 && tagIds.length === 0 ? ( {tCommon(($) => $['operation.noSearchResults'], { - content: t(($) => $['newKnowledge.creators']), + content: t(($) => $.knowledge), })}
    ) : ( @@ -228,6 +240,7 @@ export function NewKnowledgeList({ setShowTagManagementModal(true)} /> ))} @@ -251,6 +264,16 @@ export function NewKnowledgeList({ )} + setShowTagManagementModal(false)} + onTagsChange={() => { + void queryClient.invalidateQueries({ + queryKey: consoleQuery.knowledgeFs.spaces.get.key(), + }) + }} + /> {showExternalApiPanel && canConnect && ( setOpen(false)} /> diff --git a/web/features/tag-management/components/tag-search-content.tsx b/web/features/tag-management/components/tag-search-content.tsx index 2604609230d..0b3067ac3cb 100644 --- a/web/features/tag-management/components/tag-search-content.tsx +++ b/web/features/tag-management/components/tag-search-content.tsx @@ -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() const realItemCount = filteredItems.filter((tag) => !isCreateTagOption(tag)).length const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || '' @@ -93,11 +98,7 @@ export const TagSearchContent = ({ } return ( - + {tag.name} diff --git a/web/features/tag-management/components/tag-selector.tsx b/web/features/tag-management/components/tag-selector.tsx index 9373debe15e..291792a10d0 100644 --- a/web/features/tag-management/components/tag-selector.tsx +++ b/web/features/tag-management/components/tag-selector.tsx @@ -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} > handleOpenChange(false)} /> diff --git a/web/i18n/ar-TN/dataset.json b/web/i18n/ar-TN/dataset.json index 3ec63f6ac4b..d438924f6a9 100644 --- a/web/i18n/ar-TN/dataset.json +++ b/web/i18n/ar-TN/dataset.json @@ -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 أيام على آخر مزامنة ناجحة.", diff --git a/web/i18n/de-DE/dataset.json b/web/i18n/de-DE/dataset.json index 9821cde05ee..98cc4ba13f1 100644 --- a/web/i18n/de-DE/dataset.json +++ b/web/i18n/de-DE/dataset.json @@ -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.", diff --git a/web/i18n/en-US/dataset.json b/web/i18n/en-US/dataset.json index 8b48119f454..c62ed93370d 100644 --- a/web/i18n/en-US/dataset.json +++ b/web/i18n/en-US/dataset.json @@ -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.", diff --git a/web/i18n/es-ES/dataset.json b/web/i18n/es-ES/dataset.json index cc3b1eafd43..f8f544d10cc 100644 --- a/web/i18n/es-ES/dataset.json +++ b/web/i18n/es-ES/dataset.json @@ -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.", diff --git a/web/i18n/fa-IR/dataset.json b/web/i18n/fa-IR/dataset.json index 3aed9376c43..d9fbaee63f8 100644 --- a/web/i18n/fa-IR/dataset.json +++ b/web/i18n/fa-IR/dataset.json @@ -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": "این منبع هرگز با موفقیت همگام‌سازی نشده یا بیش از ۷ روز از آخرین همگام‌سازی موفق آن گذشته است.", diff --git a/web/i18n/fr-FR/dataset.json b/web/i18n/fr-FR/dataset.json index f548c632805..2ad8a84b627 100644 --- a/web/i18n/fr-FR/dataset.json +++ b/web/i18n/fr-FR/dataset.json @@ -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": "L’index publié n’est pas lié à la configuration de modèles active. Reconstruisez ou republiez l’index.", "newKnowledge.overview.attention.modelReadiness.description": "Des profils de modèles requis sont absents ou ne sont pas liés à l’index 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 d’embedding, 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 n’a jamais été synchronisée avec succès ou sa dernière synchronisation réussie date de plus de 7 jours.", diff --git a/web/i18n/hi-IN/dataset.json b/web/i18n/hi-IN/dataset.json index 89382db1a7a..ab16e09704c 100644 --- a/web/i18n/hi-IN/dataset.json +++ b/web/i18n/hi-IN/dataset.json @@ -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 दिन से अधिक पहले हुआ था।", diff --git a/web/i18n/id-ID/dataset.json b/web/i18n/id-ID/dataset.json index a5fdb52883c..e8a7da56483 100644 --- a/web/i18n/id-ID/dataset.json +++ b/web/i18n/id-ID/dataset.json @@ -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.", diff --git a/web/i18n/it-IT/dataset.json b/web/i18n/it-IT/dataset.json index 80d79e63f75..c10ebb5f0c7 100644 --- a/web/i18n/it-IT/dataset.json +++ b/web/i18n/it-IT/dataset.json @@ -351,6 +351,8 @@ "newKnowledge.overview.attention.lowQualityQuery.title": "La qualità della query richiede una verifica", "newKnowledge.overview.attention.modelReadiness.bindingMissing": "L’indice pubblicato non è associato alla configurazione dei modelli attiva. Ricrea o ripubblica l’indice.", "newKnowledge.overview.attention.modelReadiness.description": "Mancano profili di modello obbligatori oppure non sono associati all’indice attualmente pubblicato.", + "newKnowledge.overview.attention.modelReadiness.pendingDescription": "I modelli selezionati verranno convalidati durante l’elaborazione 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 l’ultima sincronizzazione riuscita risale a più di 7 giorni fa.", diff --git a/web/i18n/ja-JP/dataset.json b/web/i18n/ja-JP/dataset.json index 41c9551aedc..47be4ba0617 100644 --- a/web/i18n/ja-JP/dataset.json +++ b/web/i18n/ja-JP/dataset.json @@ -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 日以上経過しています。", diff --git a/web/i18n/ko-KR/dataset.json b/web/i18n/ko-KR/dataset.json index d67026f7226..1d33905a4df 100644 --- a/web/i18n/ko-KR/dataset.json +++ b/web/i18n/ko-KR/dataset.json @@ -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일이 지났습니다.", diff --git a/web/i18n/lo-LA/dataset.json b/web/i18n/lo-LA/dataset.json index 17b1a93204e..15bc34b1fd3 100644 --- a/web/i18n/lo-LA/dataset.json +++ b/web/i18n/lo-LA/dataset.json @@ -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 ມື້.", diff --git a/web/i18n/nl-NL/dataset.json b/web/i18n/nl-NL/dataset.json index 7eb367e86b7..4eec3754b7f 100644 --- a/web/i18n/nl-NL/dataset.json +++ b/web/i18n/nl-NL/dataset.json @@ -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.", diff --git a/web/i18n/pl-PL/dataset.json b/web/i18n/pl-PL/dataset.json index d5d115d0ea4..5ffc39b80cd 100644 --- a/web/i18n/pl-PL/dataset.json +++ b/web/i18n/pl-PL/dataset.json @@ -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.", diff --git a/web/i18n/pt-BR/dataset.json b/web/i18n/pt-BR/dataset.json index 556c2c2a978..d0c7c7be1a1 100644 --- a/web/i18n/pt-BR/dataset.json +++ b/web/i18n/pt-BR/dataset.json @@ -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.", diff --git a/web/i18n/ro-RO/dataset.json b/web/i18n/ro-RO/dataset.json index 8c27d7faf2e..0825ff1a268 100644 --- a/web/i18n/ro-RO/dataset.json +++ b/web/i18n/ro-RO/dataset.json @@ -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.", diff --git a/web/i18n/ru-RU/dataset.json b/web/i18n/ru-RU/dataset.json index bd324b41ef3..68ecfc4e831 100644 --- a/web/i18n/ru-RU/dataset.json +++ b/web/i18n/ru-RU/dataset.json @@ -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 дней.", diff --git a/web/i18n/sl-SI/dataset.json b/web/i18n/sl-SI/dataset.json index 240c82ef9de..48bd8a27eb4 100644 --- a/web/i18n/sl-SI/dataset.json +++ b/web/i18n/sl-SI/dataset.json @@ -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.", diff --git a/web/i18n/th-TH/dataset.json b/web/i18n/th-TH/dataset.json index 715709c4bf3..e0c28b6ab61 100644 --- a/web/i18n/th-TH/dataset.json +++ b/web/i18n/th-TH/dataset.json @@ -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 วันแล้ว", diff --git a/web/i18n/tr-TR/dataset.json b/web/i18n/tr-TR/dataset.json index 0376ae204a1..8a02625c98d 100644 --- a/web/i18n/tr-TR/dataset.json +++ b/web/i18n/tr-TR/dataset.json @@ -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.", diff --git a/web/i18n/uk-UA/dataset.json b/web/i18n/uk-UA/dataset.json index a5b7894bada..1e0b5cc95c0 100644 --- a/web/i18n/uk-UA/dataset.json +++ b/web/i18n/uk-UA/dataset.json @@ -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 днів.", diff --git a/web/i18n/vi-VN/dataset.json b/web/i18n/vi-VN/dataset.json index 6a4c1dad3a7..693ae8aa60d 100644 --- a/web/i18n/vi-VN/dataset.json +++ b/web/i18n/vi-VN/dataset.json @@ -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.", diff --git a/web/i18n/zh-Hans/dataset.json b/web/i18n/zh-Hans/dataset.json index a15096b50f7..c6091e9c5a1 100644 --- a/web/i18n/zh-Hans/dataset.json +++ b/web/i18n/zh-Hans/dataset.json @@ -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 天。", diff --git a/web/i18n/zh-Hant/dataset.json b/web/i18n/zh-Hant/dataset.json index 8d8b299b2ae..d18feefec58 100644 --- a/web/i18n/zh-Hant/dataset.json +++ b/web/i18n/zh-Hant/dataset.json @@ -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 天。", diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index fdcbd6a3d4f..fb8db728935 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -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', diff --git a/web/service/console-openapi-url.ts b/web/service/console-openapi-url.ts index cb6f2c5826a..c3d60d5ad43 100644 --- a/web/service/console-openapi-url.ts +++ b/web/service/console-openapi-url.ts @@ -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'] },