diff --git a/core-web/libs/data-access/src/lib/dot-ai/dot-ai-config.service.spec.ts b/core-web/libs/data-access/src/lib/dot-ai/dot-ai-config.service.spec.ts index 390d7b2166cc..406022f5af6c 100644 --- a/core-web/libs/data-access/src/lib/dot-ai/dot-ai-config.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-ai/dot-ai-config.service.spec.ts @@ -115,7 +115,7 @@ describe('DotAiConfigService', () => { spectator.service.getResolvedConfig().subscribe((r) => (result = r)); flush({ - configHost: 'demo.dotcms.com (falls back to system host)', + configHost: 'demo.dotcms.com', settings: { temperature: '0.7' }, providerConfig: JSON.stringify({ chat: { provider: 'openrouter', apiKey: '*****', model: 'a,b,c' } @@ -134,12 +134,12 @@ describe('DotAiConfigService', () => { spectator.service.getResolvedConfig().subscribe((r) => (result = r)); flush({ - configHost: 'demo.dotcms.com (falls back to system host)', + configHost: 'demo.dotcms.com', settings: {}, providerConfig: '{}' }); - expect(result.configHost).toBe('demo.dotcms.com (falls back to system host)'); + expect(result.configHost).toBe('demo.dotcms.com'); }); it('should report isConfigured false when providerConfig is omitted', () => { diff --git a/core-web/libs/data-access/src/lib/dot-ai/dot-ai-config.service.ts b/core-web/libs/data-access/src/lib/dot-ai/dot-ai-config.service.ts index 148e1a855f8f..16a288d3f196 100644 --- a/core-web/libs/data-access/src/lib/dot-ai/dot-ai-config.service.ts +++ b/core-web/libs/data-access/src/lib/dot-ai/dot-ai-config.service.ts @@ -20,6 +20,8 @@ interface ResponseEntityView { interface RawCompletionsConfig { configHost: string; + /** Absent on an older backend, where the hostname carried the fallback note inline. */ + configHostInherited?: boolean; settings?: Record; /** Omitted entirely by the backend when blank — that absence is the "not configured" signal. */ providerConfig?: string; @@ -130,6 +132,7 @@ export class DotAiConfigService { #toResolvedConfig(raw: RawCompletionsConfig): DotAiResolvedConfig { const base = { configHost: raw?.configHost ?? '', + configHostInherited: raw?.configHostInherited ?? false, settings: raw?.settings ?? {} }; diff --git a/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts b/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts index d31f83741577..edb276f5d7cf 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts @@ -270,8 +270,13 @@ export interface DotAiEmbeddingsBuildResult { /** `providerConfig` parsed exactly once, by DotAiConfigService. */ export interface DotAiResolvedConfig { - /** A display string from the server, e.g. "demo.dotcms.com (falls back to system host)". */ + /** The site the configuration is read for, e.g. "demo.dotcms.com". */ configHost: string; + /** + * The site has no dotAI configuration of its own, so what is shown came from the System + * Host. The server reports this separately from the hostname so the client can label it. + */ + configHostInherited: boolean; settings: Record; providerConfig: Record | null; /** `chat.model` is a CSV fallback list whose first entry is the default. */ diff --git a/core-web/libs/portlets/CLAUDE.md b/core-web/libs/portlets/CLAUDE.md index bccb282b8ccf..2a79de91edc5 100644 --- a/core-web/libs/portlets/CLAUDE.md +++ b/core-web/libs/portlets/CLAUDE.md @@ -65,6 +65,8 @@ this.dialogService.open(MyFormComponent, { width: '700px', ... }); **Modal dialogs (default)**: List component opens `DialogService.open(CreateComponent, ...)`. The dialog closes with the form value; the list component passes it to the store. This is the pattern used in `dot-tags` and should be the default for new portlets. +**Dialogs whose submit can fail into the form (exception)**: when a submit can be rejected in a way that is *a correction to a field the dialog is still holding* — a Lucene or GraphQL query the server parses, say — `close(formValue)` loses the input at the moment the user needs it, and the error surfaces on the screen behind a modal that has already gone. Such a dialog keeps ownership of the submit: it calls the store itself, renders the failure inline, and closes only on success. Keep the request state local to the dialog rather than adding an in-flight flag to portlet state, and clear any unrendered outcome in the dialog's `DestroyRef.onDestroy` — PrimeNG's header X and the Escape key call `DynamicDialogRef.close` directly and never reach your own cancel handler. See `dot-ai`'s `dot-ai-index-create`. `close(formValue)` stays the default for every dialog whose submit cannot fail into the form. + **Routed CRUD (rare)**: Separate route for create/edit pages. Use only when the form is too complex for a dialog (many tabs, nested data). See `dot-experiments` for this pattern. ## When the CRUD Pattern Is Not Enough diff --git a/core-web/libs/portlets/dot-ai/src/lib/components/dot-ai-empty-state/dot-ai-empty-state.component.html b/core-web/libs/portlets/dot-ai/src/lib/components/dot-ai-empty-state/dot-ai-empty-state.component.html deleted file mode 100644 index 1f9d38950ee1..000000000000 --- a/core-web/libs/portlets/dot-ai/src/lib/components/dot-ai-empty-state/dot-ai-empty-state.component.html +++ /dev/null @@ -1,7 +0,0 @@ -
-

{{ title() }}

- - @if (subtitle()) { -

{{ subtitle() }}

- } -
diff --git a/core-web/libs/portlets/dot-ai/src/lib/components/dot-ai-empty-state/dot-ai-empty-state.component.ts b/core-web/libs/portlets/dot-ai/src/lib/components/dot-ai-empty-state/dot-ai-empty-state.component.ts deleted file mode 100644 index 297db5036048..000000000000 --- a/core-web/libs/portlets/dot-ai/src/lib/components/dot-ai-empty-state/dot-ai-empty-state.component.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Component, input } from '@angular/core'; - -/** - * The portlet's empty / no-results / not-permitted state: a title with an optional line under - * it, centred in whatever space it is given. - * - * Seven of these were written out by hand across the five tabs, each carrying its own - * typography and spacing literals, so a change to the portlet's empty states meant editing - * every tab and hoping none had drifted. - * - * Takes resolved strings rather than message keys, as `dot-site` does — the caller pipes its - * own `dm`, which keeps the keys visible at the point of use. - * - * Not `dot-empty-container` from `@dotcms/ui`: that renders a smaller `text-lg` title, a - * styled subtitle and an icon slot, so adopting it would restyle every empty state in the - * portlet rather than just de-duplicate them. - */ -@Component({ - selector: 'dot-ai-empty-state', - templateUrl: './dot-ai-empty-state.component.html', - // data-testid stays on the call site rather than an input: written there it lands on - // this host element in the parent's own template, so it is present whether or not a test - // renders this component for real. - host: { class: 'block' } -}) -export class DotAiEmptyStateComponent { - readonly title = input.required(); - - readonly subtitle = input(''); -} diff --git a/core-web/libs/portlets/dot-ai/src/lib/models/dot-ai-portlet.models.ts b/core-web/libs/portlets/dot-ai/src/lib/models/dot-ai-portlet.models.ts index 7477057927e8..6e5ee2c10368 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/models/dot-ai-portlet.models.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/models/dot-ai-portlet.models.ts @@ -9,7 +9,6 @@ import { DotAIImageOrientation, DOT_AI_VECTOR_OPERATOR, DotAiIndex, - DotAiIndexStatus, DotAiSearchResponse, DotAiVectorOperator } from '@dotcms/dotcms-models'; @@ -47,17 +46,32 @@ export const DOT_AI_TABS = [ export type DotAiTab = (typeof DOT_AI_TABS)[number]; export type DotAiTabId = DotAiTab['id']; +export const DOT_AI_INDEX_OPERATION = { + BUILD: 'build', + REMOVE_CONTENT: 'removeContent', + DELETE_INDEX: 'deleteIndex', + REBUILD_DB: 'rebuildDb' +} as const; + +export type DotAiIndexOperation = + (typeof DOT_AI_INDEX_OPERATION)[keyof typeof DOT_AI_INDEX_OPERATION]; + /** - * The outcome of the last index build, surfaced in the tab. + * What the last index operation did. + * + * One channel for all four, because where an outcome belongs is decided by the outcome rather + * than the operation: anything the user has to act on goes in the dialog still holding the + * field that caused it, everything else is a toast. * - * `empty` is its own case on purpose: the server answers 200 with `totalToEmbed: 0` when the - * query matches nothing, and an index with no rows does not come back from `indexCount` at - * all — so without this the build looks like it silently did nothing. + * `empty` is its own outcome on purpose. A build whose query matches nothing answers 200 with + * `totalToEmbed: 0`, and the index it makes never comes back from `indexCount`; a removal whose + * query matches nothing answers 200 with `deleted: 0`. Both are a query to fix, not a success. */ -export interface DotAiIndexBuildNotice { - kind: 'built' | 'empty' | 'failed'; +export interface DotAiIndexNotice { + operation: DotAiIndexOperation; + outcome: 'ok' | 'empty' | 'failed'; indexName: string; - /** Rows embedded, for `built`; the server's reason, for `failed`. */ + /** The count, for `ok`; the server's reason, for `failed`. */ detail?: string; } @@ -112,6 +126,8 @@ export interface DotAiPortletState { */ configLoadFailed: boolean; configHost: string; + /** The site has no dotAI configuration of its own; these settings came from System Host. */ + configHostInherited: boolean; settings: Record; chatModels: string[]; redactionFailed: boolean; @@ -119,9 +135,17 @@ export interface DotAiPortletState { // indexes indexes: DotAiIndex[]; - indexStatuses: Record; indexFragmentSnapshot: Record; - indexBuildSeeds: string[]; + /** + * Builds that have been requested but may not be in `indexes` yet — name to the epoch ms + * the build was requested. Embedding is asynchronous, so a freshly built index has no rows + * in `dot_embeddings` and does not come back from `indexCount` for a second or two; this is + * what lets the row show as Building in the meantime instead of not showing at all. + * + * The timestamp is the stop condition: a build that never materialises would otherwise poll + * for the lifetime of the page. See `PENDING_SEED_TTL_MS`. + */ + indexBuildSeeds: Record; indexesForbidden: boolean; // shared retrieval settings @@ -148,7 +172,13 @@ export interface DotAiPortletState { // embeddings screen (client-side filters — the whole dataset arrives in one response) indexFilter: string; - indexBuildNotice: DotAiIndexBuildNotice | null; + indexNotice: DotAiIndexNotice | null; + /** + * The operation and index whose outcome an open dialog will render itself. + * + * Declared at submit, not at render, so nothing depends on which effect runs first. + */ + indexNoticeOwner: { operation: DotAiIndexOperation; indexName: string } | null; // image image: DotAiGeneratedImage | null; @@ -192,15 +222,15 @@ export const DOT_AI_INITIAL_STATE: DotAiPortletState = { configLoaded: false, configLoadFailed: false, configHost: '', + configHostInherited: false, settings: {}, chatModels: [], redactionFailed: false, providerConfig: null, indexes: [], - indexStatuses: {}, indexFragmentSnapshot: {}, - indexBuildSeeds: [], + indexBuildSeeds: {}, indexesForbidden: false, settingsIndexName: 'default', @@ -222,7 +252,8 @@ export const DOT_AI_INITIAL_STATE: DotAiPortletState = { chatStreaming: false, indexFilter: '', - indexBuildNotice: null, + indexNotice: null, + indexNoticeOwner: null, image: null, imageGenerating: false, diff --git a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-config.feature.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-config.feature.spec.ts index 6793ec3acf4e..6aba244c3957 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-config.feature.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-config.feature.spec.ts @@ -13,7 +13,7 @@ import { withAiConfig } from './with-ai-config.feature'; import { DOT_AI_INITIAL_STATE, DotAiPortletState } from '../../models/dot-ai-portlet.models'; const resolved = (overrides: Partial = {}): DotAiResolvedConfig => ({ - configHost: 'demo.dotcms.com (falls back to system host)', + configHost: 'demo.dotcms.com', settings: { embeddingsSearchThreshold: '0.4' }, providerConfig: { chat: { model: 'a,b' } }, chatModels: ['a', 'b'], diff --git a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-config.feature.ts b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-config.feature.ts index 4150d5c679d8..649322c7639e 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-config.feature.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-config.feature.ts @@ -55,6 +55,7 @@ export function withAiConfig() { /** The resolved config reassembled from state, for the Config Values screen. */ resolvedConfig: computed(() => ({ configHost: store.configHost(), + configHostInherited: store.configHostInherited(), settings: store.settings(), providerConfig: store.providerConfig(), chatModels: store.chatModels(), @@ -97,6 +98,7 @@ export function withAiConfig() { configLoadFailed: false, isConfigured: config.isConfigured, configHost: config.configHost, + configHostInherited: config.configHostInherited, settings: config.settings, chatModels: config.chatModels, redactionFailed: config.redactionFailed, diff --git a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.spec.ts index 7ec62aa7d021..893bb9351e04 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.spec.ts @@ -11,7 +11,11 @@ import { DotAiEmbeddingsBuildResult, DotAiIndex } from '@dotcms/dotcms-models'; import { withAiEmbeddings } from './with-ai-embeddings.feature'; import { withAiIndexes } from './with-ai-indexes.feature'; -import { DOT_AI_INITIAL_STATE, DotAiPortletState } from '../../models/dot-ai-portlet.models'; +import { + DOT_AI_INDEX_OPERATION, + DOT_AI_INITIAL_STATE, + DotAiPortletState +} from '../../models/dot-ai-portlet.models'; const index = (overrides: Partial = {}): DotAiIndex => ({ name: 'blogs', @@ -95,8 +99,9 @@ describe('withAiEmbeddings', () => { store.buildIndex({ indexName: 'blogs', query: '+++[' }); - expect(store.indexBuildNotice()).toEqual({ - kind: 'failed', + expect(store.indexNotice()).toEqual({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', indexName: 'blogs', detail: 'Index -1 out of bounds for length 0' }); @@ -114,7 +119,11 @@ describe('withAiEmbeddings', () => { store.buildIndex({ indexName: 'blogs', query: '+contentType:NoSuchType' }); - expect(store.indexBuildNotice()).toEqual({ kind: 'empty', indexName: 'blogs' }); + expect(store.indexNotice()).toEqual({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'empty', + indexName: 'blogs' + }); }); it('should report how much was embedded on success', () => { @@ -126,26 +135,69 @@ describe('withAiEmbeddings', () => { store.buildIndex({ indexName: 'blogs', query: '+contentType:Blog' }); - expect(store.indexBuildNotice()).toEqual({ - kind: 'built', + expect(store.indexNotice()).toEqual({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'ok', indexName: 'blogs', detail: '6' }); }); }); - describe('deleteFromIndex', () => { + describe('removeFromIndex', () => { it('should send the query as a deletion criterion, not as content to embed', () => { service.deleteFromIndex = vi.fn().mockReturnValue(of(3)); - store.deleteFromIndex({ indexName: 'blogs', query: '+contentType:Blog' }); + store.removeFromIndex({ indexName: 'blogs', query: '+contentType:Blog' }); expect(service.deleteFromIndex).toHaveBeenCalledWith('blogs', '+contentType:Blog'); expect(service.getIndexes).toHaveBeenCalled(); }); + + it('should report how much it removed', () => { + // The count came back from the server and was dropped on the floor, so a removal + // confirmed and then said nothing at all. + service.deleteFromIndex = vi.fn().mockReturnValue(of(3)); + + store.removeFromIndex({ indexName: 'blogs', query: '+contentType:Blog' }); + + expect(store.indexNotice()).toEqual({ + operation: DOT_AI_INDEX_OPERATION.REMOVE_CONTENT, + outcome: 'ok', + indexName: 'blogs', + detail: '3' + }); + }); + + it('should flag a removal that matched nothing rather than looking successful', () => { + // The server answers 200 with `deleted: 0` when the query matches nothing. + service.deleteFromIndex = vi.fn().mockReturnValue(of(0)); + + store.removeFromIndex({ indexName: 'blogs', query: '+contentType:NoSuchType' }); + + expect(store.indexNotice()).toEqual({ + operation: DOT_AI_INDEX_OPERATION.REMOVE_CONTENT, + outcome: 'empty', + indexName: 'blogs', + detail: '0' + }); + }); }); describe('deleteIndex (FR-034)', () => { + it('should end any build outstanding for the index it deletes', () => { + // A surviving seed put the deleted index straight back in the table as a zeroed + // BUILDING row, for the rest of the two-minute grace period. + service.getIndexes = vi.fn().mockReturnValue(of([])); + service.deleteIndex = vi.fn().mockReturnValue(of(4)); + store.markIndexBuilding('blogs'); + + store.deleteIndex('blogs'); + + expect(store.indexBuildSeeds()).toEqual({}); + expect(store.indexes().map((row) => row.name)).not.toContain('blogs'); + }); + it('should let concurrent deletions of different indexes both complete', () => { const first = new Subject(); const second = new Subject(); @@ -166,13 +218,51 @@ describe('withAiEmbeddings', () => { }); }); + describe('a failure that settles nothing (FR-050)', () => { + it('should still notify on a 403, so a dialog waiting on it can settle', () => { + // The dialogs cannot be dismissed while their request is outstanding and settle + // only on a notice, so a branch that returns without one leaves a modal nobody can + // close but a page reload. + const error = new HttpErrorResponse({ status: 403 }); + service.buildIndex = vi.fn().mockReturnValue(throwError(() => error)); + + store.buildIndex({ indexName: 'blogs', query: '+contentType:Blog' }); + + expect(store.indexesForbidden()).toBe(true); + expect(store.indexNotice()).toEqual( + expect.objectContaining({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', + indexName: 'blogs' + }) + ); + }); + }); + describe('rebuildEmbeddingsDb', () => { + it('should end every outstanding build, since every index went with the store', () => { + service.getIndexes = vi.fn().mockReturnValue(of([])); + service.rebuildEmbeddingsDb = vi.fn().mockReturnValue(of(true)); + store.markIndexBuilding('blogs'); + store.markIndexBuilding('other'); + + store.rebuildEmbeddingsDb(); + + expect(store.indexBuildSeeds()).toEqual({}); + expect(store.indexes()).toEqual([]); + }); + it('should rebuild and refresh', () => { service.rebuildEmbeddingsDb = vi.fn().mockReturnValue(of(true)); store.rebuildEmbeddingsDb(); expect(service.rebuildEmbeddingsDb).toHaveBeenCalled(); + expect(store.indexNotice()).toEqual({ + operation: DOT_AI_INDEX_OPERATION.REBUILD_DB, + outcome: 'ok', + indexName: '' + }); expect(service.getIndexes).toHaveBeenCalled(); }); @@ -202,14 +292,24 @@ describe('withAiEmbeddings', () => { expect(spectator.inject(DotHttpErrorManagerService).handle).not.toHaveBeenCalled(); }); - it('should still route other failures through the error manager', () => { - const error = new HttpErrorResponse({ status: 500 }); + it("should report other failures in the portlet's own words", () => { + // Not through DotHttpErrorManagerService: it renders these as "Unknown Error" over + // a raw Java message and loses which operation and index it was about. + const error = new HttpErrorResponse({ + status: 500, + error: { message: 'relation does not exist' } + }); service.rebuildEmbeddingsDb = vi.fn().mockReturnValue(throwError(() => error)); store.rebuildEmbeddingsDb(); expect(store.indexesForbidden()).toBe(false); - expect(spectator.inject(DotHttpErrorManagerService).handle).toHaveBeenCalledWith(error); + expect(store.indexNotice()).toEqual({ + operation: DOT_AI_INDEX_OPERATION.REBUILD_DB, + outcome: 'failed', + indexName: '', + detail: 'relation does not exist' + }); }); }); diff --git a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.ts b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.ts index 2ce7eeba91cb..18a235618465 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.ts @@ -5,12 +5,16 @@ import { EMPTY, pipe } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; import { computed, inject, Signal } from '@angular/core'; -import { catchError, exhaustMap, mergeMap, tap } from 'rxjs/operators'; +import { catchError, concatMap, mergeMap, tap } from 'rxjs/operators'; -import { DotAiEmbeddingsService, DotHttpErrorManagerService } from '@dotcms/data-access'; +import { DotAiEmbeddingsService } from '@dotcms/data-access'; import { DotAiEmbeddingsBuildForm, DotAiIndex } from '@dotcms/dotcms-models'; -import { DotAiPortletState } from '../../models/dot-ai-portlet.models'; +import { + DOT_AI_INDEX_OPERATION, + DotAiIndexNotice, + DotAiPortletState +} from '../../models/dot-ai-portlet.models'; /** * Index administration: build, add to, delete from, delete, rebuild. @@ -19,9 +23,17 @@ import { DotAiPortletState } from '../../models/dot-ai-portlet.models'; * every mutation here refreshes through that one owner, so the Embeddings table and the * retrieval picker update together (FR-033). * + * Every operation records what it did in `indexNotice`. None of them used to: three of the + * four returned a count the store dropped on the floor, so a destructive action confirmed and + * then said nothing at all. + * * The `rxMethod` operator per action is load-bearing: - * - `exhaustMap` for build, rebuild and delete-from-index — each is one submit of one form, so - * a double click must not double-fire (FR-035) + * - `concatMap` for build and remove — these are submitted from a dialog that can be abandoned + * mid-flight and reopened, so a second submit must neither be dropped (`exhaustMap`, which + * left the new dialog spinning on a request that was never sent) nor cancel the first + * (`switchMap`, which would abandon a build the server has already started). Double-submit + * is prevented in the dialog, which disables its own button while a request is outstanding + * (FR-035) * - `mergeMap` for `deleteIndex`, because that one is per row — deleting A must not cancel the * delete of B (FR-034) */ @@ -33,6 +45,7 @@ export function withAiEmbeddings() { methods: { loadIndexes: () => void; markIndexBuilding: (indexName: string) => void; + forgetIndexBuildSeeds: (indexName?: string) => void; }; }>(), withComputed((store) => ({ @@ -52,7 +65,6 @@ export function withAiEmbeddings() { })), withMethods((store) => { const embeddingsService = inject(DotAiEmbeddingsService); - const httpErrorManager = inject(DotHttpErrorManagerService); /** The server's own words when it has any, so the reason is not thrown away. */ const extractReason = (error: HttpErrorResponse): string | undefined => { @@ -65,20 +77,31 @@ export function withAiEmbeddings() { return body?.message ?? body?.error ?? error?.message; }; + const notify = (notice: DotAiIndexNotice) => patchState(store, { indexNotice: notice }); + /** - * A 403 is the same normal non-admin state `loadIndexes` handles — index - * operations require CMS_ADMINISTRATOR_ROLE while portlet access does not — so it - * must put the tab into its forbidden state rather than throw a dialog over - * someone who has done nothing wrong (FR-050). + * Records a failed operation and swallows it. + * + * A 403 is the same normal non-admin state `loadIndexes` handles — index operations + * require CMS_ADMINISTRATOR_ROLE while portlet access does not — so it puts the tab + * into its forbidden state rather than throwing a dialog over someone who has done + * nothing wrong (FR-050). Everything else is reported in the portlet's own words + * rather than through the shared handler, which renders a malformed query as + * "Unknown Error" with the index name lost (FR-014). */ - const fail = (error: HttpErrorResponse) => { + const report = ( + operation: DotAiIndexNotice['operation'], + indexName: string, + error: HttpErrorResponse + ) => { + // Every path notifies, 403 included. A dialog cannot be dismissed while its + // request is outstanding, and it settles only on a notice — so a branch that + // returns without one leaves a modal nobody can close but a page reload. if (error?.status === 403) { patchState(store, { indexesForbidden: true }); - - return EMPTY; } - httpErrorManager.handle(error); + notify({ operation, outcome: 'failed', indexName, detail: extractReason(error) }); return EMPTY; }; @@ -88,15 +111,27 @@ export function withAiEmbeddings() { patchState(store, { indexFilter }); }, - dismissBuildNotice(): void { - patchState(store, { indexBuildNotice: null }); + /** + * Declares that an open dialog will render the outcome of this exact request. + * + * The tab reports everything else. Asking it to infer ownership from "is a + * dialog open" was wrong: a dialog renders only outcomes matching its own + * operation *and* index, so a delete that failed while the build dialog + * happened to be up was suppressed by the tab, ignored by the dialog, and + * never reported at all. + */ + claimIndexOutcome(owner: DotAiIndexNotice['operation'], indexName: string): void { + patchState(store, { indexNoticeOwner: { operation: owner, indexName } }); + }, + + releaseIndexOutcome(): void { + patchState(store, { indexNoticeOwner: null }); }, buildIndex: rxMethod( pipe( - tap(() => patchState(store, { indexBuildNotice: null })), - // exhaustMap: a double submit must not build twice. - exhaustMap((form) => + tap(() => patchState(store, { indexNotice: null })), + concatMap((form) => embeddingsService.buildIndex(form).pipe( tap((result) => { // A query that matches nothing still answers 200, and the @@ -104,8 +139,9 @@ export function withAiEmbeddings() { // saying nothing here reads as "the build did nothing". if (!result.totalToEmbed) { patchState(store, { - indexBuildNotice: { - kind: 'empty', + indexNotice: { + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'empty', indexName: result.indexName } }); @@ -114,8 +150,9 @@ export function withAiEmbeddings() { } patchState(store, { - indexBuildNotice: { - kind: 'built', + indexNotice: { + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'ok', indexName: result.indexName, detail: `${result.totalToEmbed}` } @@ -132,28 +169,33 @@ export function withAiEmbeddings() { // handler renders it as "Unknown Error" over a raw Java message // with the index name lost. Same reasoning as the chat stream // (FR-014). - catchError((error: HttpErrorResponse) => { - patchState(store, { - indexBuildNotice: { - kind: 'failed', - indexName: form.indexName, - detail: extractReason(error) - } - }); - - return EMPTY; - }) + catchError((error: HttpErrorResponse) => + report(DOT_AI_INDEX_OPERATION.BUILD, form.indexName, error) + ) ) ) ) ), - deleteFromIndex: rxMethod<{ indexName: string; query: string }>( + removeFromIndex: rxMethod<{ indexName: string; query: string }>( pipe( - exhaustMap(({ indexName, query }) => + tap(() => patchState(store, { indexNotice: null })), + concatMap(({ indexName, query }) => embeddingsService.deleteFromIndex(indexName, query).pipe( - tap(() => store.loadIndexes()), - catchError(fail) + tap((deleted) => { + // The server answers 200 with `deleted: 0` when the query + // matches nothing, so silence here would read as success. + notify({ + operation: DOT_AI_INDEX_OPERATION.REMOVE_CONTENT, + outcome: deleted ? 'ok' : 'empty', + indexName, + detail: `${deleted}` + }); + store.loadIndexes(); + }), + catchError((error: HttpErrorResponse) => + report(DOT_AI_INDEX_OPERATION.REMOVE_CONTENT, indexName, error) + ) ) ) ) @@ -164,8 +206,22 @@ export function withAiEmbeddings() { // mergeMap: per-row, so deleting one index cannot cancel another. mergeMap((indexName) => embeddingsService.deleteIndex(indexName).pipe( - tap(() => store.loadIndexes()), - catchError(fail) + tap((deleted) => { + notify({ + operation: DOT_AI_INDEX_OPERATION.DELETE_INDEX, + outcome: 'ok', + indexName, + detail: `${deleted}` + }); + // The index is gone, so any build outstanding for it is + // too — a surviving seed would put it straight back in + // the table as a zeroed BUILDING row. + store.forgetIndexBuildSeeds(indexName); + store.loadIndexes(); + }), + catchError((error: HttpErrorResponse) => + report(DOT_AI_INDEX_OPERATION.DELETE_INDEX, indexName, error) + ) ) ) ) @@ -173,10 +229,21 @@ export function withAiEmbeddings() { rebuildEmbeddingsDb: rxMethod( pipe( - exhaustMap(() => + concatMap(() => embeddingsService.rebuildEmbeddingsDb().pipe( - tap(() => store.loadIndexes()), - catchError(fail) + tap(() => { + notify({ + operation: DOT_AI_INDEX_OPERATION.REBUILD_DB, + outcome: 'ok', + indexName: '' + }); + // Every index went with the store, and so does every seed. + store.forgetIndexBuildSeeds(); + store.loadIndexes(); + }), + catchError((error: HttpErrorResponse) => + report(DOT_AI_INDEX_OPERATION.REBUILD_DB, '', error) + ) ) ) ) diff --git a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-indexes.feature.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-indexes.feature.spec.ts index e4b73b52f109..34fd3c19b65b 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-indexes.feature.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-indexes.feature.spec.ts @@ -125,6 +125,185 @@ describe('withAiIndexes', () => { }); }); + it('should not call an index Building because its name is on Object.prototype', () => { + // The seed map comes from Object.fromEntries, so `in` walked its prototype: an index + // named constructor / toString / valueOf / hasOwnProperty read as seeded when it was + // not, and then rendered Building forever — nothing clears a seed that never existed. + // The create form's name pattern accepts all four. + stubIndexes([ + index({ name: 'constructor' }), + index({ name: 'toString' }), + index({ name: 'valueOf' }), + index({ name: 'hasOwnProperty' }) + ]); + + store.loadIndexes(); + + expect(store.indexBuildSeeds()).toEqual({}); + expect(Object.values(store.indexStatuses())).toEqual([ + DOT_AI_INDEX_STATUS.READY, + DOT_AI_INDEX_STATUS.READY, + DOT_AI_INDEX_STATUS.READY, + DOT_AI_INDEX_STATUS.READY + ]); + }); + + describe('an index the server has not caught up with', () => { + // Embedding is asynchronous, so a freshly built index has nothing in dot_embeddings and + // indexCount does not return it. Every assertion here is about the window in between, + // which is what used to leave the user reloading the page to see their new index. + it('should put the new index in the table straight away', () => { + stubIndexes([index({ name: 'existing' })]); + store.loadIndexes(); + + store.markIndexBuilding('blogs'); + + expect(store.indexes().map((row) => row.name)).toContain('blogs'); + expect(store.indexStatuses()['blogs']).toBe(DOT_AI_INDEX_STATUS.BUILDING); + }); + + it('should keep it on a poll that still does not list it', () => { + // The regression: applyIndexes rebuilt the status map from the server's list alone, + // so this very refresh erased the BUILDING flag it was meant to act on. + stubIndexes([index({ name: 'existing' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + + store.loadIndexes(); + + expect(store.indexes().map((row) => row.name)).toContain('blogs'); + expect(store.indexStatuses()['blogs']).toBe(DOT_AI_INDEX_STATUS.BUILDING); + }); + + it('should not settle it to READY off a placeholder that never moves', () => { + // The placeholder stands at zero fragments. Snapshotting that would make the next + // poll read "unchanged" and call the build finished before it started. + stubIndexes([index({ name: 'existing' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + + store.loadIndexes(); + store.loadIndexes(); + + expect(store.indexStatuses()['blogs']).toBe(DOT_AI_INDEX_STATUS.BUILDING); + }); + + it('should hand over to the real row once the server lists it', () => { + stubIndexes([index({ name: 'existing' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + + stubIndexes([index({ name: 'existing' }), index({ name: 'blogs', fragments: 4 })]); + store.loadIndexes(); + + const blogs = store.indexes().find((row) => row.name === 'blogs'); + + expect(store.indexes().filter((row) => row.name === 'blogs')).toHaveLength(1); + expect(blogs?.fragments).toBe(4); + expect(store.indexStatuses()['blogs']).toBe(DOT_AI_INDEX_STATUS.BUILDING); + }); + + it('should not cut off a long build the server is still reporting progress on', () => { + // The TTL covers only the window before the index is listed at all. Measured from + // the start of the build instead, a large one would flip to Ready mid-flight and + // stop the poll — the exact failure this feature exists to prevent. + vi.useFakeTimers(); + + try { + stubIndexes([index({ name: 'existing' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + + stubIndexes([index({ name: 'existing' }), index({ name: 'blogs', fragments: 4 })]); + store.loadIndexes(); + + vi.advanceTimersByTime(5 * 60 * 1000); + + stubIndexes([index({ name: 'existing' }), index({ name: 'blogs', fragments: 90 })]); + store.loadIndexes(); + + expect(store.indexStatuses()['blogs']).toBe(DOT_AI_INDEX_STATUS.BUILDING); + } finally { + vi.useRealTimers(); + } + }); + + it('should forget a seed when its index is deleted, so no ghost row returns', () => { + // The seed outlived the index: `withPendingIndexes` put the deleted index straight + // back in the table as a zeroed BUILDING row, offered in the retrieval picker and + // eligible to become settingsIndexName, for the rest of the grace period. + stubIndexes([index({ name: 'blogs', fragments: 10 })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + + store.forgetIndexBuildSeeds('blogs'); + stubIndexes([]); + store.loadIndexes(); + + expect(store.indexes()).toEqual([]); + expect(store.indexStatuses()).toEqual({}); + expect(store.indexBuildSeeds()).toEqual({}); + }); + + it('should take the row with the seed, so a failed refresh leaves nothing behind', () => { + // loadIndexes' error branch leaves `indexes` untouched, so a row cleared only by + // the next successful refresh outlived the index it stood for. + stubIndexes([index({ name: 'blogs', fragments: 10 })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + + store.forgetIndexBuildSeeds('blogs'); + + expect(store.indexes().map((row) => row.name)).not.toContain('blogs'); + }); + + it('should treat an empty name as a name, not as "all of them"', () => { + // '' is what a store-wide rebuild reports as its index name, so a falsy check here + // would quietly clear every seed. + stubIndexes([index({ name: 'blogs' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + + store.forgetIndexBuildSeeds(''); + + expect(store.indexBuildSeeds()).toHaveProperty('blogs'); + }); + + it('should forget every seed when the whole store is rebuilt', () => { + stubIndexes([index({ name: 'blogs' }), index({ name: 'other' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + store.markIndexBuilding('other'); + + store.forgetIndexBuildSeeds(); + stubIndexes([]); + store.loadIndexes(); + + expect(store.indexes()).toEqual([]); + expect(store.indexBuildSeeds()).toEqual({}); + }); + + it('should stop waiting on a build that never materialises', () => { + // Without an expiry the seed would keep the badge up and the poll running for the + // life of the page. + vi.useFakeTimers(); + + try { + stubIndexes([index({ name: 'existing' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + + vi.advanceTimersByTime(3 * 60 * 1000); + store.loadIndexes(); + + expect(store.indexes().map((row) => row.name)).not.toContain('blogs'); + expect(store.indexStatuses()['blogs']).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + }); + describe('index seeding (FR-018)', () => { it('should keep a restored index that is still offered', () => { // The old "seed once" flag was never persisted, so every visit arrived unseeded and @@ -162,6 +341,52 @@ describe('withAiIndexes', () => { expect(spectator.inject(DotAiEmbeddingsService).getIndexes).toHaveBeenCalledTimes(1); }); + it('should give up on a build when the refresh itself keeps failing', () => { + // The expiry used to live only in applyIndexes, which a failing request never + // reaches — so a build started just before the server went away polled every five + // seconds for the life of the page, raising an error dialog on each tick. + stubIndexes([index({ name: 'existing' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + spectator.flushEffects(); + + spectator.inject(DotAiEmbeddingsService).getIndexes = vi + .fn() + .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + vi.advanceTimersByTime(3 * 60 * 1000); + spectator.flushEffects(); + + expect(store.indexBuildSeeds()).toEqual({}); + + const calls = (spectator.inject(DotAiEmbeddingsService).getIndexes as Mock).mock.calls + .length; + vi.advanceTimersByTime(20000); + + expect( + (spectator.inject(DotAiEmbeddingsService).getIndexes as Mock).mock.calls.length + ).toBe(calls); + }); + + it('should stop polling a forbidden endpoint once the build expires', () => { + // A 403 mid-build (the admin role revoked in session) took the same path: nothing + // pruned the seed, so the poll retried an endpoint it would never be allowed on. + stubIndexes([index({ name: 'existing' })]); + store.loadIndexes(); + store.markIndexBuilding('blogs'); + spectator.flushEffects(); + + spectator.inject(DotAiEmbeddingsService).getIndexes = vi + .fn() + .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 403 }))); + + vi.advanceTimersByTime(3 * 60 * 1000); + spectator.flushEffects(); + + expect(store.indexesForbidden()).toBe(true); + expect(store.indexBuildSeeds()).toEqual({}); + }); + it('should re-fetch until the build settles, then stop', () => { stubIndexes([index({ name: 'blogs', fragments: 10 })]); store.loadIndexes(); diff --git a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-indexes.feature.ts b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-indexes.feature.ts index e7972a1c0f8d..95a872ab883c 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-indexes.feature.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-indexes.feature.ts @@ -15,14 +15,33 @@ import { computed, inject } from '@angular/core'; import { catchError, switchMap, tap } from 'rxjs/operators'; import { DotAiEmbeddingsService, DotHttpErrorManagerService } from '@dotcms/data-access'; -import { DOT_AI_INDEX_STATUS, DotAiIndex } from '@dotcms/dotcms-models'; +import { DOT_AI_INDEX_STATUS, DotAiIndex, DotAiIndexStatus } from '@dotcms/dotcms-models'; import { DotAiPortletState } from '../../models/dot-ai-portlet.models'; -import { deriveIndexStatuses, toIndexOptions } from '../../utils/dot-ai-index.utils'; +import { + stillBuildingSeeds, + toIndexOptions, + toRetrievalIndexes, + withPendingIndexes +} from '../../utils/dot-ai-index.utils'; /** Matches the legacy portlet's cadence; fast enough to feel live, slow enough to be cheap. */ const INDEX_POLL_MS = 5000; +/** + * How long a requested build may stay missing from `indexCount` before the portlet gives up on + * it. + * + * Deliberately scoped to the window *before* the index is listed at all. Once it appears, the + * fragment-count delta owns its lifecycle and no clock is involved — a timer measured from the + * start of the build would cut a large one off mid-flight, flipping a still-growing index to + * Ready and stopping the poll, which is the exact failure this feature exists to prevent. + * + * Embedding normally writes its first rows within a second or two, so a build still unlisted + * after two minutes failed somewhere the client cannot see. + */ +const PENDING_SEED_TTL_MS = 2 * 60 * 1000; + /** * The embeddings index list — one owner, two readers. * @@ -39,27 +58,86 @@ export function withAiIndexes() { type<{ state: DotAiPortletState }>(), withComputed((store) => ({ /** Retrieval targets only — the cache pseudo-index is excluded. */ - indexOptions: computed(() => toIndexOptions(store.indexes())) + indexOptions: computed(() => toIndexOptions(store.indexes())), + + /** + * Build status per index, projected rather than stored. + * + * `applyIndexes` keeps a seed only while its index still derives as BUILDING, so + * "seeded" and "building" are the same fact by the time anything reads this — + * holding it as a third state field only created somewhere for the two to drift. + */ + indexStatuses: computed>(() => { + const seeds = store.indexBuildSeeds(); + + return Object.fromEntries( + store.indexes().map((index) => [ + index.name, + // `hasOwnProperty`, not `in`: the seed map comes from + // `Object.fromEntries`, so `in` walks its prototype and an index + // named `constructor`, `toString`, `valueOf` or `hasOwnProperty` + // reads as seeded when it is not — then renders Building forever, + // since nothing clears a seed that does not exist. The create + // form's name pattern accepts all four. + Object.prototype.hasOwnProperty.call(seeds, index.name) + ? DOT_AI_INDEX_STATUS.BUILDING + : DOT_AI_INDEX_STATUS.READY + ]) + ); + }) })), withMethods((store) => { const embeddingsService = inject(DotAiEmbeddingsService); const httpErrorManager = inject(DotHttpErrorManagerService); + /** + * The seeds still worth waiting on: listed by the server, or still inside the + * grace period. + * + * Called from the failure paths as well as the success one. The poll runs while + * any seed is held, and a `loadIndexes` that keeps failing never reaches + * `applyIndexes` — so with the expiry enforced only there, a build started just + * before the server went away would poll every five seconds for the life of the + * page, raising an error dialog on each tick. Expiry is exactly the answer to + * that, and it has to run wherever the request lands. + */ + const liveSeeds = (listed: Set): Record => { + const now = Date.now(); + + return Object.fromEntries( + Object.entries(store.indexBuildSeeds()).filter( + ([name, requestedAt]) => + listed.has(name) || now - requestedAt < PENDING_SEED_TTL_MS + ) + ); + }; + const applyIndexes = (indexes: DotAiIndex[]) => { - const offered = toIndexOptions(indexes).map((option) => option.value); + const listed = new Set(indexes.map((index) => index.name)); + const live = liveSeeds(listed); + const seeds = new Set(Object.keys(live)); - const seeds = new Set(store.indexBuildSeeds()); - const statuses = deriveIndexStatuses(indexes, store.indexFragmentSnapshot(), seeds); + // The server's list plus a placeholder for each seeded build it has not caught + // up with, so a new index is in the table from the moment it is requested. + const merged = withPendingIndexes(indexes, seeds); // An index that has settled is no longer a candidate for the next poll. - const stillBuilding = store - .indexBuildSeeds() - .filter((name) => statuses[name] === DOT_AI_INDEX_STATUS.BUILDING); + const building = stillBuildingSeeds(merged, store.indexFragmentSnapshot(), seeds); + const stillBuilding = Object.fromEntries( + Object.entries(live).filter(([name]) => building.has(name)) + ); + + // Retrieval targets, for the picker's fallback below. Read off `merged` rather + // than through `toIndexOptions`, which would build and discard a label per + // index for what is one membership test. + const offered = toRetrievalIndexes(merged).map((index) => index.name); patchState(store, { - indexes, - indexStatuses: statuses, + indexes: merged, indexBuildSeeds: stillBuilding, + // Snapshotted from the server's own response, never from `merged`: a + // placeholder recorded at zero fragments would look like a settled index on + // the next poll and flip itself to READY before the build had begun. indexFragmentSnapshot: indexes.reduce>( (snapshot, index) => { snapshot[index.name] = index.fragments; @@ -87,15 +165,22 @@ export function withAiIndexes() { embeddingsService.getIndexes().pipe( tap(applyIndexes), catchError((error: HttpErrorResponse) => { + // Nothing was listed, so only the grace period can keep a + // seed now — which is what stops the poll rather than + // letting it retry a dead endpoint forever. + const surviving = liveSeeds(new Set()); + if (error?.status === 403) { patchState(store, { indexesForbidden: true, - indexes: [] + indexes: [], + indexBuildSeeds: surviving }); return EMPTY; } + patchState(store, { indexBuildSeeds: surviving }); httpErrorManager.handle(error); return EMPTY; @@ -112,13 +197,46 @@ export function withAiIndexes() { * "a build just started here", so the first poll does not have to infer it * from a delta that has not appeared yet. */ + /** + * Drops build seeds, by name or all of them. + * + * Deleting an index, or rebuilding the store, ends any build outstanding for + * it. Without this the seed outlives the index it was for and + * `withPendingIndexes` puts the deleted index straight back in the table as a + * zeroed BUILDING row — offered in the retrieval picker, and eligible to + * become `settingsIndexName`, for the rest of the grace period. + */ + forgetIndexBuildSeeds(indexName?: string): void { + // `undefined`, not falsy: '' is a real value in this module — `report` + // passes it as the index name for a store-wide rebuild — and it must not + // be mistaken for "all of them". + if (indexName === undefined) { + patchState(store, { indexBuildSeeds: {}, indexes: [] }); + + return; + } + + // The row goes with the seed. `markIndexBuilding` writes both, and leaving + // the row for the refresh to clear left a deleted index standing — as a + // plausible-looking READY row with zeroes — whenever that refresh failed. + patchState(store, { + indexBuildSeeds: Object.fromEntries( + Object.entries(store.indexBuildSeeds()).filter( + ([name]) => name !== indexName + ) + ), + indexes: store.indexes().filter((index) => index.name !== indexName) + }); + }, + markIndexBuilding(indexName: string): void { patchState(store, { - indexBuildSeeds: [...new Set([...store.indexBuildSeeds(), indexName])], - indexStatuses: { - ...store.indexStatuses(), - [indexName]: DOT_AI_INDEX_STATUS.BUILDING - } + indexBuildSeeds: { ...store.indexBuildSeeds(), [indexName]: Date.now() }, + // Stand the row up now rather than waiting for the next poll — the build + // has been accepted, so the index exists whether or not `indexCount` + // knows about it yet. `withPendingIndexes` is a no-op, same array + // reference included, when the list already has it. + indexes: withPendingIndexes(store.indexes(), new Set([indexName])) }); } }; @@ -150,11 +268,7 @@ export function withAiIndexes() { })), withHooks({ onInit(store) { - store.pollIndexes( - computed(() => - Object.values(store.indexStatuses()).includes(DOT_AI_INDEX_STATUS.BUILDING) - ) - ); + store.pollIndexes(computed(() => Object.keys(store.indexBuildSeeds()).length > 0)); } }) ); diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.html b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.html index fa10cb31c879..1849e72476fb 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.html +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.html @@ -1,8 +1,10 @@
+
-
- @if (store.chatAnswer(); as current) { + @if (store.chatAnswer(); as current) { +
- - } -
+
+ } @else { + + }
+ - {{ store.resolvedConfig()?.configHost }} + {{ $hostLabel() }} - - - -
@if (store.redactionFailed()) { - - } @else { + } @else if ($emptyConfig(); as empty) { + + + } @else if ($filteredRows().length) { - - - - -
-

{{ 'dotai.config.empty' | dm }}

-
- - -
}
- - -
{{ $providerJson() }}
-
diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.spec.ts index f91d146c7ca3..3de4085d5254 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.spec.ts @@ -14,7 +14,8 @@ import DotAiConfigValuesComponent from './dot-ai-config-values.component'; import { DotAiStore } from '../../store/dot-ai.store'; const resolved = (overrides: Partial = {}): DotAiResolvedConfig => ({ - configHost: 'demo.dotcms.com (falls back to system host)', + configHost: 'demo.dotcms.com', + configHostInherited: false, settings: { temperature: '0.7', debugLogging: 'false' }, providerConfig: { chat: { apiKey: '*****', temperature: '0.7' } }, chatModels: [], @@ -29,7 +30,8 @@ describe('DotAiConfigValuesComponent', () => { const storeMock = { resolvedConfig: vi.fn().mockReturnValue(resolved()), redactionFailed: vi.fn().mockReturnValue(false), - isConfigured: vi.fn().mockReturnValue(true) + isConfigured: vi.fn().mockReturnValue(true), + configUnavailable: vi.fn().mockReturnValue(false) }; const createComponent = createComponentFactory({ @@ -43,6 +45,7 @@ describe('DotAiConfigValuesComponent', () => { vi.clearAllMocks(); storeMock.resolvedConfig.mockReturnValue(resolved()); storeMock.redactionFailed.mockReturnValue(false); + storeMock.configUnavailable.mockReturnValue(false); spectator = createComponent(); }); @@ -50,9 +53,21 @@ describe('DotAiConfigValuesComponent', () => { expect(spectator.queryAll(byTestId('dotai-config-row')).length).toBeGreaterThan(0); }); - it('should render configHost verbatim, since the server sends a display string', () => { - expect(spectator.query(byTestId('dotai-config-host'))).toContainText( - 'falls back to system host' + it('should label the host from a message key rather than a server-built sentence', () => { + const messageService = spectator.inject(DotMessageService, true); + + expect(messageService.get).toHaveBeenCalledWith('dotai.config.host', 'demo.dotcms.com'); + }); + + it('should say when the configuration was inherited from System Host', () => { + // Two different facts: which site is on screen, and whose settings those are. The old + // server-built string claimed the fallback unconditionally. + storeMock.resolvedConfig.mockReturnValue(resolved({ configHostInherited: true })); + spectator = createComponent(); + + expect(spectator.inject(DotMessageService, true).get).toHaveBeenCalledWith( + 'dotai.config.host.inherited', + 'demo.dotcms.com' ); }); @@ -71,29 +86,64 @@ describe('DotAiConfigValuesComponent', () => { expect(spectator.query(byTestId('dotai-config-table'))).toBeFalsy(); }); - describe('the provider JSON view', () => { - const viewProviderButton = (): HTMLButtonElement | null => - spectator.query(byTestId('dotai-config-view-provider'))?.querySelector('button') ?? - null; + describe('an empty table', () => { + // Three different reasons the rows can be empty, and the screen has to say which. + // resolvedConfig is a computed that always returns an object, so "no rows" is the + // state during the initial load and after a failed one as well as after a filter + // that matched nothing. + const empty = (): HTMLElement | null => + spectator.query(byTestId('dotai-config-empty')) as HTMLElement | null; + + it('should blame the filter only when there is a filter', () => { + storeMock.resolvedConfig.mockReturnValue( + resolved({ settings: {}, providerConfig: null }) + ); + spectator = createComponent(); + spectator.triggerEventHandler( + byTestId('dotai-config-filter'), + 'search', + 'nothing-matches-this' + ); + spectator.detectChanges(); + + expect(empty()).toBeTruthy(); + expect(spectator.inject(DotMessageService, true).get).toHaveBeenCalledWith( + 'dotai.config.empty' + ); + }); - it('should mask credentials rather than printing the server mask (FR-042)', () => { - spectator.click(viewProviderButton() as HTMLButtonElement); - const json = spectator.query(byTestId('dotai-config-provider-json'))?.textContent ?? ''; + it('should say the config could not be loaded rather than blaming an empty filter', () => { + // The regression: a failed load left the diagnostic screen telling the user their + // filter matched nothing, with the filter box empty. + storeMock.resolvedConfig.mockReturnValue( + resolved({ settings: {}, providerConfig: null }) + ); + storeMock.configUnavailable.mockReturnValue(true); + spectator = createComponent(); - expect(json).toContain('••••••••'); - expect(json).not.toContain('*****'); + expect(empty()).toBeTruthy(); + expect(spectator.inject(DotMessageService, true).get).toHaveBeenCalledWith( + 'dotai.config.unavailable.title' + ); }); - it('should not be offered when redaction failed', () => { - // providerConfig is null there while isConfigured stays true, so the dialog would - // open on `{}` — which reads as "no provider configuration" rather than "withheld". - storeMock.redactionFailed.mockReturnValue(true); + it('should show nothing at all while the config is still loading', () => { + // A brief blank pane says less than a wrong sentence does. + storeMock.resolvedConfig.mockReturnValue( + resolved({ settings: {}, providerConfig: null }) + ); spectator = createComponent(); - expect(viewProviderButton()?.hasAttribute('disabled')).toBe(true); + expect(empty()).toBeFalsy(); + expect(spectator.query(byTestId('dotai-config-table'))).toBeFalsy(); }); }); + it('should not offer a provider config view at all', () => { + // Removed on review: the raw JSON dump was not something the screen needed to carry. + expect(spectator.query(byTestId('dotai-config-view-provider'))).toBeFalsy(); + }); + describe('the filter bar', () => { // jsdom does no layout, so these assert the class contract; the geometry it produces // was measured in the browser (280px filter, one line box on the button). @@ -116,15 +166,5 @@ describe('DotAiConfigValuesComponent', () => { // action right without either being positioned. expect(host.className).toContain('flex-1'); }); - - it('should not let the view-provider label wrap', () => { - // PrimeNG sets no white-space on the button or its label, so a squeezed button - // broke "View provider config" across two lines — measured at 1100px, not just - // at narrow widths. - const button = spectator.query(byTestId('dotai-config-view-provider')) as HTMLElement; - - expect(button.className).toContain('whitespace-nowrap'); - expect(button.className).toContain('shrink-0'); - }); }); }); diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.ts index f32332c7735d..8d3b19ade92f 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.ts @@ -1,23 +1,23 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { ButtonModule } from 'primeng/button'; -import { DialogModule } from 'primeng/dialog'; import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; -import { DotCopyButtonComponent, DotMessagePipe, DotSearchInputComponent } from '@dotcms/ui'; +import { DotMessageService } from '@dotcms/data-access'; +import { + DotCopyButtonComponent, + DotEmptyContainerComponent, + DotMessagePipe, + DotSearchInputComponent, + PrincipalConfiguration +} from '@dotcms/ui'; -import { DotAiEmptyStateComponent } from '../../components/dot-ai-empty-state/dot-ai-empty-state.component'; import { DotAiStore } from '../../store/dot-ai.store'; -import { - DOT_AI_CONFIG_SOURCE, - maskCredentials, - toConfigRows -} from '../../utils/dot-ai-config.utils'; +import { DOT_AI_CONFIG_SOURCE, toConfigRows } from '../../utils/dot-ai-config.utils'; +import { toEmptyStateConfig } from '../../utils/dot-ai-empty-state.utils'; /** - * Config Values: every resolved dotAI setting, where it came from, and the raw provider - * configuration behind it. + * Config Values: every resolved dotAI setting and where it came from. * * A diagnostic screen, so it stays reachable and useful even when nothing else works — * which is exactly when it is needed (FR-048). @@ -25,11 +25,9 @@ import { @Component({ selector: 'dot-ai-config-values', imports: [ - DotAiEmptyStateComponent, + DotEmptyContainerComponent, TableModule, TagModule, - ButtonModule, - DialogModule, DotSearchInputComponent, DotCopyButtonComponent, DotMessagePipe @@ -40,9 +38,48 @@ import { export default class DotAiConfigValuesComponent { protected readonly store = inject(DotAiStore); + readonly #messageService = inject(DotMessageService); + protected readonly sources = DOT_AI_CONFIG_SOURCE; protected readonly $filter = signal(''); - protected readonly $providerDialogOpen = signal(false); + + protected readonly redactionFailedConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.config.redaction-failed.title', + subtitle: 'dotai.config.redaction-failed.sub', + icon: 'visibility_off' + }); + + readonly #noMatchesConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.config.empty', + icon: 'filter_alt_off' + }); + + readonly #unavailableConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.config.unavailable.title', + subtitle: 'dotai.config.unavailable.sub', + icon: 'cloud_off' + }); + + /** + * Which site's configuration is on screen, and whether it is actually that site's. + * + * The server resolves dotAI settings per site and falls back to the System Host's when the + * site has none of its own, so these are two different facts and the label has to say which + * one applies. They arrive as separate fields precisely so this can be a message key rather + * than an English sentence assembled on the server. + */ + protected readonly $hostLabel = computed(() => { + const config = this.store.resolvedConfig(); + + if (!config?.configHost) { + return ''; + } + + return this.#messageService.get( + config.configHostInherited ? 'dotai.config.host.inherited' : 'dotai.config.host', + config.configHost + ); + }); protected readonly $rows = computed(() => toConfigRows(this.store.resolvedConfig())); @@ -61,14 +98,28 @@ export default class DotAiConfigValuesComponent { }); /** - * A flat two-column table cannot represent nested JSON, so it gets its own view. + * Which empty state the pane owes the user, or null when it owes none. * - * Masked on the way out: the server sends credential fields as `*****`, and printing that - * verbatim shows a mask that reads like a real value (FR-042). + * The rows being empty is not one fact but three, and they need different sentences. + * `resolvedConfig` is a computed that always returns an object, so the rows are empty + * during the initial async window and after a failed load as well as after a filter that + * matched nothing -- and blaming the filter in the first two cases is a lie told by the + * one screen that exists to be trusted when nothing else works (FR-048). + * + * Null while the config is still on its way: a brief blank pane says less than a wrong + * sentence does. */ - protected readonly $providerJson = computed(() => - JSON.stringify(maskCredentials(this.store.resolvedConfig()?.providerConfig ?? {}), null, 2) - ); + protected readonly $emptyConfig = computed(() => { + if (this.$filteredRows().length) { + return null; + } + + if (this.$filter().trim()) { + return this.#noMatchesConfig; + } + + return this.store.configUnavailable() ? this.#unavailableConfig : null; + }); protected severityFor(source: string): 'info' | 'secondary' { return source === DOT_AI_CONFIG_SOURCE.APP_CONFIG ? 'info' : 'secondary'; diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.html b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.html index 8cc95732017a..02ea525bf882 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.html +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.html @@ -9,8 +9,19 @@
+ + - @if (store.indexBuildNotice(); as notice) { - - - @switch (notice.kind) { - @case ('built') { - {{ 'dotai.embeddings.build.ok' | dm: [notice.detail ?? '', notice.indexName] }} - } - @case ('empty') { - {{ 'dotai.embeddings.build.empty' | dm: [notice.indexName] }} - } - @default { - {{ - 'dotai.embeddings.build.failed' - | dm: [notice.indexName, notice.detail ?? ''] - }} - } - } - - } -
@if (store.indexesForbidden()) { - + } @else if (!store.filteredIndexes().length) { + + } @else { - - - - - - - - }
+ + + diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.spec.ts index 51b6cb2e66ae..189142076859 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.spec.ts @@ -7,7 +7,9 @@ import { import { Subject } from 'rxjs'; import { Mock, MockInstance, vi } from 'vitest'; -import { ConfirmationService } from 'primeng/api'; +import { signal } from '@angular/core'; + +import { ConfirmationService, MessageService } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; @@ -15,6 +17,7 @@ import { DotAiIndex } from '@dotcms/dotcms-models'; import DotAiEmbeddingsComponent from './dot-ai-embeddings.component'; +import { DOT_AI_INDEX_OPERATION, DotAiIndexNotice } from '../../models/dot-ai-portlet.models'; import { DotAiStore } from '../../store/dot-ai.store'; const index = (overrides: Partial = {}): DotAiIndex => ({ @@ -30,8 +33,10 @@ const index = (overrides: Partial = {}): DotAiIndex => ({ describe('DotAiEmbeddingsComponent', () => { let spectator: Spectator; let onClose: Subject; + let onDialogDestroy: Subject; let dialogService: DialogService; let confirmSpy: MockInstance; + let toastSpy: MockInstance; const storeMock = { indexes: vi.fn().mockReturnValue([index()]), @@ -41,9 +46,11 @@ describe('DotAiEmbeddingsComponent', () => { isConfigured: vi.fn().mockReturnValue(true), setIndexFilter: vi.fn(), buildIndex: vi.fn(), - indexBuildNotice: vi.fn().mockReturnValue(null), - dismissBuildNotice: vi.fn(), - deleteFromIndex: vi.fn(), + // A real signal, not a mock fn: the component reads this inside an `effect`, which + // only re-runs for tracked dependencies. + indexNotice: signal(null), + indexNoticeOwner: signal<{ operation: string; indexName: string } | null>(null), + removeFromIndex: vi.fn(), deleteIndex: vi.fn(), rebuildEmbeddingsDb: vi.fn() }; @@ -56,7 +63,10 @@ describe('DotAiEmbeddingsComponent', () => { componentProviders: [ { provide: DotAiStore, useValue: storeMock }, ConfirmationService, - { provide: DialogService, useValue: { open: vi.fn() } } + { provide: DialogService, useValue: { open: vi.fn() } }, + // Real, for the same reason ConfirmationService is: p-toast subscribes to its + // messageObserver at construction and a bare mock has none. + MessageService ], // Echoes the key, so assertions on dialog copy read as the key that was asked // for rather than `undefined`. @@ -69,10 +79,14 @@ describe('DotAiEmbeddingsComponent', () => { storeMock.indexesForbidden.mockReturnValue(false); storeMock.filteredIndexes.mockReturnValue([index()]); onClose = new Subject(); + onDialogDestroy = new Subject(); + storeMock.indexNotice.set(null); + storeMock.indexNoticeOwner.set(null); spectator = createComponent(); dialogService = spectator.inject(DialogService, true); - (dialogService.open as Mock).mockReturnValue({ onClose }); + (dialogService.open as Mock).mockReturnValue({ onClose, onDestroy: onDialogDestroy }); confirmSpy = vi.spyOn(spectator.inject(ConfirmationService, true), 'confirm'); + toastSpy = vi.spyOn(spectator.inject(MessageService, true), 'add'); }); const clickButton = (testId: string) => @@ -143,64 +157,120 @@ describe('DotAiEmbeddingsComponent', () => { expect.objectContaining({ width: '700px', closable: true, - closeOnEscape: true + // The dialog handles Escape itself, so that it can decline while a + // request is in flight — PrimeNG binds its own listener once at open. + closeOnEscape: false }) ); }); - it('should build without forwarding the dialog-only mode field', () => { - // The server answers 400 "Unrecognized field 'mode'" rather than ignoring it, so - // passing the dialog result through verbatim broke every index build. + it('should still report an outcome no open dialog owns', () => { + // A delete fails while the build dialog happens to be up. The dialog renders only + // outcomes for the request it submitted, so suppressing on "a dialog is open" + // alone left this reported by nobody at all. clickButton('dotai-embeddings-new-index'); - - onClose.next({ mode: 'add', indexName: 'blogs', query: '+contentType:Blog' }); - - expect(storeMock.buildIndex).toHaveBeenCalledWith({ + storeMock.indexNoticeOwner.set({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + indexName: 'other' + }); + storeMock.indexNotice.set({ + operation: DOT_AI_INDEX_OPERATION.DELETE_INDEX, + outcome: 'failed', indexName: 'blogs', - query: '+contentType:Blog' + detail: 'boom' }); - expect(storeMock.buildIndex.mock.calls[0][0]).not.toHaveProperty('mode'); - expect(storeMock.deleteFromIndex).not.toHaveBeenCalled(); + spectator.detectChanges(); + + expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({ severity: 'error' })); }); - it('should still forward the optional build fields', () => { + it('should stay quiet about an outcome the dialog is there to render', () => { + // Otherwise the failure appears twice — once in the form holding the query, once + // as a toast over a modal, which is the report nobody could act on. clickButton('dotai-embeddings-new-index'); - - onClose.next({ - mode: 'add', + storeMock.indexNoticeOwner.set({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + indexName: 'blogs' + }); + storeMock.indexNotice.set({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', indexName: 'blogs', - query: '+contentType:Blog', - fields: 'title,body', - velocityTemplate: '$!{title}' + detail: 'Cannot parse query' }); + spectator.detectChanges(); + + expect(toastSpy).not.toHaveBeenCalled(); + }); - expect(storeMock.buildIndex).toHaveBeenCalledWith({ + it('should report a build abandoned mid-flight once the dialog has gone', () => { + // Escape or the header X while the build is still running: the outcome arrives + // after the dialog is destroyed, so this tab is the only thing left that can show + // it. It used to render the success only, so a failure disappeared. + clickButton('dotai-embeddings-new-index'); + storeMock.indexNoticeOwner.set(null); + storeMock.indexNotice.set({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', indexName: 'blogs', - query: '+contentType:Blog', - fields: 'title,body', - velocityTemplate: '$!{title}' + detail: 'Cannot parse query' }); + spectator.detectChanges(); + + expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({ severity: 'error' })); }); - it('should delete from the index on a delete-mode result (FR-030)', () => { + it('should not toast a failure the dialog already showed inline', () => { + // Suppressed while the dialog is up, and marked reported all the same — otherwise + // closing the dialog toasts the error the user has just read and dismissed. clickButton('dotai-embeddings-new-index'); + storeMock.indexNoticeOwner.set({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + indexName: 'blogs' + }); + storeMock.indexNotice.set({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', + indexName: 'blogs', + detail: 'Cannot parse query' + }); + spectator.detectChanges(); - onClose.next({ mode: 'delete', indexName: 'blogs', query: '+contentType:Blog' }); + storeMock.indexNoticeOwner.set(null); + spectator.detectChanges(); - expect(storeMock.deleteFromIndex).toHaveBeenCalledWith({ - indexName: 'blogs', - query: '+contentType:Blog' + expect(toastSpy).not.toHaveBeenCalled(); + }); + + it('should keep suppressing while the dialog is still closing', () => { + // `close()` fires onClose and only then plays the leave animation, so the dialog is + // still up and still showing the outcome. Ownership is released in the dialog's own + // teardown, which runs after that — so this is about ownership, not the close event. + clickButton('dotai-embeddings-new-index'); + storeMock.indexNoticeOwner.set({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + indexName: 'blogs' }); - expect(storeMock.buildIndex).not.toHaveBeenCalled(); + storeMock.indexNotice.set({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', + indexName: 'blogs' + }); + onClose.next(undefined); + spectator.detectChanges(); + + expect(toastSpy).not.toHaveBeenCalled(); }); - it('should do nothing when the dialog is dismissed', () => { + it('should leave the build to the dialog rather than submitting on close', () => { + // The dialog owns the submit now: a rejected Lucene query has to be correctable in + // the form that produced it, not reported here after the modal took the query away. clickButton('dotai-embeddings-new-index'); onClose.next(undefined); expect(storeMock.buildIndex).not.toHaveBeenCalled(); - expect(storeMock.deleteFromIndex).not.toHaveBeenCalled(); + expect(storeMock.removeFromIndex).not.toHaveBeenCalled(); }); }); @@ -239,22 +309,73 @@ describe('DotAiEmbeddingsComponent', () => { }); }); + describe('removing content', () => { + it('should be a toolbar action beside New Index, not a row action', () => { + // The two are the same shape of operation — a Lucene query plus an index to scope + // it to. On a row it implied it knew what was in that index, and nothing records + // the query that built one, so the query here is written blind either way. + expect(spectator.query(byTestId('dotai-embeddings-remove-content'))).toBeTruthy(); + expect(spectator.query(byTestId('dotai-embeddings-row-actions'))).toBeFalsy(); + }); + + it('should open its dialog at the mandated width and be dismissible', () => { + clickButton('dotai-embeddings-remove-content'); + + expect(dialogService.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + width: '700px', + closable: true, + // The dialog handles Escape itself, so that it can decline while a + // request is in flight — PrimeNG binds its own listener once at open. + closeOnEscape: false + }) + ); + }); + + it('should report a removal outcome for an index no open dialog claimed', () => { + clickButton('dotai-embeddings-remove-content'); + storeMock.indexNoticeOwner.set({ + operation: DOT_AI_INDEX_OPERATION.REMOVE_CONTENT, + indexName: 'other' + }); + storeMock.indexNotice.set({ + operation: DOT_AI_INDEX_OPERATION.REMOVE_CONTENT, + outcome: 'failed', + indexName: 'blogs' + }); + spectator.detectChanges(); + + expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({ severity: 'error' })); + }); + + it('should be unavailable when there is no index to remove from', () => { + storeMock.indexes.mockReturnValue([]); + spectator = createComponent(); + + expect( + spectator + .query(byTestId('dotai-embeddings-remove-content')) + ?.querySelector('button')?.disabled + ).toBe(true); + }); + }); + describe('the per-row delete action', () => { - const deleteButton = () => + const trigger = () => spectator.query(byTestId('dotai-embeddings-delete'))?.querySelector('button'); it('should be secondary, so a red control does not sit on every row', () => { // The destructive step is the confirm dialog, whose accept button carries // p-button-danger; the row action only opens it. - expect(deleteButton()?.className).toContain('p-button-secondary'); - expect(deleteButton()?.className).not.toContain('p-button-danger'); + expect(trigger()?.className).toContain('p-button-secondary'); + expect(trigger()?.className).not.toContain('p-button-danger'); }); it('should force its own square rather than rely on the icon-only token', () => { // PrimeNG's icon-only width sets a width and leaves the height to padding plus // content, so with a full-size glyph the button came out 28x37 — the distortion. - // dot-plugins and dot-locales both pin the box in `styleClass` for this reason. - const classes = deleteButton()?.className.split(/\s+/) ?? []; + const classes = trigger()?.className.split(/\s+/) ?? []; expect(classes).toContain('w-8'); expect(classes).toContain('h-8'); @@ -262,27 +383,21 @@ describe('DotAiEmbeddingsComponent', () => { }); it('should collapse the glyph line box, which is what inflated the height', () => { - // `leading-none` is the other half: without it the glyph's own line-height sets - // the button's content height and no width class can square it up. - const glyph = deleteButton()?.querySelector('.material-symbols-outlined'); + const glyph = trigger()?.querySelector('.material-symbols-outlined'); expect(glyph?.className).toContain('leading-none!'); expect(glyph?.className).toContain('text-lg!'); }); - it('should be a borderless round action, like the other tables row actions', () => { - const classes = deleteButton()?.className.split(/\s+/) ?? []; + it('should keep Rebuild DB a plain outlined button, not a red one', () => { + // A permanently-red control in the toolbar read as a warning about the screen. + // The destructive step is the confirm dialog it opens. + const rebuild = spectator + .query(byTestId('dotai-embeddings-rebuild')) + ?.querySelector('button'); - expect(classes).toContain('p-button-text'); - expect(classes).toContain('p-button-rounded'); - expect(classes).not.toContain('p-button-outlined'); - }); - - it('should keep Rebuild DB red, since that one drops every embedding', () => { - expect( - spectator.query(byTestId('dotai-embeddings-rebuild'))?.querySelector('button') - ?.className - ).toContain('p-button-danger'); + expect(rebuild?.className).toContain('p-button-outlined'); + expect(rebuild?.className).not.toContain('p-button-danger'); }); }); }); diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.ts index a29538f5f1c7..bab619e58bfa 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.ts @@ -1,28 +1,33 @@ -import { Component, inject } from '@angular/core'; +import { Component, computed, effect, inject, untracked } from '@angular/core'; -import { ConfirmationService } from 'primeng/api'; +import { ConfirmationService, MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService } from 'primeng/dynamicdialog'; -import { MessageModule } from 'primeng/message'; import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; +import { ToastModule } from 'primeng/toast'; import { ToolbarModule } from 'primeng/toolbar'; -import { take } from 'rxjs/operators'; - import { DotMessageService } from '@dotcms/data-access'; import { DOT_AI_INDEX_STATUS, DotAiIndex } from '@dotcms/dotcms-models'; -import { DotMessagePipe, DotSearchInputComponent } from '@dotcms/ui'; - import { - DotAiIndexCreateComponent, - DotAiIndexCreateResult -} from './dot-ai-index-create/dot-ai-index-create.component'; + DotEmptyContainerComponent, + DotMessagePipe, + DotSearchInputComponent, + PrincipalConfiguration +} from '@dotcms/ui'; -import { DotAiEmptyStateComponent } from '../../components/dot-ai-empty-state/dot-ai-empty-state.component'; -import { DotAiIndexBuildNotice } from '../../models/dot-ai-portlet.models'; +import { DotAiIndexCreateComponent } from './dot-ai-index-create/dot-ai-index-create.component'; +import { DotAiIndexRemoveContentComponent } from './dot-ai-index-remove-content/dot-ai-index-remove-content.component'; + +import { + DOT_AI_INDEX_OPERATION, + DotAiIndexNotice, + DotAiIndexOperation +} from '../../models/dot-ai-portlet.models'; import { DotAiStore } from '../../store/dot-ai.store'; +import { toEmptyStateConfig } from '../../utils/dot-ai-empty-state.utils'; /** * Button treatment shared by both confirmations: a primary accept and an outlined cancel. @@ -35,6 +40,47 @@ import { DotAiStore } from '../../store/dot-ai.store'; * itself and the theme defines no `p-button-primary` to ask for — `p-button-secondary` exists * there, `p-button-primary` does not. Setting one would resolve to nothing. */ +/** + * The message key per operation and outcome. + * + * Spelled out rather than assembled from the operation name at call time: interpolated keys + * are invisible to a grep, so a copy audit reports them as orphaned and a cleanup deletes + * them, and a combination nobody defined renders its own key to the user instead of failing. + * `Record` makes TypeScript require every one. + */ +const NOTICE_MESSAGE_KEYS: Record< + DotAiIndexOperation, + Record +> = { + [DOT_AI_INDEX_OPERATION.BUILD]: { + ok: 'dotai.embeddings.build.ok', + empty: 'dotai.embeddings.build.empty', + failed: 'dotai.embeddings.build.failed' + }, + [DOT_AI_INDEX_OPERATION.REMOVE_CONTENT]: { + ok: 'dotai.embeddings.remove-content.ok', + empty: 'dotai.embeddings.remove-content.empty', + failed: 'dotai.embeddings.remove-content.failed' + }, + [DOT_AI_INDEX_OPERATION.DELETE_INDEX]: { + ok: 'dotai.embeddings.delete.ok', + empty: 'dotai.embeddings.delete.ok', + failed: 'dotai.embeddings.delete.failed' + }, + [DOT_AI_INDEX_OPERATION.REBUILD_DB]: { + ok: 'dotai.embeddings.rebuild.ok', + empty: 'dotai.embeddings.rebuild.ok', + failed: 'dotai.embeddings.rebuild.failed' + } +} as const; + +/** Toast severity per outcome, and the summary key that goes with it. */ +const NOTICE_SEVERITY: Record = { + ok: 'success', + empty: 'warn', + failed: 'error' +} as const; + const CONFIRM_BUTTONS = { rejectButtonStyleClass: 'p-button-outlined' } as const; @@ -52,17 +98,17 @@ const CONFIRM_BUTTONS = { @Component({ selector: 'dot-ai-embeddings', imports: [ - DotAiEmptyStateComponent, + DotEmptyContainerComponent, ToolbarModule, - MessageModule, TableModule, TagModule, + ToastModule, ButtonModule, ConfirmDialogModule, DotSearchInputComponent, DotMessagePipe ], - providers: [ConfirmationService, DialogService], + providers: [ConfirmationService, DialogService, MessageService], templateUrl: './dot-ai-embeddings.component.html', host: { class: 'block h-full' } }) @@ -70,61 +116,130 @@ export default class DotAiEmbeddingsComponent { protected readonly store = inject(DotAiStore); readonly #confirmationService = inject(ConfirmationService); + readonly #toast = inject(MessageService); readonly #dialogService = inject(DialogService); readonly #messageService = inject(DotMessageService); protected readonly statuses = DOT_AI_INDEX_STATUS; + /** Identity guard: each operation makes a fresh notice, so this toasts each one once. */ + #toasted: DotAiIndexNotice | null = null; + + constructor() { + // Every outcome is announced somewhere. A dialog that is still up owns anything the + // user has to act on, because those are corrections to a field it is still holding; + // everything else — successes, and failures whose dialog has gone — is a toast, which + // is what the rest of the admin does. Nothing renders a standing banner on the tab. + effect(() => { + const notice = this.store.indexNotice(); + const owner = this.store.indexNoticeOwner(); + + if (!notice || notice === this.#toasted) { + return; + } + + // A dialog renders only outcomes for the request it submitted, so ownership has to + // match the operation *and* the index. Suppressing on "a dialog is open" alone + // swallowed, say, a delete that failed while the build dialog happened to be up. + const ownedByDialog = + owner?.operation === notice.operation && owner.indexName === notice.indexName; + + if (ownedByDialog && notice.outcome !== 'ok') { + // The dialog is showing it inline. Marked as reported all the same, so closing + // the dialog does not then toast the error the user has just read and dismissed. + this.#toasted = notice; + + return; + } + + this.#toasted = notice; + untracked(() => this.#toast.add(this.#toastFor(notice))); + }); + } + + /** + * Two different empty states behind one slot: an instance with no indexes at all, and a + * filter that matched none of the ones there are. Telling someone to create their first + * index when they have six and mistyped the filter is the wrong instruction. + */ + protected readonly $emptyConfig = computed(() => + this.store.indexFilter().trim() + ? toEmptyStateConfig(this.#messageService, { + title: 'dotai.embeddings.no-matches', + icon: 'filter_alt_off' + }) + : toEmptyStateConfig(this.#messageService, { + title: 'dotai.embeddings.empty.title', + subtitle: 'dotai.embeddings.empty.sub', + icon: 'database' + }) + ); + + protected readonly forbiddenConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.index.admin-required', + subtitle: 'dotai.index.admin-required.sub', + icon: 'lock' + }); + /** Fixed layout plus full height keeps the empty state from collapsing the table. */ protected readonly tablePt = { table: { class: 'table-fixed' }, wrapper: { class: 'h-full' } }; - /** p-message severities for the three build outcomes. */ - protected noticeSeverity(kind: DotAiIndexBuildNotice['kind']): 'success' | 'warn' | 'error' { - if (kind === 'built') { - return 'success'; - } + /** + * Opens the build dialog and leaves the submit to it. + * + * `onClose` carries no form value any more — the dialog submits to the store itself, so a + * rejected Lucene query is corrected in the form that produced it rather than reported + * onto this tab after the modal has closed over the query. It is still subscribed, for + * the one thing this tab needs to know: whether the dialog is still there to do the + * reporting. + */ + protected openCreateDialog(): void { + this.#dialogService.open(DotAiIndexCreateComponent, { + header: this.#messageService.get('dotai.index.create.header'), + width: '700px', + closable: true, + // Escape is handled by the dialog itself: PrimeNG binds its own listener once + // at open time and never rereads the flag, so it cannot be told to stand down + // while a request is in flight. See `watchIndexOperation`. + closeOnEscape: false, + draggable: false + }); + } - return kind === 'empty' ? 'warn' : 'error'; + protected openRemoveContentDialog(): void { + this.#dialogService.open(DotAiIndexRemoveContentComponent, { + header: this.#messageService.get('dotai.embeddings.remove-content.header'), + width: '700px', + closable: true, + closeOnEscape: false, + draggable: false + }); } - protected openCreateDialog(): void { - this.#dialogService - .open(DotAiIndexCreateComponent, { - header: this.#messageService.get('dotai.index.create.header'), - width: '700px', - closable: true, - closeOnEscape: true, - draggable: false, - data: { indexes: this.store.indexes().map((index) => index.name) } - }) - // `DialogService.onClose` is `Observable`, so the annotation here is what - // makes the "mode must not travel any further" invariant below a compiler rule - // rather than a convention. - .onClose.pipe(take(1)) - .subscribe((result: DotAiIndexCreateResult | undefined) => { - if (!result) { - return; - } - - // `mode` picks the branch and must not travel any further: it is a dialog - // concept, and EmbeddingsForm rejects the whole request with - // "Unrecognized field 'mode'" rather than ignoring it. - const { mode, ...form } = result; - - if (mode === 'delete') { - this.store.deleteFromIndex({ - indexName: form.indexName, - query: form.query - }); - - return; - } - - this.store.buildIndex(form); - }); + /** The one place an outcome becomes words. Severity follows the outcome, not the action. */ + #toastFor(notice: DotAiIndexNotice): { + severity: string; + summary: string; + detail: string; + life: number; + } { + const severity = NOTICE_SEVERITY[notice.outcome]; + + return { + severity, + summary: this.#messageService.get(`dotai.embeddings.toast.${severity}`), + // Every notice key takes the same two, in the same order: the index, then the + // count or the server's reason. Anything else and one mapper cannot serve them all. + detail: this.#messageService.get( + NOTICE_MESSAGE_KEYS[notice.operation][notice.outcome], + notice.indexName, + notice.detail ?? '' + ), + life: severity === 'success' ? 4000 : 8000 + }; } protected confirmDeleteIndex(index: DotAiIndex): void { diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.html b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.html index dd3ebd96f05b..9c497708c80c 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.html +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.html @@ -1,22 +1,4 @@
- -
- - - {{ item.label | dm }} - -
-
- @if ($mode() === 'add') { -
- - - {{ 'dotai.index.create.fields.hint' | dm }} -
+
+ + + {{ 'dotai.index.create.fields.hint' | dm }} +
+ +
+ + + {{ 'dotai.index.create.template.hint' | dm }} +
-
- - -
+ + @if ($notice(); as notice) { + + @if (notice.outcome === 'empty') { + {{ 'dotai.embeddings.build.empty' | dm: [notice.indexName, notice.detail ?? ''] }} + } @else { + {{ 'dotai.embeddings.build.failed' | dm: [notice.indexName, notice.detail ?? ''] }} + } + }
@@ -89,13 +86,14 @@ severity="secondary" [text]="true" [label]="'dotai.index.create.cancel' | dm" + [disabled]="$submitting()" (onClick)="cancel()" data-testid="dotai-index-create-cancel" />
diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.spec.ts index 91df1c0b18fe..5011e22b697b 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.spec.ts @@ -5,28 +5,65 @@ import { Spectator } from '@openng/spectator/vitest'; +import { signal } from '@angular/core'; + import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; +import { DotAiIndex } from '@dotcms/dotcms-models'; import { DotAiIndexCreateComponent } from './dot-ai-index-create.component'; +import { DOT_AI_INDEX_OPERATION, DotAiIndexNotice } from '../../../models/dot-ai-portlet.models'; +import { DotAiStore } from '../../../store/dot-ai.store'; + +const dialogConfig = { closable: true, closeOnEscape: true }; + describe('DotAiIndexCreateComponent', () => { let spectator: Spectator; let dialogRef: DynamicDialogRef; + let store: { + indexes: ReturnType>; + indexNotice: ReturnType>; + buildIndex: ReturnType; + claimIndexOutcome: ReturnType; + releaseIndexOutcome: ReturnType; + }; const createComponent = createComponentFactory({ component: DotAiIndexCreateComponent, providers: [ mockProvider(DynamicDialogRef), mockProvider(DotMessageService), - { provide: DynamicDialogConfig, useValue: { data: { indexes: ['default'] } } } + { provide: DynamicDialogConfig, useValue: dialogConfig } ], shallow: true }); beforeEach(() => { - spectator = createComponent(); + dialogConfig.closable = true; + dialogConfig.closeOnEscape = true; + + store = { + indexes: signal([ + { + name: 'default', + fragments: 1, + contents: 1, + tokenTotal: 1, + tokensPerChunk: 1, + contentTypes: [] + } + ]), + indexNotice: signal(null), + buildIndex: vi.fn(), + claimIndexOutcome: vi.fn(), + releaseIndexOutcome: vi.fn() + }; + + spectator = createComponent({ + providers: [{ provide: DotAiStore, useValue: store }] + }); dialogRef = spectator.inject(DynamicDialogRef); }); @@ -34,11 +71,21 @@ describe('DotAiIndexCreateComponent', () => { spectator.typeInElement(value, spectator.query(byTestId(testId)) as HTMLElement); /** PrimeNG puts its click handler on the inner
} @else { -
-
- -

{{ 'dotai.image.empty.sub' | dm }}

-
+ +
+
}
diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.ts index b2931526a9fd..ef472fb1d730 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.ts @@ -9,10 +9,12 @@ import { SkeletonModule } from 'primeng/skeleton'; import { TooltipModule } from 'primeng/tooltip'; import { DotAiPromptInputComponent } from '@dotcms/ai-ui'; +import { DotMessageService } from '@dotcms/data-access'; import { DotAIImageOrientation } from '@dotcms/dotcms-models'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotEmptyContainerComponent, DotMessagePipe } from '@dotcms/ui'; import { DotAiStore } from '../../store/dot-ai.store'; +import { toEmptyStateConfig } from '../../utils/dot-ai-empty-state.utils'; /** * Image tab: describe an image, generate it, then decide what to do with it. @@ -24,6 +26,7 @@ import { DotAiStore } from '../../store/dot-ai.store'; @Component({ selector: 'dot-ai-image', imports: [ + DotEmptyContainerComponent, FormsModule, ButtonModule, SelectModule, @@ -40,6 +43,14 @@ import { DotAiStore } from '../../store/dot-ai.store'; export default class DotAiImageComponent { protected readonly store = inject(DotAiStore); + readonly #messageService = inject(DotMessageService); + + protected readonly emptyConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.image.empty.title', + subtitle: 'dotai.image.empty.sub', + icon: 'image' + }); + protected readonly $prompt = signal(''); /** diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.html b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.html index 969091574dab..cc0845df4c11 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.html +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.html @@ -44,30 +44,36 @@ +
-
- @if (store.isSearching()) { + @if (store.isSearching()) { +
@for (row of [1, 2, 3]; track row) { }
- } @else if (store.searchMissingIndex(); as missing) { - - } @else if (!store.hasSearched()) { - - } @else if (!store.searchResults().length) { - - } @else { +
+ } @else if (store.searchMissingIndex()) { + + } @else if (!store.hasSearched()) { + + } @else if (!store.searchResults().length) { + + } @else { +
    @for (result of store.searchResults(); track result.inode) {
  • @@ -129,8 +135,8 @@
  • }
- } -
+
+ }
diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.spec.ts index bbe1f082e042..bb76581d61bf 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.spec.ts @@ -263,7 +263,12 @@ describe('DotAiSearchComponent', () => { it('should share one set of edges between the field and the results', () => { // Chat and Image both centre their content in a `container mx-auto` column; // without it Search spanned the full pane and its field did not line up with - // the results underneath. + // the results underneath. Asserted with results on screen, since the empty states + // deliberately sit outside that column — see the next test. + storeMock.hasSearched.mockReturnValue(true); + storeMock.searchResults.mockReturnValue([result()]); + spectator = createComponent(); + const field = spectator.query(byTestId('dotai-search-input'))?.closest('.container'); const results = spectator .query(byTestId('dotai-search-scroll')) @@ -273,6 +278,20 @@ describe('DotAiSearchComponent', () => { expect(results?.className).toContain('mx-auto'); }); + it('should keep the empty states out of that column so they can centre', () => { + // dot-empty-container centres itself with `h-full`, and a percentage height needs + // a parent that has one. `container mx-auto` is auto-height, so nested there the + // state pinned itself to the top of an otherwise blank pane. + spectator = createComponent(); + + const scroll = spectator.query(byTestId('dotai-search-scroll')); + const empty = spectator.query(byTestId('dotai-search-empty-first-run')); + + expect(empty).toBeTruthy(); + expect(empty?.closest('.container')).toBeNull(); + expect(empty?.parentElement).toBe(scroll); + }); + it('should keep the meta line in the same column as the field', () => { storeMock.searchResponse.mockReturnValue(response({ count: 1 })); spectator = createComponent(); diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.ts index fd682c8c50cc..d8283bf8f202 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.ts @@ -7,12 +7,18 @@ import { InputTextModule } from 'primeng/inputtext'; import { ProgressBarModule } from 'primeng/progressbar'; import { SkeletonModule } from 'primeng/skeleton'; -import { DotMessagePipe, DotRelativeDatePipe } from '@dotcms/ui'; +import { DotMessageService } from '@dotcms/data-access'; +import { + DotEmptyContainerComponent, + DotMessagePipe, + DotRelativeDatePipe, + PrincipalConfiguration +} from '@dotcms/ui'; -import { DotAiEmptyStateComponent } from '../../components/dot-ai-empty-state/dot-ai-empty-state.component'; import { DotAiWorkspaceComponent } from '../../components/dot-ai-workspace/dot-ai-workspace.component'; import { DotAiStore } from '../../store/dot-ai.store'; import { toClosenessPercent } from '../../utils/dot-ai-distance.utils'; +import { toEmptyStateConfig } from '../../utils/dot-ai-empty-state.utils'; /** * Search tab: a hero query field over a ranked result list, with the shared retrieval-settings @@ -25,7 +31,7 @@ import { toClosenessPercent } from '../../utils/dot-ai-distance.utils'; @Component({ selector: 'dot-ai-search', imports: [ - DotAiEmptyStateComponent, + DotEmptyContainerComponent, ButtonModule, InputGroupModule, InputGroupAddonModule, @@ -42,6 +48,29 @@ import { toClosenessPercent } from '../../utils/dot-ai-distance.utils'; export default class DotAiSearchComponent { protected readonly store = inject(DotAiStore); + readonly #messageService = inject(DotMessageService); + + protected readonly firstRunConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.search.first-run.title', + subtitle: 'dotai.search.first-run.sub', + icon: 'search' + }); + + protected readonly noResultsConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.search.no-results.title', + subtitle: 'dotai.search.no-results.sub', + icon: 'search_off' + }); + + /** The only one that has to be computed — the subtitle is the index name from the server. */ + protected readonly $missingIndexConfig = computed(() => ({ + ...toEmptyStateConfig(this.#messageService, { + title: 'dotai.search.index-missing', + icon: 'database_off' + }), + subtitle: this.store.searchMissingIndex() ?? '' + })); + /** * Typed rather than `$any($event.target).value`, and a handler rather than `ngModel`: * `disabled` is an *input on NgModel*, which applies it a microtask after the binding, so diff --git a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-config.utils.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-config.utils.spec.ts index be8da6018680..85d3432c6ac8 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-config.utils.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-config.utils.spec.ts @@ -1,14 +1,10 @@ import { DotAiResolvedConfig } from '@dotcms/dotcms-models'; -import { - DOT_AI_CONFIG_SOURCE, - maskCredentials, - SECRET_MASK, - toConfigRows -} from './dot-ai-config.utils'; +import { DOT_AI_CONFIG_SOURCE, SECRET_MASK, toConfigRows } from './dot-ai-config.utils'; const config = (overrides: Partial = {}): DotAiResolvedConfig => ({ - configHost: 'demo.dotcms.com (falls back to system host)', + configHost: 'demo.dotcms.com', + configHostInherited: false, settings: { temperature: '0.7', imageSize: '1024x1024' }, providerConfig: { chat: { provider: 'openrouter', apiKey: '*****', temperature: '0.7' } }, chatModels: [], @@ -63,34 +59,4 @@ describe('toConfigRows', () => { expect(keys).toEqual([...keys].sort((a, b) => a.localeCompare(b))); }); - - describe('maskCredentials (FR-042)', () => { - it('should replace the server mask with the client one', () => { - const masked = maskCredentials({ chat: { apiKey: '*****', model: 'gpt-4' } }); - - expect(masked).toEqual({ chat: { apiKey: SECRET_MASK, model: 'gpt-4' } }); - }); - - it('should reach a credential at any depth, unlike the table rows', () => { - const masked = maskCredentials({ - image: { aws: { secretAccessKey: '*****', region: 'us-east-1' } } - }); - - expect(masked).toEqual({ - image: { aws: { secretAccessKey: SECRET_MASK, region: 'us-east-1' } } - }); - }); - - it('should walk arrays without flattening them', () => { - expect(maskCredentials([{ apiKey: 'x' }, 'plain'])).toEqual([ - { apiKey: SECRET_MASK }, - 'plain' - ]); - }); - - it('should pass a primitive through untouched', () => { - expect(maskCredentials(null)).toBeNull(); - expect(maskCredentials('gpt-4')).toBe('gpt-4'); - }); - }); }); diff --git a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-config.utils.ts b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-config.utils.ts index d59d7d9b1a1b..33eef60fb052 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-config.utils.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-config.utils.ts @@ -20,35 +20,6 @@ export interface DotAiConfigValueRow { source: DotAiConfigSource; } -/** - * A copy of the provider config with every credential field replaced by the client's own mask. - * - * The server has already rewritten these to `*****`, so nothing secret reaches the browser — - * but FR-042 rules out rendering the server's mask as much as the value itself, since `*****` - * reads like a real five-character setting. The raw JSON view is the only place the config is - * shown unflattened, so it is the only place that needs this; the table gets the same - * treatment structurally, through `toSecretRows`. - * - * Recurses, unlike `toSecretRows`, which only walks the one section level the table has rows - * for. The JSON view shows whatever depth the provider sent. - */ -export function maskCredentials(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(maskCredentials); - } - - if (!value || typeof value !== 'object') { - return value; - } - - return Object.fromEntries( - Object.entries(value as Record).map(([key, nested]) => [ - key, - AI_CREDENTIAL_FIELDS.includes(key) ? SECRET_MASK : maskCredentials(nested) - ]) - ); -} - /** * Flattens the resolved config into table rows, deriving each value's origin the same way the * backend resolves it: an explicitly-set value wins, otherwise the built-in default. diff --git a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-empty-state.utils.ts b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-empty-state.utils.ts new file mode 100644 index 000000000000..b01316850e6d --- /dev/null +++ b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-empty-state.utils.ts @@ -0,0 +1,24 @@ +import { DotMessageService } from '@dotcms/data-access'; +import { PrincipalConfiguration } from '@dotcms/ui'; + +/** + * A `dot-empty-container` configuration, with the portlet's conventions filled in. + * + * Ten of these are built across the five tabs, every one of them repeating the same icon set + * and the same `messageService.get` per string. Sibling portlets inline the literal because + * they have one or two; at ten, the convention needs somewhere to live so that changing it + * does not mean editing five components. + * + * Takes message keys and resolves them here, so the keys stay visible at the call site. + */ +export function toEmptyStateConfig( + messageService: DotMessageService, + keys: { title: string; subtitle?: string; icon: string } +): PrincipalConfiguration { + return { + title: messageService.get(keys.title), + ...(keys.subtitle ? { subtitle: messageService.get(keys.subtitle) } : {}), + icon: keys.icon, + iconStyle: 'material-symbols-rounded' + }; +} diff --git a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index-dialog.utils.ts b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index-dialog.utils.ts new file mode 100644 index 000000000000..356d75c95a12 --- /dev/null +++ b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index-dialog.utils.ts @@ -0,0 +1,143 @@ +import { DOCUMENT } from '@angular/common'; +import { + computed, + DestroyRef, + effect, + inject, + Signal, + signal, + untracked, + WritableSignal +} from '@angular/core'; + +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; + +import { DotAiIndexNotice, DotAiIndexOperation } from '../models/dot-ai-portlet.models'; + +export interface DotAiIndexOperationDialog { + /** A request this dialog submitted is outstanding. */ + readonly $submitting: Signal; + /** The outcome of *this dialog's* request, while it is still this dialog's to show. */ + readonly $notice: Signal; + /** Call as the request goes out, with the index it is for. */ + readonly submitted: (indexName: string) => void; +} + +/** + * The half of an index dialog that watches for its own outcome. + * + * Both dialogs need identically subtle behaviour — settle on my outcome, close on my success, + * stay open on anything I can still correct — and writing it twice is what let them drift: + * one filtered by operation and the other did not, so any operation's result cleared the + * build dialog's spinner mid-build. + * + * Matching on `indexName` as well as operation is what makes "my own" mean anything. Both + * store methods are `rxMethod`s on the shell-scoped store, so they outlive the dialog that + * started them: abandon a slow build and open the dialog again, and without this the second + * dialog closes on the first one's success and reports an index the user never asked for. + * + * While a request is outstanding the dialog cannot be dismissed at all. The answer is coming + * back *into this form* — a rejected query belongs in the field that produced it — so letting + * it be closed first is what created the abandoned-mid-flight case in the first place. + * + * The header X is a live binding off `DynamicDialogConfig`, so hiding it is a matter of + * setting `closable`. Escape is not: PrimeNG binds a document listener **once**, when the + * dialog opens, and never rereads the flag — so both dialogs open with `closeOnEscape: false` + * and Escape is handled here instead. It still closes the dialog, as the portlet guide + * requires; it just declines to while the server is mid-answer. + */ +export function watchIndexOperation( + operation: DotAiIndexOperation, + deps: { + notice: Signal; + close: () => void; + config: DynamicDialogConfig; + /** Declares to the tab that this dialog will render the outcome itself. */ + claim: (operation: DotAiIndexOperation, indexName: string) => void; + release: () => void; + } +): DotAiIndexOperationDialog { + const $submitting = signal(false); + const $target: WritableSignal = signal(null); + + /** + * Shows or hides PrimeNG's header X. + * + * `closable` is a plain property the dialog host reads in a template binding, so it lands + * on that host's next change-detection pass. Locking gets one for free — it runs from a + * click handler — but the unlock runs inside an effect, already part of the pass in + * progress, so the X comes back on the user's next interaction rather than instantly. A + * `setTimeout` was tried and changes nothing, so it is not carried here for the illusion. + * + * The gap is cosmetic and self-healing: it only occurs on a failed or empty outcome, which + * is exactly when the user is about to type in the field again, and Cancel and Escape are + * both live throughout. + */ + const dismissable = (allowed: boolean) => { + deps.config.closable = allowed; + }; + + const documentRef = inject(DOCUMENT); + const unbindEscape = () => documentRef.removeEventListener('keydown', onEscape); + + function onEscape(event: KeyboardEvent) { + // `defaultPrevented` leaves nested overlays — a select, a picker — to close themselves + // first. PrimeNG's own handler is gone (`closeOnEscape: false`), because it binds once + // at open and would not stand down mid-request. + if (event.key !== 'Escape' || event.defaultPrevented || $submitting()) { + return; + } + + // Unbound here rather than at teardown: DynamicDialog is destroyed only after its + // leave animation, and a live handler on a dialog already closing would close it twice. + unbindEscape(); + deps.close(); + } + + documentRef.addEventListener('keydown', onEscape); + inject(DestroyRef).onDestroy(() => { + unbindEscape(); + deps.release(); + }); + + const $own = computed(() => { + const notice = deps.notice(); + + return notice?.operation === operation && notice.indexName === $target() ? notice : null; + }); + + effect(() => { + const notice = $own(); + + if (!notice) { + return; + } + + untracked(() => { + $submitting.set(false); + dismissable(true); + + // Only a real success dismisses the dialog. Everything else — a query that matched + // nothing, a query the server rejected — is a correction to a field still on + // screen, which is the whole reason the dialog owns its submit. + if (notice.outcome === 'ok') { + deps.close(); + } + }); + }); + + return { + $submitting, + $notice: computed(() => { + const notice = $own(); + + return notice && notice.outcome !== 'ok' ? notice : null; + }), + submitted: (indexName: string) => { + $target.set(indexName); + $submitting.set(true); + dismissable(false); + deps.claim(operation, indexName); + } + }; +} diff --git a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.spec.ts index 73c84a0affea..60d87a8dc48a 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.spec.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.spec.ts @@ -1,10 +1,11 @@ -import { DOT_AI_INDEX_STATUS, DotAiIndex } from '@dotcms/dotcms-models'; +import { DotAiIndex } from '@dotcms/dotcms-models'; import { CACHE_INDEX_NAME, - deriveIndexStatuses, + stillBuildingSeeds, toIndexOptions, - toRetrievalIndexes + toRetrievalIndexes, + withPendingIndexes } from './dot-ai-index.utils'; const index = (overrides: Partial = {}): DotAiIndex => ({ @@ -46,49 +47,73 @@ describe('dot-ai-index.utils', () => { }); }); - describe('deriveIndexStatuses', () => { - it('should report READY when there is no previous snapshot', () => { - const result = deriveIndexStatuses([index({ name: 'a' })], {}, new Set()); + describe('withPendingIndexes', () => { + it('should stand in for a seeded build the server has not listed yet', () => { + const result = withPendingIndexes([index({ name: 'a' })], new Set(['blogs'])); - expect(result['a']).toBe(DOT_AI_INDEX_STATUS.READY); + expect(result.map((i) => i.name)).toEqual(['a', 'blogs']); + expect(result.at(-1)?.fragments).toBe(0); }); - it('should report BUILDING when a build was just seeded for that index', () => { - const result = deriveIndexStatuses([index({ name: 'a' })], {}, new Set(['a'])); + it('should return the same array when every seed is already listed', () => { + // Identity matters: markIndexBuilding patches `indexes` with this, and a fresh + // array there would churn every reader of the list on each poll. + const indexes = [index({ name: 'blogs' })]; - expect(result['a']).toBe(DOT_AI_INDEX_STATUS.BUILDING); + expect(withPendingIndexes(indexes, new Set(['blogs']))).toBe(indexes); + }); + }); + + describe('stillBuildingSeeds', () => { + it('should keep a seed whose index the server has not listed yet', () => { + expect(stillBuildingSeeds([], {}, new Set(['a']))).toEqual(new Set(['a'])); + }); + + it('should keep a seed with no snapshot to compare against', () => { + expect(stillBuildingSeeds([index({ name: 'a' })], {}, new Set(['a']))).toEqual( + new Set(['a']) + ); }); - it('should keep BUILDING while the fragment count is still moving', () => { - const result = deriveIndexStatuses( + it('should keep a seed while the fragment count is still moving', () => { + const result = stillBuildingSeeds( [index({ name: 'a', fragments: 20 })], { a: 10 }, new Set(['a']) ); - expect(result['a']).toBe(DOT_AI_INDEX_STATUS.BUILDING); + expect(result).toEqual(new Set(['a'])); }); - it('should settle to READY once the fragment count stops changing', () => { - const result = deriveIndexStatuses( + it('should drop a seed once the fragment count stops changing', () => { + const result = stillBuildingSeeds( [index({ name: 'a', fragments: 10 })], { a: 10 }, new Set(['a']) ); - expect(result['a']).toBe(DOT_AI_INDEX_STATUS.READY); + expect(result.size).toBe(0); }); - it('should derive status per index, not portlet-wide', () => { + it('should settle each index on its own, not portlet-wide', () => { // The legacy portlet flipped every row at once off a single global delta. - const result = deriveIndexStatuses( + const result = stillBuildingSeeds( [index({ name: 'a', fragments: 20 }), index({ name: 'b', fragments: 5 })], { a: 10, b: 5 }, new Set(['a', 'b']) ); - expect(result['a']).toBe(DOT_AI_INDEX_STATUS.BUILDING); - expect(result['b']).toBe(DOT_AI_INDEX_STATUS.READY); + expect(result).toEqual(new Set(['a'])); + }); + + it('should ignore an index nobody seeded a build for', () => { + const result = stillBuildingSeeds( + [index({ name: 'a', fragments: 20 })], + { a: 10 }, + new Set() + ); + + expect(result.size).toBe(0); }); }); }); diff --git a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.ts b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.ts index 3d0f0d96d169..04c39690f9fd 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.ts @@ -1,4 +1,4 @@ -import { DOT_AI_INDEX_STATUS, DotAiIndex, DotAiIndexStatus } from '@dotcms/dotcms-models'; +import { DotAiIndex } from '@dotcms/dotcms-models'; /** * Internal cache index. It is real and shows in the Embeddings table, but it is not a @@ -20,27 +20,58 @@ export function toIndexOptions(indexes: DotAiIndex[]): { label: string; value: s } /** - * Build status per index, derived rather than read: `dot_embeddings` has no status column. + * A build that has been requested but has not reached `indexCount` yet. * - * An index counts as building while its fragment count is still moving. `buildSeeds` carries - * the indexes a build was just requested for, which is what lets the very first poll report - * BUILDING instead of guessing from a delta that has not appeared yet. + * Embedding is asynchronous — `EmbeddingsRunner` writes one row per contentlet as it finishes — + * so for the first second or two after a build the index genuinely exists but has nothing in + * `dot_embeddings`, and `indexCount` does not return it. Standing in for it with a zeroed row + * is what puts it in the table immediately, rather than leaving the user to reload the page. + */ +function toPendingIndex(name: string): DotAiIndex { + return { name, fragments: 0, contents: 0, tokenTotal: 0, tokensPerChunk: 0, contentTypes: [] }; +} + +/** + * The server's list plus a placeholder row for every seeded build it has not caught up with. + * + * Ordered with the pending ones last so an in-flight build does not reshuffle the table. + */ +export function withPendingIndexes(indexes: DotAiIndex[], buildSeeds: Set): DotAiIndex[] { + const listed = new Set(indexes.map((index) => index.name)); + const pending = [...buildSeeds].filter((name) => !listed.has(name)); + + return pending.length ? [...indexes, ...pending.map(toPendingIndex)] : indexes; +} + +/** + * The seeded builds that are still running. + * + * `dot_embeddings` has no status column, so this is derived: a build is finished when its + * index's fragment count stops moving. The seeds are what let the very first poll report a + * build instead of guessing from a delta that has not appeared yet. + * + * A seeded index the server has not listed yet has no `previousFragments` entry — the snapshot + * is taken from the server's own response, never from the placeholder rows — so it keeps + * building rather than settling off a fragment count of zero that never moves. * * Deliberately per index. The legacy portlet derived one portlet-wide flag, so starting a * build on one index made every row claim to be building. */ -export function deriveIndexStatuses( +export function stillBuildingSeeds( indexes: DotAiIndex[], previousFragments: Record, buildSeeds: Set -): Record { - return indexes.reduce>((statuses, index) => { - const previous = previousFragments[index.name]; - const moved = previous !== undefined && previous !== index.fragments; - const building = buildSeeds.has(index.name) && (previous === undefined || moved); +): Set { + const fragments = new Map(indexes.map((index) => [index.name, index.fragments])); - statuses[index.name] = building ? DOT_AI_INDEX_STATUS.BUILDING : DOT_AI_INDEX_STATUS.READY; + return new Set( + [...buildSeeds].filter((name) => { + const current = fragments.get(name); + const previous = previousFragments[name]; - return statuses; - }, {}); + // Unlisted, or never snapshotted: there is nothing to compare, so it cannot have + // settled. Otherwise it is building exactly while the count is moving. + return current === undefined || previous === undefined || previous !== current; + }) + ); } diff --git a/dotCMS/src/main/java/com/dotcms/ai/AiKeys.java b/dotCMS/src/main/java/com/dotcms/ai/AiKeys.java index 979553504400..2037f45e744a 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/AiKeys.java +++ b/dotCMS/src/main/java/com/dotcms/ai/AiKeys.java @@ -40,6 +40,7 @@ public class AiKeys { public static final String SITE = "site"; public static final String CREATED = "created"; public static final String CONFIG_HOST = "configHost"; + public static final String CONFIG_HOST_INHERITED = "configHostInherited"; public static final String MODDATE = "moddate"; public static final String DISTANCE = "distance"; public static final String EXTRACTED_TEXT = "extractedText"; diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index f36da6942d0e..a4e286e52850 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -193,9 +193,12 @@ public final Response getConfig(@Context final HttpServletRequest request, final AppConfig appConfig = ConfigService.INSTANCE.config(host); final Map map = new HashMap<>(); - map.put(AiKeys.CONFIG_HOST, host.getHostname() + " (falls back to system host)"); - final String providerConfig = appConfig.getProviderConfig(); + final String requestedHost = host.getHostname(); + + map.put(AiKeys.CONFIG_HOST, requestedHost); + map.put(AiKeys.CONFIG_HOST_INHERITED, isInheritedConfig(requestedHost, appConfig)); + if (StringUtils.isNotBlank(providerConfig)) { map.put(AppKeys.PROVIDER_CONFIG.key, redactCredentials(providerConfig)); } @@ -209,6 +212,27 @@ public final Response getConfig(@Context final HttpServletRequest request, return Response.ok(map).build(); } + /** + * Whether the configuration being reported came from the System Host rather than from the + * site it was asked for. + * + * {@link ConfigService#config(Host)} falls back to the System Host's secrets when a site + * has none of its own, keeping only the hostname it ended up using — so the two hostnames + * differing is what "inherited" means here. + * + * Gated on there being a configuration at all, because that same fallback reports the + * System Host whether or not the System Host had any secrets either. Without the gate, an + * instance with nothing configured anywhere claims to have inherited settings it never + * found. + * + * Reported as its own field rather than concatenated into the hostname so the client can + * label and translate it. + */ + static boolean isInheritedConfig(final String requestedHost, final AppConfig appConfig) { + return StringUtils.isNotBlank(appConfig.getProviderConfig()) + && !requestedHost.equalsIgnoreCase(appConfig.getHost()); + } + @PUT @JSONP @Path("/config") diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index b816dfa8ee1c..10e8468749f8 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -8708,9 +8708,27 @@ dotai.chat.stopped=Answer stopped. dotai.chat.thinking=Reading your content dotai.chat.error.empty=No answer came back. Try asking again, or loosen the closeness threshold so the index returns something to answer from. dotai.embeddings.filter.placeholder=Filter indexes -dotai.embeddings.build.ok=Indexed {0} items into "{1}". +dotai.embeddings.build.ok=Indexed {1} items into "{0}". dotai.embeddings.build.empty=Nothing matched that query, so "{0}" was created with no content. Check the query and build again. dotai.embeddings.build.failed=Could not build "{0}". {1} Check the query syntax and try again. +dotai.embeddings.delete.ok=Deleted "{0}" and the {1} embeddings in it. +dotai.embeddings.delete.failed=Could not delete "{0}". {1} +dotai.embeddings.rebuild.ok=Embeddings store rebuilt. Every index has to be built again. +dotai.embeddings.rebuild.failed=Could not rebuild the embeddings store. {1} +dotai.embeddings.toast.success=Done +dotai.embeddings.toast.warn=Nothing changed +dotai.embeddings.toast.error=Failed +dotai.embeddings.remove-content.action=Remove content +dotai.embeddings.remove-content.header=Remove content from an index +dotai.embeddings.remove-content.index=Index +dotai.embeddings.remove-content.index.placeholder=Select an index +dotai.embeddings.remove-content.explainer=This removes the embeddings for the content your query matches from the index you pick. The content itself is not deleted. The query runs against your content as it is now, not against what is in the index, so content that has changed and no longer matches is left behind, and archived or deleted content cannot be reached this way. +dotai.embeddings.remove-content.query=Content query +dotai.embeddings.remove-content.query.hint=Lucene query selecting the content to remove. Unlike building, this is not limited to live content. +dotai.embeddings.remove-content.submit=Remove content +dotai.embeddings.remove-content.ok=Removed {1} items from "{0}". +dotai.embeddings.remove-content.empty=Nothing in "{0}" matched that query, so nothing was removed. Check the query and try again. +dotai.embeddings.remove-content.failed=Could not remove content from "{0}". {1} Check the query syntax and try again. dotai.embeddings.rebuild=Rebuild DB dotai.embeddings.rebuild.header=Rebuild the embeddings store? dotai.embeddings.rebuild.message=This discards every embedding on this instance. Indexes must be rebuilt afterwards. This cannot be undone. @@ -8725,14 +8743,12 @@ dotai.embeddings.column.status=Status dotai.embeddings.status.ready=Ready dotai.embeddings.status.building=Building dotai.embeddings.no-types=No content types +dotai.embeddings.no-matches=No indexes match this filter. dotai.embeddings.empty.title=No indexes yet dotai.embeddings.empty.sub=Create an index from a content query to start searching and chatting over your content. dotai.index.admin-required.sub=Ask an administrator to grant the role, or to build the indexes for you. dotai.index.create.action=New Index dotai.index.create.header=Build an index -dotai.index.create.mode=Mode -dotai.index.create.mode.add=Add to index -dotai.index.create.mode.delete=Delete from index dotai.index.create.name=Index name dotai.index.create.name.placeholder=default dotai.index.create.name.invalid=Use letters, numbers, hyphens or underscores only — no spaces. @@ -8742,11 +8758,12 @@ dotai.index.create.query.placeholder=+contentType:Blog dotai.index.create.query.hint=Lucene query selecting the content to work on. dotai.index.create.fields=Fields dotai.index.create.fields.placeholder=title,body -dotai.index.create.fields.hint=Comma separated. Leave empty to embed the whole contentlet. +dotai.index.create.fields.hint=Comma separated field variables. Ignored when a Velocity template is set. Leave both empty and dotCMS picks the fields itself. dotai.index.create.template=Velocity template +dotai.index.create.template.placeholder=$contentlet.title $contentlet.body.toHtml() +dotai.index.create.template.hint=Renders the exact text to embed, once per matching content. Use $contentlet.fieldVariable for field values, or $contentletToString for what dotCMS would pick on its own. HTML is stripped to plain text. Takes precedence over Fields. dotai.index.create.cancel=Cancel dotai.index.create.submit.add=Build index -dotai.index.create.submit.delete=Delete from index dotai.image.placeholder=Describe the image to generate... dotai.image.input.aria=Describe the image to generate dotai.image.size.aria=Image size @@ -8755,14 +8772,17 @@ dotai.image.save=Save to Assets dotai.image.download=Download dotai.image.actions.aria=Generated image actions dotai.image.published=Saved to your assets. +dotai.image.empty.title=Generate an image dotai.image.empty.sub=Describe what you want. Generating does not save anything until you choose to. dotai.config.filter.placeholder=Filter settings -dotai.config.view-provider=View provider config -dotai.config.provider.header=Provider configuration +dotai.config.host=Showing settings for {0} +dotai.config.host.inherited=Showing settings for {0}, inherited from System Host dotai.config.column.key=Key dotai.config.column.value=Value dotai.config.column.source=Source dotai.config.empty=No settings match this filter. +dotai.config.unavailable.title=Settings could not be loaded +dotai.config.unavailable.sub=The dotAI configuration request failed. Reload the page to try again. dotai.config.redaction-failed.title=Provider configuration could not be shown dotai.config.redaction-failed.sub=dotCMS could not safely redact the credentials, so the configuration is withheld. Check the server logs. dotai.settings.site=Site diff --git a/dotCMS/src/test/java/com/dotcms/ai/rest/CompletionsResourceTest.java b/dotCMS/src/test/java/com/dotcms/ai/rest/CompletionsResourceTest.java new file mode 100644 index 000000000000..1f8690f6de4a --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/ai/rest/CompletionsResourceTest.java @@ -0,0 +1,76 @@ +package com.dotcms.ai.rest; + +import com.dotcms.ai.app.AppConfig; +import com.dotcms.ai.app.AppKeys; +import com.dotcms.security.apps.Secret; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link CompletionsResource#isInheritedConfig(String, AppConfig)}. + * + * The rule is small but it decides a label the user reads on the Config Values screen, and the + * "nothing configured anywhere" case is the one it exists to get right. + */ +public class CompletionsResourceTest { + + private static final String SITE = "demo.dotcms.com"; + private static final String SYSTEM_HOST = "System Host"; + private static final String PROVIDER_CONFIG = "{\"chat\":{\"provider\":\"openai\"}}"; + + /** + * The site has its own configuration, so nothing was inherited. + */ + @Test + public void test_isInheritedConfig_ownConfig() { + assertFalse(CompletionsResource.isInheritedConfig(SITE, appConfig(SITE, PROVIDER_CONFIG))); + } + + /** + * ConfigService fell back to the System Host's secrets and found some, which is what + * "inherited" means. + */ + @Test + public void test_isInheritedConfig_fellBackToSystemHost() { + assertTrue(CompletionsResource.isInheritedConfig( + SITE, appConfig(SYSTEM_HOST, PROVIDER_CONFIG))); + } + + /** + * Nothing is configured anywhere. ConfigService still reports the System Host as the + * resolved host, so without the blank check this claimed to have inherited settings it + * never found. + */ + @Test + public void test_isInheritedConfig_nothingConfiguredAnywhere() { + assertFalse(CompletionsResource.isInheritedConfig(SITE, appConfig(SYSTEM_HOST, null))); + assertFalse(CompletionsResource.isInheritedConfig(SITE, appConfig(SYSTEM_HOST, " "))); + } + + /** + * Hostnames are compared without regard to case, as hostnames are. + */ + @Test + public void test_isInheritedConfig_hostnameCaseIsNotAChange() { + assertFalse(CompletionsResource.isInheritedConfig( + "DEMO.dotCMS.com", appConfig(SITE, PROVIDER_CONFIG))); + } + + private static AppConfig appConfig(final String host, final String providerConfigJson) { + final Map secrets = new HashMap<>(); + if (providerConfigJson != null) { + final Secret secret = mock(Secret.class); + when(secret.getString()).thenReturn(providerConfigJson); + secrets.put(AppKeys.PROVIDER_CONFIG.key, secret); + } + + return new AppConfig(host, secrets); + } +}