From 1f5f92b17faf93b8473cb5011d6aa9250da485a1 Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 14 Sep 2026 12:28:58 -0600 Subject: [PATCH 01/11] fix(dotai): design and QA follow-ups from the Angular migration (#37538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven items from the design and QA review of the new Angular dotAI portlet. Empty states now go through `dot-empty-container` from `@dotcms/ui`, as every other portlet does. The portlet's own `dot-ai-empty-state` is deleted and all eight call sites converted, each with an icon. `Velocity template` had no hint and no placeholder at all, and the `Fields` hint was wrong. The two are not independent: `BulkEmbeddingsRunner` tries the template first and only falls back to the field path when it renders nothing, so a template silently overrides `Fields`, and an empty pair means dotCMS guesses the fields rather than embedding the whole contentlet. Both hints now say so, and the template carries an example. `View provider config` and its JSON dialog are removed, along with the `maskCredentials` helper that had no other caller. `Rebuild DB` is a plain outlined button. A permanently-red control in the toolbar read as a warning about the screen; the destructive step is the confirm dialog it opens. The Config Values host line was built server-side by concatenating the hostname with " (falls back to system host)" — untranslatable, and it claimed the fallback whether or not it had happened. `/completions/config` now returns `configHost` and `configHostInherited` separately, and the label comes from a message key. Two defects: - A rejected Lucene query was reported on the tab *after* the modal had closed over the query that caused it. The dialog now owns the submit, renders the failure and the "nothing matched" warning inline with the query still in the field, and closes only on success. - A new index did not appear until a manual page reload. `markIndexBuilding` set the BUILDING flag and the refresh that followed erased it: `applyIndexes` rebuilt the status map from the server's list alone, and embedding is asynchronous so `indexCount` does not return the index yet. With no BUILDING status left the poll never started. Seeded builds now hold a placeholder row until the server catches up, with a TTL so an abandoned build stops the poll. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/dot-ai/dot-ai-config.service.spec.ts | 6 +- .../src/lib/dot-ai/dot-ai-config.service.ts | 3 + .../dotcms-models/src/lib/dot-ai.model.ts | 7 +- .../dot-ai-empty-state.component.html | 7 - .../dot-ai-empty-state.component.ts | 30 ---- .../src/lib/models/dot-ai-portlet.models.ts | 19 ++- .../features/with-ai-config.feature.spec.ts | 2 +- .../store/features/with-ai-config.feature.ts | 2 + .../features/with-ai-embeddings.feature.ts | 10 +- .../features/with-ai-indexes.feature.spec.ts | 76 ++++++++++ .../store/features/with-ai-indexes.feature.ts | 61 ++++++-- .../dot-ai-chat/dot-ai-chat.component.html | 8 +- .../tabs/dot-ai-chat/dot-ai-chat.component.ts | 19 ++- .../dot-ai-config-values.component.html | 49 ++----- .../dot-ai-config-values.component.spec.ts | 55 +++----- .../dot-ai-config-values.component.ts | 72 ++++++---- .../dot-ai-embeddings.component.html | 51 +++---- .../dot-ai-embeddings.component.spec.ts | 64 +++------ .../dot-ai-embeddings.component.ts | 91 +++++------- .../dot-ai-index-create.component.html | 21 ++- .../dot-ai-index-create.component.spec.ts | 130 ++++++++++++++++-- .../dot-ai-index-create.component.ts | 89 ++++++++---- .../dot-ai-image/dot-ai-image.component.html | 16 +-- .../dot-ai-image/dot-ai-image.component.ts | 13 +- .../dot-ai-search.component.html | 20 +-- .../dot-ai-search/dot-ai-search.component.ts | 36 ++++- .../src/lib/utils/dot-ai-config.utils.spec.ts | 40 +----- .../src/lib/utils/dot-ai-config.utils.ts | 29 ---- .../src/lib/utils/dot-ai-index.utils.ts | 28 ++++ .../src/main/java/com/dotcms/ai/AiKeys.java | 1 + .../dotcms/ai/rest/CompletionsResource.java | 9 +- .../WEB-INF/messages/Language.properties | 9 +- 32 files changed, 643 insertions(+), 430 deletions(-) delete mode 100644 core-web/libs/portlets/dot-ai/src/lib/components/dot-ai-empty-state/dot-ai-empty-state.component.html delete mode 100644 core-web/libs/portlets/dot-ai/src/lib/components/dot-ai-empty-state/dot-ai-empty-state.component.ts 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/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..d5f0d71eb776 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 @@ -112,6 +112,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; @@ -121,7 +123,16 @@ export interface DotAiPortletState { 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 `BUILD_SEED_TTL_MS`. + */ + indexBuildSeeds: Record; indexesForbidden: boolean; // shared retrieval settings @@ -149,6 +160,8 @@ export interface DotAiPortletState { // embeddings screen (client-side filters — the whole dataset arrives in one response) indexFilter: string; indexBuildNotice: DotAiIndexBuildNotice | null; + /** A build request is outstanding. Read by the create dialog, which stays open until it settles. */ + indexBuildInFlight: boolean; // image image: DotAiGeneratedImage | null; @@ -192,6 +205,7 @@ export const DOT_AI_INITIAL_STATE: DotAiPortletState = { configLoaded: false, configLoadFailed: false, configHost: '', + configHostInherited: false, settings: {}, chatModels: [], redactionFailed: false, @@ -200,7 +214,7 @@ export const DOT_AI_INITIAL_STATE: DotAiPortletState = { indexes: [], indexStatuses: {}, indexFragmentSnapshot: {}, - indexBuildSeeds: [], + indexBuildSeeds: {}, indexesForbidden: false, settingsIndexName: 'default', @@ -223,6 +237,7 @@ export const DOT_AI_INITIAL_STATE: DotAiPortletState = { indexFilter: '', indexBuildNotice: null, + indexBuildInFlight: false, 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.ts b/core-web/libs/portlets/dot-ai/src/lib/store/features/with-ai-embeddings.feature.ts index 2ce7eeba91cb..6818a29b5c02 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 @@ -94,7 +94,12 @@ export function withAiEmbeddings() { buildIndex: rxMethod( pipe( - tap(() => patchState(store, { indexBuildNotice: null })), + tap(() => + patchState(store, { + indexBuildNotice: null, + indexBuildInFlight: true + }) + ), // exhaustMap: a double submit must not build twice. exhaustMap((form) => embeddingsService.buildIndex(form).pipe( @@ -104,6 +109,7 @@ export function withAiEmbeddings() { // saying nothing here reads as "the build did nothing". if (!result.totalToEmbed) { patchState(store, { + indexBuildInFlight: false, indexBuildNotice: { kind: 'empty', indexName: result.indexName @@ -114,6 +120,7 @@ export function withAiEmbeddings() { } patchState(store, { + indexBuildInFlight: false, indexBuildNotice: { kind: 'built', indexName: result.indexName, @@ -134,6 +141,7 @@ export function withAiEmbeddings() { // (FR-014). catchError((error: HttpErrorResponse) => { patchState(store, { + indexBuildInFlight: false, indexBuildNotice: { kind: 'failed', indexName: form.indexName, 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..ab35307946a3 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,82 @@ describe('withAiIndexes', () => { }); }); + 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 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 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..034fafe8194c 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 @@ -18,11 +18,25 @@ import { DotAiEmbeddingsService, DotHttpErrorManagerService } from '@dotcms/data import { DOT_AI_INDEX_STATUS, DotAiIndex } from '@dotcms/dotcms-models'; import { DotAiPortletState } from '../../models/dot-ai-portlet.models'; -import { deriveIndexStatuses, toIndexOptions } from '../../utils/dot-ai-index.utils'; +import { + deriveIndexStatuses, + toIndexOptions, + 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 unaccounted for before the portlet stops waiting on it. + * + * A seed keeps its index BUILDING and keeps the poll running. Embedding normally writes its + * first rows within a second or two, so anything still missing after two minutes is a build + * that failed somewhere the client cannot see — and without a stop condition the poll would run + * for the life of the page. + */ +const BUILD_SEED_TTL_MS = 2 * 60 * 1000; + /** * The embeddings index list — one owner, two readers. * @@ -46,20 +60,39 @@ export function withAiIndexes() { const httpErrorManager = inject(DotHttpErrorManagerService); const applyIndexes = (indexes: DotAiIndex[]) => { - const offered = toIndexOptions(indexes).map((option) => option.value); + const now = Date.now(); - const seeds = new Set(store.indexBuildSeeds()); - const statuses = deriveIndexStatuses(indexes, store.indexFragmentSnapshot(), seeds); + // Drop seeds that have outlived their welcome before anything derives from them, + // so an abandoned build stops both the BUILDING badge and the poll. + const liveSeeds = Object.fromEntries( + Object.entries(store.indexBuildSeeds()).filter( + ([, startedAt]) => now - startedAt < BUILD_SEED_TTL_MS + ) + ); + + const seeds = new Set(Object.keys(liveSeeds)); + + // 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); + const offered = toIndexOptions(merged).map((option) => option.value); + + const statuses = deriveIndexStatuses(merged, store.indexFragmentSnapshot(), 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 stillBuilding = Object.fromEntries( + Object.entries(liveSeeds).filter( + ([name]) => statuses[name] === DOT_AI_INDEX_STATUS.BUILDING + ) + ); patchState(store, { - indexes, + indexes: merged, indexStatuses: statuses, 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; @@ -113,8 +146,18 @@ export function withAiIndexes() { * from a delta that has not appeared yet. */ markIndexBuilding(indexName: string): void { + const listed = store.indexes().some((index) => index.name === indexName); + patchState(store, { - indexBuildSeeds: [...new Set([...store.indexBuildSeeds(), indexName])], + 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. + ...(listed + ? {} + : { + indexes: withPendingIndexes(store.indexes(), new Set([indexName])) + }), indexStatuses: { ...store.indexStatuses(), [indexName]: DOT_AI_INDEX_STATUS.BUILDING 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..09377f5254fe 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 @@ -48,11 +48,9 @@ } } @else { - - } diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.ts index eac98cbc63ed..c493eb3834a6 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.ts @@ -5,10 +5,10 @@ import { Component, computed, DestroyRef, inject, signal, viewChild } from '@ang import { ButtonModule } from 'primeng/button'; import { DotAgentThinkingComponent, DotAiPromptInputComponent } from '@dotcms/ai-ui'; +import { DotMessageService } from '@dotcms/data-access'; import { DOT_AI_ANSWER_STATE } from '@dotcms/dotcms-models'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotEmptyContainerComponent, DotMessagePipe, 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'; @@ -28,7 +28,7 @@ import { DotAiStore } from '../../store/dot-ai.store'; @Component({ selector: 'dot-ai-chat', imports: [ - DotAiEmptyStateComponent, + DotEmptyContainerComponent, ButtonModule, MarkdownModule, DotAgentThinkingComponent, @@ -42,6 +42,19 @@ import { DotAiStore } from '../../store/dot-ai.store'; export default class DotAiChatComponent { protected readonly store = inject(DotAiStore); + readonly #messageService = inject(DotMessageService); + + /** + * The subtitle deliberately does not promise sources: only the non-streaming mode returns + * them, and this tab streams. + */ + protected readonly emptyConfig: PrincipalConfiguration = { + title: this.#messageService.get('dotai.chat.empty.title'), + subtitle: this.#messageService.get('dotai.chat.empty.sub'), + icon: 'forum', + iconStyle: 'material-symbols-rounded' + }; + constructor() { // FR-015: leaving Chat mid-answer must cancel it. The store's own onDestroy cannot do // this — DotAiStore is provided on the shell, above the five tab routes, so switching diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.html b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.html index bc1e7cab7691..a0de49c1c898 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.html +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.html @@ -9,33 +9,20 @@ (search)="$filter.set($event)" data-testid="dotai-config-filter" /> - + - {{ store.resolvedConfig()?.configHost }} + {{ $hostLabel() }} - - - -
@if (store.redactionFailed()) { - } @else { -
-

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

-
+ @@ -90,20 +78,3 @@ }
- - -
{{ $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..8d3f5e0c9ebc 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: [], @@ -50,9 +51,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,27 +84,9 @@ 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; - - 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 ?? ''; - - expect(json).toContain('••••••••'); - expect(json).not.toContain('*****'); - }); - - 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); - spectator = createComponent(); - - expect(viewProviderButton()?.hasAttribute('disabled')).toBe(true); - }); + 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', () => { @@ -116,15 +111,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..12af81d07786 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,22 @@ 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'; /** - * 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 +24,9 @@ import { @Component({ selector: 'dot-ai-config-values', imports: [ - DotAiEmptyStateComponent, + DotEmptyContainerComponent, TableModule, TagModule, - ButtonModule, - DialogModule, DotSearchInputComponent, DotCopyButtonComponent, DotMessagePipe @@ -40,9 +37,44 @@ 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: PrincipalConfiguration = { + title: this.#messageService.get('dotai.config.redaction-failed.title'), + subtitle: this.#messageService.get('dotai.config.redaction-failed.sub'), + icon: 'visibility_off', + iconStyle: 'material-symbols-rounded' + }; + + protected readonly noMatchesConfig: PrincipalConfiguration = { + title: this.#messageService.get('dotai.config.empty'), + icon: 'filter_alt_off', + iconStyle: 'material-symbols-rounded' + }; + + /** + * 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())); @@ -60,16 +92,6 @@ export default class DotAiConfigValuesComponent { ); }); - /** - * A flat two-column table cannot represent nested JSON, so it gets its own view. - * - * 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). - */ - protected readonly $providerJson = computed(() => - JSON.stringify(maskCredentials(this.store.resolvedConfig()?.providerConfig ?? {}), null, 2) - ); - 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..a3cbbaafd08b 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 @@ -10,7 +10,7 @@
+ @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 (notice.kind === 'built') { + + {{ 'dotai.embeddings.build.ok' | dm: [notice.detail ?? '', notice.indexName] }} + + } }
@if (store.indexesForbidden()) { - } @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..a869200ef125 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 @@ -148,53 +148,17 @@ describe('DotAiEmbeddingsComponent', () => { ); }); - 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 clear a previous outcome so the dialog opens clean', () => { + // The dialog reads the same notice signal; a stale one would greet the next build + // with the last one's error. clickButton('dotai-embeddings-new-index'); - onClose.next({ mode: 'add', indexName: 'blogs', query: '+contentType:Blog' }); - - expect(storeMock.buildIndex).toHaveBeenCalledWith({ - indexName: 'blogs', - query: '+contentType:Blog' - }); - expect(storeMock.buildIndex.mock.calls[0][0]).not.toHaveProperty('mode'); - expect(storeMock.deleteFromIndex).not.toHaveBeenCalled(); + expect(storeMock.dismissBuildNotice).toHaveBeenCalled(); }); - it('should still forward the optional build fields', () => { - clickButton('dotai-embeddings-new-index'); - - onClose.next({ - mode: 'add', - indexName: 'blogs', - query: '+contentType:Blog', - fields: 'title,body', - velocityTemplate: '$!{title}' - }); - - expect(storeMock.buildIndex).toHaveBeenCalledWith({ - indexName: 'blogs', - query: '+contentType:Blog', - fields: 'title,body', - velocityTemplate: '$!{title}' - }); - }); - - it('should delete from the index on a delete-mode result (FR-030)', () => { - clickButton('dotai-embeddings-new-index'); - - onClose.next({ mode: 'delete', indexName: 'blogs', query: '+contentType:Blog' }); - - expect(storeMock.deleteFromIndex).toHaveBeenCalledWith({ - indexName: 'blogs', - query: '+contentType:Blog' - }); - expect(storeMock.buildIndex).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); @@ -278,11 +242,15 @@ describe('DotAiEmbeddingsComponent', () => { 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'); + 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(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..e1e2c5751950 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 @@ -9,19 +9,17 @@ import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; 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 { DotAiIndexCreateComponent } from './dot-ai-index-create/dot-ai-index-create.component'; -import { DotAiEmptyStateComponent } from '../../components/dot-ai-empty-state/dot-ai-empty-state.component'; -import { DotAiIndexBuildNotice } from '../../models/dot-ai-portlet.models'; import { DotAiStore } from '../../store/dot-ai.store'; /** @@ -52,7 +50,7 @@ const CONFIRM_BUTTONS = { @Component({ selector: 'dot-ai-embeddings', imports: [ - DotAiEmptyStateComponent, + DotEmptyContainerComponent, ToolbarModule, MessageModule, TableModule, @@ -75,56 +73,43 @@ export default class DotAiEmbeddingsComponent { protected readonly statuses = DOT_AI_INDEX_STATUS; + protected readonly emptyConfig: PrincipalConfiguration = { + title: this.#messageService.get('dotai.embeddings.empty.title'), + subtitle: this.#messageService.get('dotai.embeddings.empty.sub'), + icon: 'database', + iconStyle: 'material-symbols-rounded' + }; + + protected readonly forbiddenConfig: PrincipalConfiguration = { + title: this.#messageService.get('dotai.index.admin-required'), + subtitle: this.#messageService.get('dotai.index.admin-required.sub'), + icon: 'lock', + iconStyle: 'material-symbols-rounded' + }; + /** 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'; - } - - return kind === 'empty' ? 'warn' : 'error'; - } - + /** + * Opens the build dialog and leaves it to it. + * + * No `onClose` handling any more: the dialog submits to the store itself so that a rejected + * Lucene query can be corrected in the form that produced it, rather than being reported + * onto this tab after the modal has closed over the query. + */ 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); - }); + this.store.dismissBuildNotice(); + + this.#dialogService.open(DotAiIndexCreateComponent, { + header: this.#messageService.get('dotai.index.create.header'), + width: '700px', + closable: true, + closeOnEscape: true, + draggable: false + }); } 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..2889cf4634e5 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 @@ -76,14 +76,32 @@ + {{ 'dotai.index.create.template.hint' | dm }}
} + + @if ($notice(); as notice) { + + @if (notice.kind === 'empty') { + {{ 'dotai.embeddings.build.empty' | dm: [notice.indexName] }} + } @else { + {{ 'dotai.embeddings.build.failed' | dm: [notice.indexName, notice.detail ?? ''] }} + } + + } +
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..f04a1fa9598f 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,58 @@ import { Spectator } from '@openng/spectator/vitest'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { signal } from '@angular/core'; + +import { 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 { DotAiIndexBuildNotice } from '../../../models/dot-ai-portlet.models'; +import { DotAiStore } from '../../../store/dot-ai.store'; + describe('DotAiIndexCreateComponent', () => { let spectator: Spectator; let dialogRef: DynamicDialogRef; + let store: { + indexes: ReturnType>; + indexBuildNotice: ReturnType>; + indexBuildInFlight: ReturnType>; + buildIndex: ReturnType; + deleteFromIndex: ReturnType; + dismissBuildNotice: ReturnType; + }; const createComponent = createComponentFactory({ component: DotAiIndexCreateComponent, - providers: [ - mockProvider(DynamicDialogRef), - mockProvider(DotMessageService), - { provide: DynamicDialogConfig, useValue: { data: { indexes: ['default'] } } } - ], + providers: [mockProvider(DynamicDialogRef), mockProvider(DotMessageService)], shallow: true }); beforeEach(() => { - spectator = createComponent(); + store = { + indexes: signal([ + { + name: 'default', + fragments: 1, + contents: 1, + tokenTotal: 1, + tokensPerChunk: 1, + contentTypes: [] + } + ]), + indexBuildNotice: signal(null), + indexBuildInFlight: signal(false), + buildIndex: vi.fn(), + deleteFromIndex: vi.fn(), + dismissBuildNotice: vi.fn() + }; + + spectator = createComponent({ + providers: [{ provide: DotAiStore, useValue: store }] + }); dialogRef = spectator.inject(DynamicDialogRef); }); @@ -39,6 +69,12 @@ describe('DotAiIndexCreateComponent', () => { spectator.query(byTestId(testId))?.querySelector('button') as HTMLButtonElement ); + const fillValidForm = () => { + fill('dotai-index-create-name', 'blogs'); + fill('dotai-index-create-query', '+contentType:Blog'); + spectator.detectChanges(); + }; + it('should keep submit disabled until both name and query are given', () => { const submit = () => spectator.query(byTestId('dotai-index-create-submit'))?.querySelector('button'); @@ -54,20 +90,20 @@ describe('DotAiIndexCreateComponent', () => { expect(submit()?.disabled).toBe(false); }); - it('should close with an add-mode payload including the optional shaping fields', () => { - fill('dotai-index-create-name', 'blogs'); - fill('dotai-index-create-query', '+contentType:Blog'); + it('should build through the store rather than resolving the dialog', () => { + fillValidForm(); fill('dotai-index-create-fields', 'title,body'); spectator.detectChanges(); clickButton('dotai-index-create-submit'); - expect(dialogRef.close).toHaveBeenCalledWith({ - mode: 'add', + expect(store.buildIndex).toHaveBeenCalledWith({ indexName: 'blogs', query: '+contentType:Blog', fields: 'title,body' }); + // The whole point: the query has to survive long enough to be corrected. + expect(dialogRef.close).not.toHaveBeenCalled(); }); it('should trim whitespace off the name and query', () => { @@ -77,14 +113,72 @@ describe('DotAiIndexCreateComponent', () => { clickButton('dotai-index-create-submit'); - expect(dialogRef.close).toHaveBeenCalledWith( + expect(store.buildIndex).toHaveBeenCalledWith( expect.objectContaining({ indexName: 'blogs', query: '+contentType:Blog' }) ); }); + it('should show a rejected query inline and keep what was typed', () => { + fillValidForm(); + clickButton('dotai-index-create-submit'); + + store.indexBuildNotice.set({ + kind: 'failed', + indexName: 'blogs', + detail: 'Cannot parse query' + }); + spectator.detectChanges(); + + expect(spectator.query(byTestId('dotai-index-create-notice'))).toBeTruthy(); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect( + (spectator.query(byTestId('dotai-index-create-query')) as HTMLTextAreaElement).value + ).toBe('+contentType:Blog'); + }); + + it('should keep a query that matched nothing in the dialog too', () => { + fillValidForm(); + clickButton('dotai-index-create-submit'); + + store.indexBuildNotice.set({ kind: 'empty', indexName: 'blogs' }); + spectator.detectChanges(); + + expect(spectator.query(byTestId('dotai-index-create-notice'))).toBeTruthy(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('should close itself once the build succeeds', () => { + fillValidForm(); + clickButton('dotai-index-create-submit'); + + store.indexBuildNotice.set({ kind: 'built', indexName: 'blogs', detail: '12' }); + spectator.detectChanges(); + + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('should block a second submit while a build is outstanding', () => { + fillValidForm(); + store.indexBuildInFlight.set(true); + spectator.detectChanges(); + + expect( + spectator.query(byTestId('dotai-index-create-submit'))?.querySelector('button') + ?.disabled + ).toBe(true); + }); + + it('should reject a name an existing index already uses', () => { + fill('dotai-index-create-name', 'default'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('dotai-index-create-name-error'))).toBeTruthy(); + }); + it('should close with nothing on cancel, so the caller does no work', () => { clickButton('dotai-index-create-cancel'); + expect(store.dismissBuildNotice).toHaveBeenCalled(); expect(dialogRef.close).toHaveBeenCalledWith(); }); @@ -104,4 +198,14 @@ describe('DotAiIndexCreateComponent', () => { 'p-label-input-required' ); }); + + it('should guide the Velocity template with a placeholder and a hint', () => { + // It had neither, and it silently overrides Fields — the one thing nobody could guess. + const template = spectator.query( + byTestId('dotai-index-create-template') + ) as HTMLTextAreaElement; + + expect(template.getAttribute('placeholder')).toBeTruthy(); + expect(spectator.query('label[for="dotai-index-template"]')).toBeTruthy(); + }); }); diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.ts index 52d431af2c9d..642d3e896fc6 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.ts @@ -1,14 +1,17 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, effect, inject, signal, untracked } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { InputTextModule } from 'primeng/inputtext'; +import { MessageModule } from 'primeng/message'; import { SelectButtonModule } from 'primeng/selectbutton'; import { TextareaModule } from 'primeng/textarea'; import { DotMessagePipe } from '@dotcms/ui'; +import { DotAiStore } from '../../../store/dot-ai.store'; + export type DotAiIndexCreateMode = 'add' | 'delete'; /** @@ -18,20 +21,19 @@ export type DotAiIndexCreateMode = 'add' | 'delete'; */ const INDEX_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; -export interface DotAiIndexCreateResult { - mode: DotAiIndexCreateMode; - indexName: string; - query: string; - fields?: string; - velocityTemplate?: string; -} - /** * One dialog, two modes. * * Add mode embeds the content the query matches; delete mode removes it from the index. The * submit label flips with the toggle so the destructive mode never hides behind a neutral * word (FR-030) — the legacy screen did the same remap. + * + * Unusually for this codebase, the dialog calls the store itself rather than closing with a + * form value for the list component to submit. It has to: a build is the one action here whose + * *failure* is a correction to the form — a malformed Lucene query — and resolving the dialog + * first threw that message onto the tab behind a modal that had already taken the query with + * it. Owning the submit is what lets the query survive its own error. The store is still data + * only; nothing here is dispatched from it. */ @Component({ selector: 'dot-ai-index-create', @@ -41,15 +43,15 @@ export interface DotAiIndexCreateResult { InputTextModule, TextareaModule, SelectButtonModule, + MessageModule, DotMessagePipe ], templateUrl: './dot-ai-index-create.component.html' }) export class DotAiIndexCreateComponent { readonly #dialogRef = inject(DynamicDialogRef); - readonly #config = inject(DynamicDialogConfig<{ indexes: string[] }>); - protected readonly existingIndexes = this.#config.data?.indexes ?? []; + protected readonly store = inject(DotAiStore); protected readonly $mode = signal('add'); protected readonly $indexName = signal(''); @@ -68,6 +70,29 @@ export class DotAiIndexCreateComponent { : 'dotai.index.create.submit.add' ); + /** + * The build outcome, while it is this dialog's to show. + * + * `built` is absent by construction — the effect below closes on it — so what is left is + * exactly the two outcomes the user has to act on: a query that matched nothing, and a + * query the server rejected. + */ + protected readonly $notice = computed(() => { + const notice = this.store.indexBuildNotice(); + + return notice?.kind === 'built' ? null : notice; + }); + + constructor() { + // A finished build is the only thing that dismisses this dialog. The success message is + // left standing on the tab behind, next to the row it just created. + effect(() => { + if (this.store.indexBuildNotice()?.kind === 'built') { + untracked(() => this.#dialogRef.close()); + } + }); + } + /** Empty until the field has been touched, so the form does not scold you on open. */ protected readonly $nameError = computed(() => { const name = this.$indexName().trim(); @@ -81,7 +106,7 @@ export class DotAiIndexCreateComponent { } // Only for a build: deleting names an index that must already exist. - if (this.$mode() === 'add' && this.existingIndexes.includes(name)) { + if (this.$mode() === 'add' && this.store.indexes().some((index) => index.name === name)) { return 'dotai.index.create.name.exists'; } @@ -89,7 +114,11 @@ export class DotAiIndexCreateComponent { }); protected readonly $canSubmit = computed( - () => !!this.$indexName().trim() && !!this.$query().trim() && !this.$nameError() + () => + !!this.$indexName().trim() && + !!this.$query().trim() && + !this.$nameError() && + !this.store.indexBuildInFlight() ); protected submit(): void { @@ -97,27 +126,31 @@ export class DotAiIndexCreateComponent { return; } - const result: DotAiIndexCreateResult = { - mode: this.$mode(), - indexName: this.$indexName().trim(), - query: this.$query().trim() - }; + const indexName = this.$indexName().trim(); + const query = this.$query().trim(); - // Only meaningful when embedding; a delete is driven purely by the query. - if (this.$mode() === 'add') { - if (this.$fields().trim()) { - result.fields = this.$fields().trim(); - } + // A delete has no outcome to report back into the form — it either works or goes + // through the shared error handler — so it keeps the original resolve-and-close shape. + if (this.$mode() === 'delete') { + this.store.deleteFromIndex({ indexName, query }); + this.#dialogRef.close(); - if (this.$velocityTemplate().trim()) { - result.velocityTemplate = this.$velocityTemplate().trim(); - } + return; } - this.#dialogRef.close(result); + this.store.buildIndex({ + indexName, + query, + // Both only shape what gets embedded, so they are omitted rather than sent blank. + ...(this.$fields().trim() ? { fields: this.$fields().trim() } : {}), + ...(this.$velocityTemplate().trim() + ? { velocityTemplate: this.$velocityTemplate().trim() } + : {}) + }); } protected cancel(): void { + this.store.dismissBuildNotice(); this.#dialogRef.close(); } } diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.html b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.html index 4de215802e21..2a8f23694c2f 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.html +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.html @@ -122,17 +122,11 @@
} @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..a3e63e599661 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,8 +9,9 @@ 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, PrincipalConfiguration } from '@dotcms/ui'; import { DotAiStore } from '../../store/dot-ai.store'; @@ -24,6 +25,7 @@ import { DotAiStore } from '../../store/dot-ai.store'; @Component({ selector: 'dot-ai-image', imports: [ + DotEmptyContainerComponent, FormsModule, ButtonModule, SelectModule, @@ -40,6 +42,15 @@ import { DotAiStore } from '../../store/dot-ai.store'; export default class DotAiImageComponent { protected readonly store = inject(DotAiStore); + readonly #messageService = inject(DotMessageService); + + protected readonly emptyConfig: PrincipalConfiguration = { + title: this.#messageService.get('dotai.image.empty.title'), + subtitle: this.#messageService.get('dotai.image.empty.sub'), + icon: 'image', + iconStyle: 'material-symbols-rounded' + }; + 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..7a8097bd7715 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 @@ -52,20 +52,20 @@ } - } @else if (store.searchMissingIndex(); as missing) { - } @else if (!store.hasSearched()) { - } @else if (!store.searchResults().length) { - } @else {
    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..8d1f1ce3ab77 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,9 +7,14 @@ 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'; @@ -25,7 +30,7 @@ import { toClosenessPercent } from '../../utils/dot-ai-distance.utils'; @Component({ selector: 'dot-ai-search', imports: [ - DotAiEmptyStateComponent, + DotEmptyContainerComponent, ButtonModule, InputGroupModule, InputGroupAddonModule, @@ -42,6 +47,31 @@ import { toClosenessPercent } from '../../utils/dot-ai-distance.utils'; export default class DotAiSearchComponent { protected readonly store = inject(DotAiStore); + readonly #messageService = inject(DotMessageService); + + /** Resolved strings rather than keys: `dot-empty-container` renders `configuration` as-is. */ + protected readonly firstRunConfig: PrincipalConfiguration = { + title: this.#messageService.get('dotai.search.first-run.title'), + subtitle: this.#messageService.get('dotai.search.first-run.sub'), + icon: 'search', + iconStyle: 'material-symbols-rounded' + }; + + protected readonly noResultsConfig: PrincipalConfiguration = { + title: this.#messageService.get('dotai.search.no-results.title'), + subtitle: this.#messageService.get('dotai.search.no-results.sub'), + icon: 'search_off', + iconStyle: 'material-symbols-rounded' + }; + + /** The only one that has to be computed — the subtitle is the index name from the server. */ + protected readonly $missingIndexConfig = computed(() => ({ + title: this.#messageService.get('dotai.search.index-missing'), + subtitle: this.store.searchMissingIndex() ?? '', + icon: 'database_off', + iconStyle: 'material-symbols-rounded' + })); + /** * 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-index.utils.ts b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.ts index 3d0f0d96d169..cce3abb0e426 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 @@ -19,6 +19,30 @@ export function toIndexOptions(indexes: DotAiIndex[]): { label: string; value: s })); } +/** + * A build that has been requested but has not reached `indexCount` 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. + */ +export 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; +} + /** * Build status per index, derived rather than read: `dot_embeddings` has no status column. * @@ -26,6 +50,10 @@ export function toIndexOptions(indexes: DotAiIndex[]): { label: string; value: s * 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. * + * 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 stays + * BUILDING rather than settling to READY 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. */ 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..f53366dffefe 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -193,7 +193,14 @@ 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)"); + // The site the configuration is being read for, and whether it actually came from that + // site. ConfigService falls back to the System Host's secrets when the site has none of + // its own, so the two hostnames differing is what "inherited" means here. Reported as + // separate fields rather than one concatenated English string so the client can label + // and translate it. + final String requestedHost = host.getHostname(); + map.put(AiKeys.CONFIG_HOST, requestedHost); + map.put(AiKeys.CONFIG_HOST_INHERITED, !requestedHost.equalsIgnoreCase(appConfig.getHost())); final String providerConfig = appConfig.getProviderConfig(); if (StringUtils.isNotBlank(providerConfig)) { diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index b816dfa8ee1c..41ab7e6cdf0f 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -8742,8 +8742,10 @@ 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 @@ -8755,10 +8757,11 @@ 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 From 6022950191bb51844a10feec0337c3ff66cdb809 Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 14 Sep 2026 12:53:30 -0600 Subject: [PATCH 02/11] fix(dotai): centre the empty states in their pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dot-empty-container` centres itself with `h-full`, and a percentage height needs a parent that has one. Every dotAI call site nested it somewhere with an auto height, so each state measured only as tall as its own text and pinned itself to the top of an otherwise blank pane. Search and Chat: the empty states move out of the `container mx-auto` column and become direct children of the scroll box, which is a flex child with a real height. The results and the loading skeletons keep the column. Embeddings and Config Values: the empty states move out of the table's `emptymessage` and replace the table entirely, as the forbidden state already did. A `` is sized by its own content, so nothing done to the host could centre it there — and this also drops a header row with nothing under it. The Embeddings empty state now picks its copy: "No indexes yet" only when there are none, and "No indexes match this filter." when a filter is what emptied the table. Telling someone to create their first index when they have six and mistyped the filter is the wrong instruction. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-ai-chat/dot-ai-chat.component.html | 20 ++++---- .../dot-ai-config-values.component.html | 18 +++----- .../dot-ai-embeddings.component.html | 19 ++++---- .../dot-ai-embeddings.component.ts | 27 ++++++++--- .../dot-ai-search.component.html | 46 +++++++++++-------- .../dot-ai-search.component.spec.ts | 21 ++++++++- .../WEB-INF/messages/Language.properties | 1 + 7 files changed, 93 insertions(+), 59 deletions(-) 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 09377f5254fe..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 { - - - - - - - - }
    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 a3cbbaafd08b..fa2320319cba 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 @@ -48,6 +48,14 @@ [configuration]="forbiddenConfig" [hideContactUsLink]="true" data-testid="dotai-embeddings-forbidden" /> + } @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.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-embeddings.component.ts index e1e2c5751950..34e3e7481207 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,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; @@ -73,12 +73,25 @@ export default class DotAiEmbeddingsComponent { protected readonly statuses = DOT_AI_INDEX_STATUS; - protected readonly emptyConfig: PrincipalConfiguration = { - title: this.#messageService.get('dotai.embeddings.empty.title'), - subtitle: this.#messageService.get('dotai.embeddings.empty.sub'), - icon: 'database', - iconStyle: 'material-symbols-rounded' - }; + /** + * 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() + ? { + title: this.#messageService.get('dotai.embeddings.no-matches'), + icon: 'filter_alt_off', + iconStyle: 'material-symbols-rounded' + } + : { + title: this.#messageService.get('dotai.embeddings.empty.title'), + subtitle: this.#messageService.get('dotai.embeddings.empty.sub'), + icon: 'database', + iconStyle: 'material-symbols-rounded' + } + ); protected readonly forbiddenConfig: PrincipalConfiguration = { title: this.#messageService.get('dotai.index.admin-required'), 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 7a8097bd7715..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()) { - - } @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/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 41ab7e6cdf0f..5b134c34a229 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -8725,6 +8725,7 @@ 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. From d841f964334bf52d872dde1e90f6d45bf037a21e Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 14 Sep 2026 12:57:04 -0600 Subject: [PATCH 03/11] fix(dotai): centre the Image empty state horizontally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `justify-center` belongs on the wrapper, not just inside the host. The host is a flex *item* in that panel, so it was sized to its own content — a 30rem column — and sat against the left edge; its own `justify-center` only centred the text within that column. The other four tabs give the host a full-width block, which is why this one was the only one still off. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/tabs/dot-ai-image/dot-ai-image.component.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.html b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.html index 2a8f23694c2f..0f86ed25685c 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.html +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-image/dot-ai-image.component.html @@ -122,7 +122,11 @@ } @else { -
    + +
    Date: Mon, 14 Sep 2026 13:16:20 -0600 Subject: [PATCH 04/11] refactor(dotai): fold out duplicated and derivable state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the feedback fixes; no behaviour change. `indexStatuses` is a projection, not state. `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 it — holding both only created somewhere for the two to drift, and `markIndexBuilding` had to write each separately. It is now a computed over `indexes` and `indexBuildSeeds`, which also makes the poll guard say what it means: is any build outstanding. The build-seed TTL now covers only the window before the index is listed at all. Measured from the start of the build, as it was, a build of a few thousand contentlets would have its seed dropped at two minutes, flip to Ready mid-flight and stop the poll — the exact failure the feature exists to prevent. Once the index is listed the fragment delta owns its lifecycle and no clock is involved. `indexBuildInFlight` leaves the state model: it disabled one button in one dialog and nothing outside read it, so it is a local signal there instead. The dialog now clears an unrendered outcome in `DestroyRef.onDestroy` rather than in `cancel()` — PrimeNG's header X and Escape call `DynamicDialogRef.close` directly and never reached that handler, leaving a failure set with nothing showing it. Also: `markIndexBuilding` no longer re-implements the guard already inside `withPendingIndexes`; `applyIndexes` stops building and discarding a label per index for one membership test; the tab's build notice loses a nested `@if`; and the ten near-identical `PrincipalConfiguration` literals collapse onto one `toEmptyStateConfig` helper. Documents the dialog-owns-its-submit exception in libs/portlets/CLAUDE.md, which still said `close(formValue)` was the only pattern. Co-Authored-By: Claude Opus 5 (1M context) --- core-web/libs/portlets/CLAUDE.md | 2 + .../src/lib/models/dot-ai-portlet.models.ts | 6 -- .../features/with-ai-embeddings.feature.ts | 10 +-- .../features/with-ai-indexes.feature.spec.ts | 25 ++++++ .../store/features/with-ai-indexes.feature.ts | 81 ++++++++++++------- .../tabs/dot-ai-chat/dot-ai-chat.component.ts | 14 ++-- .../dot-ai-config-values.component.ts | 26 +++--- .../dot-ai-embeddings.component.html | 23 +++--- .../dot-ai-embeddings.component.ts | 45 +++++++---- .../dot-ai-index-create.component.html | 2 +- .../dot-ai-index-create.component.spec.ts | 36 ++++++++- .../dot-ai-index-create.component.ts | 54 +++++++++---- .../dot-ai-image/dot-ai-image.component.ts | 14 ++-- .../dot-ai-search/dot-ai-search.component.ts | 33 ++++---- .../src/lib/utils/dot-ai-empty-state.utils.ts | 24 ++++++ .../src/lib/utils/dot-ai-index.utils.ts | 2 +- 16 files changed, 255 insertions(+), 142 deletions(-) create mode 100644 core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-empty-state.utils.ts 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/models/dot-ai-portlet.models.ts b/core-web/libs/portlets/dot-ai/src/lib/models/dot-ai-portlet.models.ts index d5f0d71eb776..33e0d52961df 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'; @@ -121,7 +120,6 @@ export interface DotAiPortletState { // indexes indexes: DotAiIndex[]; - indexStatuses: Record; indexFragmentSnapshot: Record; /** * Builds that have been requested but may not be in `indexes` yet — name to the epoch ms @@ -160,8 +158,6 @@ export interface DotAiPortletState { // embeddings screen (client-side filters — the whole dataset arrives in one response) indexFilter: string; indexBuildNotice: DotAiIndexBuildNotice | null; - /** A build request is outstanding. Read by the create dialog, which stays open until it settles. */ - indexBuildInFlight: boolean; // image image: DotAiGeneratedImage | null; @@ -212,7 +208,6 @@ export const DOT_AI_INITIAL_STATE: DotAiPortletState = { providerConfig: null, indexes: [], - indexStatuses: {}, indexFragmentSnapshot: {}, indexBuildSeeds: {}, indexesForbidden: false, @@ -237,7 +232,6 @@ export const DOT_AI_INITIAL_STATE: DotAiPortletState = { indexFilter: '', indexBuildNotice: null, - indexBuildInFlight: false, image: null, imageGenerating: false, 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 6818a29b5c02..2ce7eeba91cb 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 @@ -94,12 +94,7 @@ export function withAiEmbeddings() { buildIndex: rxMethod( pipe( - tap(() => - patchState(store, { - indexBuildNotice: null, - indexBuildInFlight: true - }) - ), + tap(() => patchState(store, { indexBuildNotice: null })), // exhaustMap: a double submit must not build twice. exhaustMap((form) => embeddingsService.buildIndex(form).pipe( @@ -109,7 +104,6 @@ export function withAiEmbeddings() { // saying nothing here reads as "the build did nothing". if (!result.totalToEmbed) { patchState(store, { - indexBuildInFlight: false, indexBuildNotice: { kind: 'empty', indexName: result.indexName @@ -120,7 +114,6 @@ export function withAiEmbeddings() { } patchState(store, { - indexBuildInFlight: false, indexBuildNotice: { kind: 'built', indexName: result.indexName, @@ -141,7 +134,6 @@ export function withAiEmbeddings() { // (FR-014). catchError((error: HttpErrorResponse) => { patchState(store, { - indexBuildInFlight: false, indexBuildNotice: { kind: 'failed', indexName: form.indexName, 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 ab35307946a3..c9f73aea5a0f 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 @@ -180,6 +180,31 @@ describe('withAiIndexes', () => { 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 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. 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 034fafe8194c..8ff7e5202893 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,12 +15,13 @@ 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, + toRetrievalIndexes, withPendingIndexes } from '../../utils/dot-ai-index.utils'; @@ -28,14 +29,18 @@ import { const INDEX_POLL_MS = 5000; /** - * How long a requested build may stay unaccounted for before the portlet stops waiting on it. + * How long a requested build may stay missing from `indexCount` before the portlet gives up on + * it. * - * A seed keeps its index BUILDING and keeps the poll running. Embedding normally writes its - * first rows within a second or two, so anything still missing after two minutes is a build - * that failed somewhere the client cannot see — and without a stop condition the poll would run - * for the life of the page. + * 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 BUILD_SEED_TTL_MS = 2 * 60 * 1000; +const PENDING_SEED_TTL_MS = 2 * 60 * 1000; /** * The embeddings index list — one owner, two readers. @@ -53,7 +58,29 @@ 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, + index.name in seeds + ? DOT_AI_INDEX_STATUS.BUILDING + : DOT_AI_INDEX_STATUS.READY + ]) + ); + }) })), withMethods((store) => { const embeddingsService = inject(DotAiEmbeddingsService); @@ -61,12 +88,15 @@ export function withAiIndexes() { const applyIndexes = (indexes: DotAiIndex[]) => { const now = Date.now(); + const listed = new Set(indexes.map((index) => index.name)); - // Drop seeds that have outlived their welcome before anything derives from them, - // so an abandoned build stops both the BUILDING badge and the poll. + // A seed survives while the server still has not listed its index and the wait + // is within the grace period, or once it is listed — from then on + // `deriveIndexStatuses` decides, off the fragment delta. const liveSeeds = Object.fromEntries( Object.entries(store.indexBuildSeeds()).filter( - ([, startedAt]) => now - startedAt < BUILD_SEED_TTL_MS + ([name, requestedAt]) => + listed.has(name) || now - requestedAt < PENDING_SEED_TTL_MS ) ); @@ -75,8 +105,6 @@ export function withAiIndexes() { // 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); - const offered = toIndexOptions(merged).map((option) => option.value); - const statuses = deriveIndexStatuses(merged, store.indexFragmentSnapshot(), seeds); // An index that has settled is no longer a candidate for the next poll. @@ -86,9 +114,13 @@ export function withAiIndexes() { ) ); + // 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: merged, - indexStatuses: statuses, 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 @@ -146,22 +178,13 @@ export function withAiIndexes() { * from a delta that has not appeared yet. */ markIndexBuilding(indexName: string): void { - const listed = store.indexes().some((index) => index.name === indexName); - patchState(store, { 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. - ...(listed - ? {} - : { - indexes: withPendingIndexes(store.indexes(), new Set([indexName])) - }), - indexStatuses: { - ...store.indexStatuses(), - [indexName]: DOT_AI_INDEX_STATUS.BUILDING - } + // 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])) }); } }; @@ -193,11 +216,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.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.ts index c493eb3834a6..e43be3b89be7 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-chat/dot-ai-chat.component.ts @@ -7,10 +7,11 @@ import { ButtonModule } from 'primeng/button'; import { DotAgentThinkingComponent, DotAiPromptInputComponent } from '@dotcms/ai-ui'; import { DotMessageService } from '@dotcms/data-access'; import { DOT_AI_ANSWER_STATE } from '@dotcms/dotcms-models'; -import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; +import { DotEmptyContainerComponent, DotMessagePipe } from '@dotcms/ui'; import { DotAiWorkspaceComponent } from '../../components/dot-ai-workspace/dot-ai-workspace.component'; import { DotAiStore } from '../../store/dot-ai.store'; +import { toEmptyStateConfig } from '../../utils/dot-ai-empty-state.utils'; /** * Chat tab: ask a question of the indexed content and watch the answer stream in. @@ -48,12 +49,11 @@ export default class DotAiChatComponent { * The subtitle deliberately does not promise sources: only the non-streaming mode returns * them, and this tab streams. */ - protected readonly emptyConfig: PrincipalConfiguration = { - title: this.#messageService.get('dotai.chat.empty.title'), - subtitle: this.#messageService.get('dotai.chat.empty.sub'), - icon: 'forum', - iconStyle: 'material-symbols-rounded' - }; + protected readonly emptyConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.chat.empty.title', + subtitle: 'dotai.chat.empty.sub', + icon: 'forum' + }); constructor() { // FR-015: leaving Chat mid-answer must cancel it. The store's own onDestroy cannot do 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 12af81d07786..2954b106f2aa 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 @@ -8,12 +8,12 @@ import { DotCopyButtonComponent, DotEmptyContainerComponent, DotMessagePipe, - DotSearchInputComponent, - PrincipalConfiguration + DotSearchInputComponent } from '@dotcms/ui'; import { DotAiStore } from '../../store/dot-ai.store'; 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 and where it came from. @@ -42,18 +42,16 @@ export default class DotAiConfigValuesComponent { protected readonly sources = DOT_AI_CONFIG_SOURCE; protected readonly $filter = signal(''); - protected readonly redactionFailedConfig: PrincipalConfiguration = { - title: this.#messageService.get('dotai.config.redaction-failed.title'), - subtitle: this.#messageService.get('dotai.config.redaction-failed.sub'), - icon: 'visibility_off', - iconStyle: 'material-symbols-rounded' - }; - - protected readonly noMatchesConfig: PrincipalConfiguration = { - title: this.#messageService.get('dotai.config.empty'), - icon: 'filter_alt_off', - iconStyle: 'material-symbols-rounded' - }; + protected readonly redactionFailedConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.config.redaction-failed.title', + subtitle: 'dotai.config.redaction-failed.sub', + icon: 'visibility_off' + }); + + protected readonly noMatchesConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.config.empty', + icon: 'filter_alt_off' + }); /** * Which site's configuration is on screen, and whether it is actually that site's. 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 fa2320319cba..8d2977b6041a 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 @@ -24,20 +24,15 @@ - - @if (store.indexBuildNotice(); as notice) { - @if (notice.kind === 'built') { - - {{ 'dotai.embeddings.build.ok' | dm: [notice.detail ?? '', notice.indexName] }} - - } + @if ($builtNotice(); as notice) { + + {{ 'dotai.embeddings.build.ok' | dm: [notice.detail ?? '', notice.indexName] }} + }
    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 34e3e7481207..288396609c92 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 @@ -21,6 +21,7 @@ import { import { DotAiIndexCreateComponent } from './dot-ai-index-create/dot-ai-index-create.component'; 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. @@ -73,6 +74,19 @@ export default class DotAiEmbeddingsComponent { protected readonly statuses = DOT_AI_INDEX_STATUS; + /** + * The build outcome this tab owns. + * + * Only a success reaches here. The two outcomes that need the query fixed — nothing + * matched, and a query the server rejected — stay inside the create dialog, next to the + * field that produced them. + */ + protected readonly $builtNotice = computed(() => { + const notice = this.store.indexBuildNotice(); + + return notice?.kind === 'built' ? notice : null; + }); + /** * 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 @@ -80,25 +94,22 @@ export default class DotAiEmbeddingsComponent { */ protected readonly $emptyConfig = computed(() => this.store.indexFilter().trim() - ? { - title: this.#messageService.get('dotai.embeddings.no-matches'), - icon: 'filter_alt_off', - iconStyle: 'material-symbols-rounded' - } - : { - title: this.#messageService.get('dotai.embeddings.empty.title'), - subtitle: this.#messageService.get('dotai.embeddings.empty.sub'), - icon: 'database', - iconStyle: 'material-symbols-rounded' - } + ? 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: PrincipalConfiguration = { - title: this.#messageService.get('dotai.index.admin-required'), - subtitle: this.#messageService.get('dotai.index.admin-required.sub'), - icon: 'lock', - iconStyle: 'material-symbols-rounded' - }; + 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 = { 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 2889cf4634e5..5ec75f75b27c 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 @@ -113,7 +113,7 @@ type="submit" [severity]="$mode() === 'delete' ? 'danger' : 'primary'" [disabled]="!$canSubmit()" - [loading]="store.indexBuildInFlight()" + [loading]="$submitting()" [label]="$submitLabel() | dm" data-testid="dotai-index-create-submit" />
    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 f04a1fa9598f..f49adf3a0273 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 @@ -23,7 +23,6 @@ describe('DotAiIndexCreateComponent', () => { let store: { indexes: ReturnType>; indexBuildNotice: ReturnType>; - indexBuildInFlight: ReturnType>; buildIndex: ReturnType; deleteFromIndex: ReturnType; dismissBuildNotice: ReturnType; @@ -48,7 +47,6 @@ describe('DotAiIndexCreateComponent', () => { } ]), indexBuildNotice: signal(null), - indexBuildInFlight: signal(false), buildIndex: vi.fn(), deleteFromIndex: vi.fn(), dismissBuildNotice: vi.fn() @@ -159,13 +157,27 @@ describe('DotAiIndexCreateComponent', () => { it('should block a second submit while a build is outstanding', () => { fillValidForm(); - store.indexBuildInFlight.set(true); + clickButton('dotai-index-create-submit'); spectator.detectChanges(); expect( spectator.query(byTestId('dotai-index-create-submit'))?.querySelector('button') ?.disabled ).toBe(true); + expect(store.buildIndex).toHaveBeenCalledTimes(1); + }); + + it('should let the form be submitted again once the build fails', () => { + fillValidForm(); + clickButton('dotai-index-create-submit'); + + store.indexBuildNotice.set({ kind: 'failed', indexName: 'blogs', detail: 'bad query' }); + spectator.detectChanges(); + + expect( + spectator.query(byTestId('dotai-index-create-submit'))?.querySelector('button') + ?.disabled + ).toBe(false); }); it('should reject a name an existing index already uses', () => { @@ -178,10 +190,26 @@ describe('DotAiIndexCreateComponent', () => { it('should close with nothing on cancel, so the caller does no work', () => { clickButton('dotai-index-create-cancel'); - expect(store.dismissBuildNotice).toHaveBeenCalled(); expect(dialogRef.close).toHaveBeenCalledWith(); }); + it('should clear an unshown outcome on teardown, however it was dismissed', () => { + // PrimeNG's own header X and Escape call DynamicDialogRef.close directly rather than + // `cancel()`, so a failure dismissed that way would be left set with nothing rendering + // it — the tab shows only `built`, and this dialog is gone. + store.indexBuildNotice.set({ kind: 'failed', indexName: 'blogs' }); + spectator.fixture.destroy(); + + expect(store.dismissBuildNotice).toHaveBeenCalled(); + }); + + it('should leave a success standing for the tab behind it', () => { + store.indexBuildNotice.set({ kind: 'built', indexName: 'blogs', detail: '12' }); + spectator.fixture.destroy(); + + expect(store.dismissBuildNotice).not.toHaveBeenCalled(); + }); + it('should associate the mode heading with the segmented control', () => { const group = spectator.query('[role="group"]'); diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.ts index 642d3e896fc6..ec6835e26bc7 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.ts +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-create/dot-ai-index-create.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, effect, inject, signal, untracked } from '@angular/core'; +import { Component, computed, DestroyRef, effect, inject, signal, untracked } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; @@ -12,7 +12,7 @@ import { DotMessagePipe } from '@dotcms/ui'; import { DotAiStore } from '../../../store/dot-ai.store'; -export type DotAiIndexCreateMode = 'add' | 'delete'; +type DotAiIndexCreateMode = 'add' | 'delete'; /** * The server stores whatever it is given — `bad name with spaces` is accepted verbatim, and @@ -28,12 +28,12 @@ const INDEX_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; * submit label flips with the toggle so the destructive mode never hides behind a neutral * word (FR-030) — the legacy screen did the same remap. * - * Unusually for this codebase, the dialog calls the store itself rather than closing with a - * form value for the list component to submit. It has to: a build is the one action here whose - * *failure* is a correction to the form — a malformed Lucene query — and resolving the dialog - * first threw that message onto the tab behind a modal that had already taken the query with - * it. Owning the submit is what lets the query survive its own error. The store is still data - * only; nothing here is dispatched from it. + * The dialog calls the store itself rather than closing with a form value for the list + * component to submit. It has to: a build is the one action here whose *failure* is a + * correction to the form — a malformed Lucene query — and resolving the dialog first threw + * that message onto the tab behind a modal that had already taken the query with it. Owning + * the submit is what lets the query survive its own error. See the documented exception under + * CRUD Patterns in `libs/portlets/CLAUDE.md`. */ @Component({ selector: 'dot-ai-index-create', @@ -53,6 +53,12 @@ export class DotAiIndexCreateComponent { protected readonly store = inject(DotAiStore); + /** + * A build is outstanding. Local rather than store state: it is this dialog's submit button + * that it disables, and nothing outside this component ever reads it. + */ + protected readonly $submitting = signal(false); + protected readonly $mode = signal('add'); protected readonly $indexName = signal(''); protected readonly $query = signal(''); @@ -84,11 +90,31 @@ export class DotAiIndexCreateComponent { }); constructor() { - // A finished build is the only thing that dismisses this dialog. The success message is - // left standing on the tab behind, next to the row it just created. + // A finished build is the only thing that dismisses this dialog; anything else is an + // outcome the form still has to show. The success message is left standing on the tab + // behind, next to the row it just created. effect(() => { - if (this.store.indexBuildNotice()?.kind === 'built') { - untracked(() => this.#dialogRef.close()); + const notice = this.store.indexBuildNotice(); + + if (!notice) { + return; + } + + untracked(() => { + this.$submitting.set(false); + + if (notice.kind === 'built') { + this.#dialogRef.close(); + } + }); + }); + + // On teardown, not in `cancel()`: PrimeNG's own header X and the Escape key call + // `DynamicDialogRef.close` directly, so a notice dismissed that way would be left set + // with nothing rendering it — the tab shows only `built`, and this dialog is gone. + inject(DestroyRef).onDestroy(() => { + if (this.store.indexBuildNotice()?.kind !== 'built') { + this.store.dismissBuildNotice(); } }); } @@ -118,7 +144,7 @@ export class DotAiIndexCreateComponent { !!this.$indexName().trim() && !!this.$query().trim() && !this.$nameError() && - !this.store.indexBuildInFlight() + !this.$submitting() ); protected submit(): void { @@ -138,6 +164,7 @@ export class DotAiIndexCreateComponent { return; } + this.$submitting.set(true); this.store.buildIndex({ indexName, query, @@ -150,7 +177,6 @@ export class DotAiIndexCreateComponent { } protected cancel(): void { - this.store.dismissBuildNotice(); this.#dialogRef.close(); } } 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 a3e63e599661..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 @@ -11,9 +11,10 @@ import { TooltipModule } from 'primeng/tooltip'; import { DotAiPromptInputComponent } from '@dotcms/ai-ui'; import { DotMessageService } from '@dotcms/data-access'; import { DotAIImageOrientation } from '@dotcms/dotcms-models'; -import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } 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. @@ -44,12 +45,11 @@ export default class DotAiImageComponent { readonly #messageService = inject(DotMessageService); - protected readonly emptyConfig: PrincipalConfiguration = { - title: this.#messageService.get('dotai.image.empty.title'), - subtitle: this.#messageService.get('dotai.image.empty.sub'), - icon: 'image', - iconStyle: 'material-symbols-rounded' - }; + 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.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-search/dot-ai-search.component.ts index 8d1f1ce3ab77..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 @@ -18,6 +18,7 @@ import { 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 @@ -49,27 +50,25 @@ export default class DotAiSearchComponent { readonly #messageService = inject(DotMessageService); - /** Resolved strings rather than keys: `dot-empty-container` renders `configuration` as-is. */ - protected readonly firstRunConfig: PrincipalConfiguration = { - title: this.#messageService.get('dotai.search.first-run.title'), - subtitle: this.#messageService.get('dotai.search.first-run.sub'), - icon: 'search', - iconStyle: 'material-symbols-rounded' - }; + protected readonly firstRunConfig = toEmptyStateConfig(this.#messageService, { + title: 'dotai.search.first-run.title', + subtitle: 'dotai.search.first-run.sub', + icon: 'search' + }); - protected readonly noResultsConfig: PrincipalConfiguration = { - title: this.#messageService.get('dotai.search.no-results.title'), - subtitle: this.#messageService.get('dotai.search.no-results.sub'), - icon: 'search_off', - iconStyle: 'material-symbols-rounded' - }; + 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(() => ({ - title: this.#messageService.get('dotai.search.index-missing'), - subtitle: this.store.searchMissingIndex() ?? '', - icon: 'database_off', - iconStyle: 'material-symbols-rounded' + ...toEmptyStateConfig(this.#messageService, { + title: 'dotai.search.index-missing', + icon: 'database_off' + }), + subtitle: this.store.searchMissingIndex() ?? '' })); /** 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.utils.ts b/core-web/libs/portlets/dot-ai/src/lib/utils/dot-ai-index.utils.ts index cce3abb0e426..3c00930f7363 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 @@ -27,7 +27,7 @@ export function toIndexOptions(indexes: DotAiIndex[]): { label: string; value: s * `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. */ -export function toPendingIndex(name: string): DotAiIndex { +function toPendingIndex(name: string): DotAiIndex { return { name, fragments: 0, contents: 0, tokenTotal: 0, tokensPerChunk: 0, contentTypes: [] }; } From e99b9aaf74cf1335c62279cf2a1504db5c08b38a Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 14 Sep 2026 14:12:24 -0600 Subject: [PATCH 05/11] fix(dotai): correct the empty, expiry and outcome-reporting edges found in review Five fixes from a review pass over the #37538 follow-ups, each one a case where the screen said something that was not true. Config Values blamed the filter for every empty table. `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 the pane said "No settings match this filter." with the filter box untouched, on the one screen that exists to be trusted when nothing else works (FR-048). A failed load now says so; the loading window renders nothing, because a brief blank pane says less than a wrong sentence does. The pending-build expiry only ran on success. It lived inside `applyIndexes`, which a failing request never reaches, so a build started just before the server went away held its seed forever: the poll ticked every five seconds for the life of the page, raising an error dialog each time. Pruning now runs wherever the request lands, the 403 and generic failure branches included. A build abandoned mid-flight failed into silence. Escape and the header X destroy the dialog before the outcome arrives, and the tab rendered `built` only -- so a success was announced and a failure disappeared. The tab now reports every outcome the dialog is no longer there to render. The handover fires on `onDestroy` rather than `onClose`, because `close()` emits `onClose` immediately and only then plays the leave animation: handing over there would flash an outcome onto the tab for the frames before the dialog's own teardown withdrew it. `deriveIndexStatuses` was a seed-survival predicate wearing a status function's name. The status the table reads comes from the `indexStatuses` computed; this one's output fed a filter, and every READY it derived was discarded. It is now `stillBuildingSeeds` and returns what it is actually asked for, so there is one place that decides what "building" means. `configHostInherited` claimed an inheritance that had not happened. ConfigService reports the System Host as the resolved host whenever a site has no secrets of its own, whether or not the System Host had any either, so an instance with nothing configured anywhere read "inherited from System Host" underneath the shell's "not configured" banner. Now gated on there being a configuration. Tests: 256 in the portlet (11 new), 927 in data-access. The two expiry regressions were confirmed red without the fix. Verified `openapi.yaml` is unchanged by a full `mvnw compile -pl :dotcms-core`. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/with-ai-indexes.feature.spec.ts | 46 +++++++++++++ .../store/features/with-ai-indexes.feature.ts | 42 ++++++++---- .../dot-ai-config-values.component.html | 10 +-- .../dot-ai-config-values.component.spec.ts | 57 +++++++++++++++- .../dot-ai-config-values.component.ts | 35 +++++++++- .../dot-ai-embeddings.component.html | 23 ++++++- .../dot-ai-embeddings.component.spec.ts | 50 +++++++++++++- .../dot-ai-embeddings.component.ts | 60 ++++++++++++----- .../src/lib/utils/dot-ai-index.utils.spec.ts | 65 +++++++++++++------ .../src/lib/utils/dot-ai-index.utils.ts | 35 +++++----- .../dotcms/ai/rest/CompletionsResource.java | 15 ++++- .../WEB-INF/messages/Language.properties | 2 + 12 files changed, 360 insertions(+), 80 deletions(-) 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 c9f73aea5a0f..7149cedd9426 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 @@ -263,6 +263,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 8ff7e5202893..9aeb3f08c8d4 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 @@ -19,7 +19,7 @@ import { DOT_AI_INDEX_STATUS, DotAiIndex, DotAiIndexStatus } from '@dotcms/dotcm import { DotAiPortletState } from '../../models/dot-ai-portlet.models'; import { - deriveIndexStatuses, + stillBuildingSeeds, toIndexOptions, toRetrievalIndexes, withPendingIndexes @@ -86,32 +86,41 @@ export function withAiIndexes() { const embeddingsService = inject(DotAiEmbeddingsService); const httpErrorManager = inject(DotHttpErrorManagerService); - const applyIndexes = (indexes: DotAiIndex[]) => { + /** + * 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(); - const listed = new Set(indexes.map((index) => index.name)); - // A seed survives while the server still has not listed its index and the wait - // is within the grace period, or once it is listed — from then on - // `deriveIndexStatuses` decides, off the fragment delta. - const liveSeeds = Object.fromEntries( + return Object.fromEntries( Object.entries(store.indexBuildSeeds()).filter( ([name, requestedAt]) => listed.has(name) || now - requestedAt < PENDING_SEED_TTL_MS ) ); + }; - const seeds = new Set(Object.keys(liveSeeds)); + const applyIndexes = (indexes: DotAiIndex[]) => { + const listed = new Set(indexes.map((index) => index.name)); + const live = liveSeeds(listed); + const seeds = new Set(Object.keys(live)); // 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); - const statuses = deriveIndexStatuses(merged, store.indexFragmentSnapshot(), seeds); // An index that has settled is no longer a candidate for the next poll. + const building = stillBuildingSeeds(merged, store.indexFragmentSnapshot(), seeds); const stillBuilding = Object.fromEntries( - Object.entries(liveSeeds).filter( - ([name]) => statuses[name] === DOT_AI_INDEX_STATUS.BUILDING - ) + Object.entries(live).filter(([name]) => building.has(name)) ); // Retrieval targets, for the picker's fallback below. Read off `merged` rather @@ -152,15 +161,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; diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.html b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.html index 7046403111d8..7ef6f67f438f 100644 --- a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.html +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-config-values/dot-ai-config-values.component.html @@ -24,14 +24,16 @@ [configuration]="redactionFailedConfig" [hideContactUsLink]="true" data-testid="dotai-config-redaction-failed" /> - } @else if (!$filteredRows().length) { + } @else if ($emptyConfig(); as empty) { + empty state nested in one cannot centre in the pane. Which state this is -- + no match, or no config to show -- is decided in the component; an empty table + is not one fact. --> - } @else { + } @else if ($filteredRows().length) { { 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({ @@ -44,6 +45,7 @@ describe('DotAiConfigValuesComponent', () => { vi.clearAllMocks(); storeMock.resolvedConfig.mockReturnValue(resolved()); storeMock.redactionFailed.mockReturnValue(false); + storeMock.configUnavailable.mockReturnValue(false); spectator = createComponent(); }); @@ -84,6 +86,59 @@ describe('DotAiConfigValuesComponent', () => { expect(spectator.query(byTestId('dotai-config-table'))).toBeFalsy(); }); + 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 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(empty()).toBeTruthy(); + expect(spectator.inject(DotMessageService, true).get).toHaveBeenCalledWith( + 'dotai.config.unavailable.title' + ); + }); + + 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(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(); 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 2954b106f2aa..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 @@ -8,7 +8,8 @@ import { DotCopyButtonComponent, DotEmptyContainerComponent, DotMessagePipe, - DotSearchInputComponent + DotSearchInputComponent, + PrincipalConfiguration } from '@dotcms/ui'; import { DotAiStore } from '../../store/dot-ai.store'; @@ -48,11 +49,17 @@ export default class DotAiConfigValuesComponent { icon: 'visibility_off' }); - protected readonly noMatchesConfig = toEmptyStateConfig(this.#messageService, { + 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. * @@ -90,6 +97,30 @@ export default class DotAiConfigValuesComponent { ); }); + /** + * Which empty state the pane owes the user, or null when it owes none. + * + * 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 $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 8d2977b6041a..8dff6db1eab2 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 @@ -24,14 +24,31 @@ - @if ($builtNotice(); as notice) { + + @if ($notice(); as notice) { - {{ 'dotai.embeddings.build.ok' | dm: [notice.detail ?? '', notice.indexName] }} + @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 ?? ''] + }} + } + } } 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 a869200ef125..b0ff539be491 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 @@ -30,6 +30,7 @@ const index = (overrides: Partial = {}): DotAiIndex => ({ describe('DotAiEmbeddingsComponent', () => { let spectator: Spectator; let onClose: Subject; + let onDialogDestroy: Subject; let dialogService: DialogService; let confirmSpy: MockInstance; @@ -69,9 +70,11 @@ describe('DotAiEmbeddingsComponent', () => { storeMock.indexesForbidden.mockReturnValue(false); storeMock.filteredIndexes.mockReturnValue([index()]); onClose = new Subject(); + onDialogDestroy = new Subject(); + storeMock.indexBuildNotice.mockReturnValue(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'); }); @@ -156,6 +159,51 @@ describe('DotAiEmbeddingsComponent', () => { expect(storeMock.dismissBuildNotice).toHaveBeenCalled(); }); + 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 + // on the tab behind the modal, which is the report nobody could act on. + clickButton('dotai-embeddings-new-index'); + storeMock.indexBuildNotice.mockReturnValue({ + kind: 'failed', + indexName: 'blogs', + detail: 'Cannot parse query' + }); + spectator.detectChanges(); + + expect(spectator.query(byTestId('dotai-embeddings-build-notice'))).toBeFalsy(); + }); + + 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 `built` only, so a success was announced and a failure + // disappeared. + clickButton('dotai-embeddings-new-index'); + onDialogDestroy.next(undefined); + storeMock.indexBuildNotice.mockReturnValue({ + kind: 'failed', + indexName: 'blogs', + detail: 'Cannot parse query' + }); + spectator.detectChanges(); + + expect(spectator.query(byTestId('dotai-embeddings-build-notice'))).toContainText( + 'dotai.embeddings.build.failed' + ); + }); + + it('should hand back only when the dialog is really gone, not when it starts closing', () => { + // `close()` fires onClose and only then plays the leave animation, so the dialog -- + // and the teardown hook that withdraws an outcome it already showed -- is still + // up. Handing over there would flash that outcome onto the tab for those frames. + clickButton('dotai-embeddings-new-index'); + onClose.next(undefined); + storeMock.indexBuildNotice.mockReturnValue({ kind: 'failed', indexName: 'blogs' }); + spectator.detectChanges(); + + expect(spectator.query(byTestId('dotai-embeddings-build-notice'))).toBeFalsy(); + }); + 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. 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 288396609c92..a2f59db57dc4 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,4 +1,4 @@ -import { Component, computed, inject } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; @@ -9,6 +9,8 @@ import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; 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 { @@ -74,16 +76,28 @@ export default class DotAiEmbeddingsComponent { protected readonly statuses = DOT_AI_INDEX_STATUS; + /** Whether the create dialog is up, and so is the one rendering build outcomes. */ + readonly #createDialogOpen = signal(false); + /** * The build outcome this tab owns. * - * Only a success reaches here. The two outcomes that need the query fixed — nothing - * matched, and a query the server rejected — stay inside the create dialog, next to the - * field that produced them. + * While the dialog is up it owns everything the user has to act on — nothing matched, and + * a query the server rejected — because those are corrections to a field it is still + * holding; only the success reaches here, next to the row it just created. + * + * Once the dialog has gone, this tab is the only thing left that can report anything, so + * it reports all of it. Without that, a build dismissed with Escape or the header X while + * still in flight failed into silence: the notice arrived after the dialog was destroyed, + * and a success in the same situation was announced while a failure was not. */ - protected readonly $builtNotice = computed(() => { + protected readonly $notice = computed(() => { const notice = this.store.indexBuildNotice(); + if (!this.#createDialogOpen()) { + return notice; + } + return notice?.kind === 'built' ? notice : null; }); @@ -118,22 +132,34 @@ export default class DotAiEmbeddingsComponent { }; /** - * Opens the build dialog and leaves it to it. + * Opens the build dialog and leaves the submit to it. * - * No `onClose` handling any more: the dialog submits to the store itself so that a rejected - * Lucene query can be corrected in the form that produced it, rather than being reported - * onto this tab after the modal has closed over the query. + * `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.store.dismissBuildNotice(); - - this.#dialogService.open(DotAiIndexCreateComponent, { - header: this.#messageService.get('dotai.index.create.header'), - width: '700px', - closable: true, - closeOnEscape: true, - draggable: false - }); + this.#createDialogOpen.set(true); + + // `onDestroy`, not `onClose`: `close()` fires `onClose` immediately and only then + // plays the leave animation, so the dialog component — and the `DestroyRef` hook that + // clears an outcome it has already shown — lives on for the length of it. Handing over + // at `onClose` would render that outcome on the tab for those frames before the + // dialog's own teardown withdrew it. `onDestroy` fires once, on every close path + // (cancel, success, Escape, the header X), after the dialog has let go. + this.#dialogService + .open(DotAiIndexCreateComponent, { + header: this.#messageService.get('dotai.index.create.header'), + width: '700px', + closable: true, + closeOnEscape: true, + draggable: false + }) + .onDestroy.pipe(take(1)) + .subscribe(() => this.#createDialogOpen.set(false)); } protected confirmDeleteIndex(index: DotAiIndex): void { 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 3c00930f7363..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 @@ -44,31 +44,34 @@ export function withPendingIndexes(indexes: DotAiIndex[], buildSeeds: Set, 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/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index f53366dffefe..3684090df9f8 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -193,17 +193,26 @@ public final Response getConfig(@Context final HttpServletRequest request, final AppConfig appConfig = ConfigService.INSTANCE.config(host); final Map map = new HashMap<>(); + final String providerConfig = appConfig.getProviderConfig(); + final boolean configured = StringUtils.isNotBlank(providerConfig); + // The site the configuration is being read for, and whether it actually came from that // site. ConfigService falls back to the System Host's secrets when the site has none of // its own, so the two hostnames differing is what "inherited" means here. Reported as // separate fields rather than one concatenated English string so the client can label // and translate it. + // + // Gated on there being a configuration at all: ConfigService reports the System Host as + // the resolved host whenever the site has no secrets of its own, whether or not the + // System Host had any either. Without this, an instance where nothing is configured + // anywhere claims to have inherited settings it never found. final String requestedHost = host.getHostname(); map.put(AiKeys.CONFIG_HOST, requestedHost); - map.put(AiKeys.CONFIG_HOST_INHERITED, !requestedHost.equalsIgnoreCase(appConfig.getHost())); + map.put( + AiKeys.CONFIG_HOST_INHERITED, + configured && !requestedHost.equalsIgnoreCase(appConfig.getHost())); - final String providerConfig = appConfig.getProviderConfig(); - if (StringUtils.isNotBlank(providerConfig)) { + if (configured) { map.put(AppKeys.PROVIDER_CONFIG.key, redactCredentials(providerConfig)); } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 5b134c34a229..febb8dab1c6f 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -8767,6 +8767,8 @@ 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 From 2f11bdebc7a0915a68e88ad51433ed1cf32e9f3e Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 14 Sep 2026 14:33:56 -0600 Subject: [PATCH 06/11] feat(dotai): split build from remove, and make every index action report (#37543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Embeddings tab packed two opposite actions into one dialog and none of the destructive ones said what they had done. **Split.** "Build an index" only builds. The Mode toggle made the form reshape under the user — Fields and Velocity template vanished on switching — and put a destructive action one click from a create action, sharing its submit button. **Removing content moved onto the index row.** It always targets exactly one index, yet the index name was a free-text field: a typo removed nothing and a near-miss hit a different index, both silently. The row's trash icon is now a menu — Remove content…, Delete index — and the dialog opens titled for the row, with the index as context rather than an input. **It explains itself.** Three facts the old delete mode stated nowhere: it removes embeddings and not content; the query matches content as it is now, so content that has changed and no longer matches is left behind and archived or deleted content cannot be reached at all; and unlike building, it is not limited to live content — `embed()` appends `+live:true` to your query and `deleteByQuery` appends nothing. **Every action reports.** Rebuild DB, Delete index and Remove content were completely silent — three of the four returned a count the store dropped on the floor. `indexBuildNotice` becomes `indexNotice`, one channel carrying the operation and its outcome, because where an outcome belongs is decided by the outcome rather than the action: anything the user has to act on stays in the dialog holding the field that caused it, everything else is a toast. A removal that matched nothing is `empty`, not success — the server answers 200 with `deleted: 0`. **Toasts, as the rest of the admin does.** The tab no longer carries a standing banner for a successful build; `dot-locales` and `dot-experiments` host their own `p-toast` the same way. Closes #37543 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/models/dot-ai-portlet.models.ts | 33 +++- .../with-ai-embeddings.feature.spec.ts | 76 ++++++-- .../features/with-ai-embeddings.feature.ts | 111 ++++++++---- .../dot-ai-embeddings.component.html | 51 ++---- .../dot-ai-embeddings.component.spec.ts | 135 +++++++++----- .../dot-ai-embeddings.component.ts | 143 ++++++++++++--- .../dot-ai-index-create.component.html | 80 +++------ .../dot-ai-index-create.component.spec.ts | 68 ++++---- .../dot-ai-index-create.component.ts | 66 ++----- ...dot-ai-index-remove-content.component.html | 57 ++++++ ...-ai-index-remove-content.component.spec.ts | 165 ++++++++++++++++++ .../dot-ai-index-remove-content.component.ts | 93 ++++++++++ .../WEB-INF/messages/Language.properties | 23 ++- 13 files changed, 804 insertions(+), 297 deletions(-) create mode 100644 core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-remove-content/dot-ai-index-remove-content.component.html create mode 100644 core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-remove-content/dot-ai-index-remove-content.component.spec.ts create mode 100644 core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-remove-content/dot-ai-index-remove-content.component.ts 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 33e0d52961df..13b4de85f360 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 @@ -46,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; } @@ -157,7 +172,7 @@ export interface DotAiPortletState { // embeddings screen (client-side filters — the whole dataset arrives in one response) indexFilter: string; - indexBuildNotice: DotAiIndexBuildNotice | null; + indexNotice: DotAiIndexNotice | null; // image image: DotAiGeneratedImage | null; @@ -231,7 +246,7 @@ export const DOT_AI_INITIAL_STATE: DotAiPortletState = { chatStreaming: false, indexFilter: '', - indexBuildNotice: null, + indexNotice: null, image: null, imageGenerating: false, 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..3ed99340ac82 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,23 +135,53 @@ 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)', () => { @@ -173,6 +212,11 @@ describe('withAiEmbeddings', () => { 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 +246,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..6f4086b84049 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 @@ -7,10 +7,14 @@ import { computed, inject, Signal } from '@angular/core'; import { catchError, exhaustMap, 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,6 +23,10 @@ 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) @@ -52,7 +60,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 +72,30 @@ 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 + ) => { if (error?.status === 403) { patchState(store, { indexesForbidden: true }); return EMPTY; } - httpErrorManager.handle(error); + notify({ operation, outcome: 'failed', indexName, detail: extractReason(error) }); return EMPTY; }; @@ -88,13 +105,13 @@ export function withAiEmbeddings() { patchState(store, { indexFilter }); }, - dismissBuildNotice(): void { - patchState(store, { indexBuildNotice: null }); + dismissIndexNotice(): void { + patchState(store, { indexNotice: null }); }, buildIndex: rxMethod( pipe( - tap(() => patchState(store, { indexBuildNotice: null })), + tap(() => patchState(store, { indexNotice: null })), // exhaustMap: a double submit must not build twice. exhaustMap((form) => embeddingsService.buildIndex(form).pipe( @@ -104,8 +121,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 +132,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 +151,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( + tap(() => patchState(store, { indexNotice: null })), exhaustMap(({ 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 +188,18 @@ 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}` + }); + store.loadIndexes(); + }), + catchError((error: HttpErrorResponse) => + report(DOT_AI_INDEX_OPERATION.DELETE_INDEX, indexName, error) + ) ) ) ) @@ -175,8 +209,17 @@ export function withAiEmbeddings() { pipe( exhaustMap(() => embeddingsService.rebuildEmbeddingsDb().pipe( - tap(() => store.loadIndexes()), - catchError(fail) + tap(() => { + notify({ + operation: DOT_AI_INDEX_OPERATION.REBUILD_DB, + outcome: 'ok', + indexName: '' + }); + store.loadIndexes(); + }), + catchError((error: HttpErrorResponse) => + report(DOT_AI_INDEX_OPERATION.REBUILD_DB, '', error) + ) ) ) ) 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 8dff6db1eab2..37e4545554b3 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 @@ -24,34 +24,6 @@ - - @if ($notice(); 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()) { + A menu rather than a bare trash icon: removing content from + an index belongs on the index it acts on, not in a mode of + the build dialog where the name had to be typed. --> + [ariaLabel]="'dotai.embeddings.row.actions' | dm" + (onClick)="openRowMenu(index); rowMenu.toggle($event)" + data-testid="dotai-embeddings-row-actions"> @@ -167,6 +139,15 @@
    + + + + + + 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 b0ff539be491..13a46d9a7de7 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,7 @@ import { import { Subject } from 'rxjs'; import { Mock, MockInstance, vi } from 'vitest'; -import { ConfirmationService } from 'primeng/api'; +import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; @@ -15,6 +15,7 @@ import { DotAiIndex } from '@dotcms/dotcms-models'; import DotAiEmbeddingsComponent from './dot-ai-embeddings.component'; +import { DOT_AI_INDEX_OPERATION } from '../../models/dot-ai-portlet.models'; import { DotAiStore } from '../../store/dot-ai.store'; const index = (overrides: Partial = {}): DotAiIndex => ({ @@ -33,6 +34,7 @@ describe('DotAiEmbeddingsComponent', () => { let onDialogDestroy: Subject; let dialogService: DialogService; let confirmSpy: MockInstance; + let toastSpy: MockInstance; const storeMock = { indexes: vi.fn().mockReturnValue([index()]), @@ -42,9 +44,9 @@ describe('DotAiEmbeddingsComponent', () => { isConfigured: vi.fn().mockReturnValue(true), setIndexFilter: vi.fn(), buildIndex: vi.fn(), - indexBuildNotice: vi.fn().mockReturnValue(null), - dismissBuildNotice: vi.fn(), - deleteFromIndex: vi.fn(), + indexNotice: vi.fn().mockReturnValue(null), + dismissIndexNotice: vi.fn(), + removeFromIndex: vi.fn(), deleteIndex: vi.fn(), rebuildEmbeddingsDb: vi.fn() }; @@ -57,7 +59,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`. @@ -71,11 +76,12 @@ describe('DotAiEmbeddingsComponent', () => { storeMock.filteredIndexes.mockReturnValue([index()]); onClose = new Subject(); onDialogDestroy = new Subject(); - storeMock.indexBuildNotice.mockReturnValue(null); + storeMock.indexNotice.mockReturnValue(null); spectator = createComponent(); dialogService = spectator.inject(DialogService, true); (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) => @@ -83,6 +89,23 @@ describe('DotAiEmbeddingsComponent', () => { spectator.query(byTestId(testId))?.querySelector('button') as HTMLButtonElement ); + /** + * Runs a row-menu item by its label key. + * + * The overlay itself is PrimeNG's and does not render in a shallow test, so this opens the + * menu for the row and invokes the command the component put on the model — which is the + * component's half of the contract. + */ + const openRowMenuItem = (labelKey: string) => { + clickButton('dotai-embeddings-row-actions'); + const item = (spectator.component as unknown as { $rowActions: () => MenuItem[] }) + .$rowActions() + .find((action) => action.label === labelKey); + + item?.command?.({} as never); + spectator.detectChanges(); + }; + it('should render the table with the index rows', () => { expect(spectator.query(byTestId('dotai-embeddings-table'))).toBeTruthy(); expect(spectator.queryAll(byTestId('dotai-embeddings-row'))).toHaveLength(1); @@ -105,7 +128,7 @@ describe('DotAiEmbeddingsComponent', () => { const acceptConfirmation = () => confirmSpy.mock.calls[0][0].accept(); it('should confirm before deleting an index (FR-031)', () => { - clickButton('dotai-embeddings-delete'); + openRowMenuItem('dotai.embeddings.delete'); expect(confirmSpy).toHaveBeenCalled(); // Nothing happens until the confirmation is accepted. @@ -115,7 +138,7 @@ describe('DotAiEmbeddingsComponent', () => { it('should delete once the confirmation is accepted (FR-031)', () => { // Asserting only the guard proves the dialog opens, not that accepting it does the // thing — a broken `accept` wiring would pass that test alone. - clickButton('dotai-embeddings-delete'); + openRowMenuItem('dotai.embeddings.delete'); acceptConfirmation(); @@ -156,52 +179,54 @@ describe('DotAiEmbeddingsComponent', () => { // with the last one's error. clickButton('dotai-embeddings-new-index'); - expect(storeMock.dismissBuildNotice).toHaveBeenCalled(); + expect(storeMock.dismissIndexNotice).toHaveBeenCalled(); }); 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 - // on the tab behind the modal, which is the report nobody could act on. + // as a toast over a modal, which is the report nobody could act on. clickButton('dotai-embeddings-new-index'); - storeMock.indexBuildNotice.mockReturnValue({ - kind: 'failed', + storeMock.indexNotice.mockReturnValue({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', indexName: 'blogs', detail: 'Cannot parse query' }); spectator.detectChanges(); - expect(spectator.query(byTestId('dotai-embeddings-build-notice'))).toBeFalsy(); + expect(toastSpy).not.toHaveBeenCalled(); }); 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 `built` only, so a success was announced and a failure - // disappeared. + // it. It used to render the success only, so a failure disappeared. clickButton('dotai-embeddings-new-index'); onDialogDestroy.next(undefined); - storeMock.indexBuildNotice.mockReturnValue({ - kind: 'failed', + storeMock.indexNotice.mockReturnValue({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', indexName: 'blogs', detail: 'Cannot parse query' }); spectator.detectChanges(); - expect(spectator.query(byTestId('dotai-embeddings-build-notice'))).toContainText( - 'dotai.embeddings.build.failed' - ); + expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({ severity: 'error' })); }); it('should hand back only when the dialog is really gone, not when it starts closing', () => { - // `close()` fires onClose and only then plays the leave animation, so the dialog -- - // and the teardown hook that withdraws an outcome it already showed -- is still - // up. Handing over there would flash that outcome onto the tab for those frames. + // `close()` fires onClose and only then plays the leave animation, so the dialog is + // still up and still owns what the user has to act on. clickButton('dotai-embeddings-new-index'); onClose.next(undefined); - storeMock.indexBuildNotice.mockReturnValue({ kind: 'failed', indexName: 'blogs' }); + storeMock.indexNotice.mockReturnValue({ + operation: DOT_AI_INDEX_OPERATION.BUILD, + outcome: 'failed', + indexName: 'blogs' + }); spectator.detectChanges(); - expect(spectator.query(byTestId('dotai-embeddings-build-notice'))).toBeFalsy(); + expect(toastSpy).not.toHaveBeenCalled(); }); it('should leave the build to the dialog rather than submitting on close', () => { @@ -212,7 +237,7 @@ describe('DotAiEmbeddingsComponent', () => { onClose.next(undefined); expect(storeMock.buildIndex).not.toHaveBeenCalled(); - expect(storeMock.deleteFromIndex).not.toHaveBeenCalled(); + expect(storeMock.removeFromIndex).not.toHaveBeenCalled(); }); }); @@ -220,7 +245,7 @@ describe('DotAiEmbeddingsComponent', () => { const config = () => confirmSpy.mock.calls[0][0]; it('should give the delete confirmation a primary accept', () => { - clickButton('dotai-embeddings-delete'); + openRowMenuItem('dotai.embeddings.delete'); // Absent, not 'p-button-primary': the theme defines no such class — `.p-button` // carries the primary styling itself — so asking for one renders nothing. @@ -228,7 +253,7 @@ describe('DotAiEmbeddingsComponent', () => { }); it('should give the delete confirmation an outlined cancel', () => { - clickButton('dotai-embeddings-delete'); + openRowMenuItem('dotai.embeddings.delete'); expect(config().rejectButtonStyleClass).toBe('p-button-outlined'); }); @@ -251,22 +276,48 @@ describe('DotAiEmbeddingsComponent', () => { }); }); - describe('the per-row delete action', () => { - const deleteButton = () => - spectator.query(byTestId('dotai-embeddings-delete'))?.querySelector('button'); + describe('the per-row action menu', () => { + const trigger = () => + spectator.query(byTestId('dotai-embeddings-row-actions'))?.querySelector('button'); + + it('should offer removing content as well as deleting the index', () => { + // Removing content belongs on the index it acts on. It used to be a mode of the + // build dialog, where the index name was a free-text field: a typo removed nothing + // and a near-miss hit a different index, both silently. + clickButton('dotai-embeddings-row-actions'); + + const labels = (spectator.component as unknown as { $rowActions: () => MenuItem[] }) + .$rowActions() + .map((action) => action.label); + + expect(labels).toEqual(['dotai.embeddings.remove-content', 'dotai.embeddings.delete']); + }); + + it('should open the remove-content dialog with the row as its index', () => { + openRowMenuItem('dotai.embeddings.remove-content'); + + expect(dialogService.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + width: '700px', + closable: true, + closeOnEscape: true, + data: { indexName: 'blogs' } + }) + ); + }); 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'); + // p-button-danger; the row action only opens a menu. + 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'); @@ -274,22 +325,12 @@ 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+/) ?? []; - - expect(classes).toContain('p-button-text'); - expect(classes).toContain('p-button-rounded'); - expect(classes).not.toContain('p-button-outlined'); - }); - 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. 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 a2f59db57dc4..7589cbf71c7f 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,12 +1,13 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { ConfirmationService } from 'primeng/api'; +import { ConfirmationService, MenuItem, 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 { MenuModule } from 'primeng/menu'; 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'; @@ -21,7 +22,9 @@ import { } from '@dotcms/ui'; 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 } from '../../models/dot-ai-portlet.models'; import { DotAiStore } from '../../store/dot-ai.store'; import { toEmptyStateConfig } from '../../utils/dot-ai-empty-state.utils'; @@ -55,15 +58,16 @@ const CONFIRM_BUTTONS = { imports: [ DotEmptyContainerComponent, ToolbarModule, - MessageModule, + MenuModule, 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' } }) @@ -71,36 +75,65 @@ 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; - /** Whether the create dialog is up, and so is the one rendering build outcomes. */ - readonly #createDialogOpen = signal(false); + /** Whether a dialog is up, and so is the one rendering what the user must act on. */ + readonly #dialogOpen = signal(false); - /** - * The build outcome this tab owns. - * - * While the dialog is up it owns everything the user has to act on — nothing matched, and - * a query the server rejected — because those are corrections to a field it is still - * holding; only the success reaches here, next to the row it just created. - * - * Once the dialog has gone, this tab is the only thing left that can report anything, so - * it reports all of it. Without that, a build dismissed with Escape or the header X while - * still in flight failed into silence: the notice arrived after the dialog was destroyed, - * and a success in the same situation was announced while a failure was not. - */ - protected readonly $notice = computed(() => { - const notice = this.store.indexBuildNotice(); + /** The index whose row menu is open; its actions are built from this. */ + readonly #menuIndex = signal(null); - if (!this.#createDialogOpen()) { - return notice; + /** Identity guard: each operation makes a fresh notice, so this toasts each one once. */ + #toasted: DotAiIndexNotice | null = null; + + protected readonly $rowActions = computed(() => { + const index = this.#menuIndex(); + + if (!index) { + return []; } - return notice?.kind === 'built' ? notice : null; + return [ + { + label: this.#messageService.get('dotai.embeddings.remove-content'), + icon: 'pi pi-eraser', + command: () => this.openRemoveContentDialog(index) + }, + { + label: this.#messageService.get('dotai.embeddings.delete'), + icon: 'pi pi-trash', + styleClass: 'p-error', + command: () => this.confirmDeleteIndex(index) + } + ]; }); + 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 dialogOpen = this.#dialogOpen(); + + if (!notice || notice === this.#toasted) { + return; + } + + if (dialogOpen && notice.outcome !== 'ok') { + 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 @@ -141,8 +174,8 @@ export default class DotAiEmbeddingsComponent { * reporting. */ protected openCreateDialog(): void { - this.store.dismissBuildNotice(); - this.#createDialogOpen.set(true); + this.store.dismissIndexNotice(); + this.#dialogOpen.set(true); // `onDestroy`, not `onClose`: `close()` fires `onClose` immediately and only then // plays the leave animation, so the dialog component — and the `DestroyRef` hook that @@ -159,7 +192,63 @@ export default class DotAiEmbeddingsComponent { draggable: false }) .onDestroy.pipe(take(1)) - .subscribe(() => this.#createDialogOpen.set(false)); + .subscribe(() => this.#dialogOpen.set(false)); + } + + /** Opens the row menu for one index. One menu instance serves every row. */ + protected openRowMenu(index: DotAiIndex): void { + this.#menuIndex.set(index); + } + + protected openRemoveContentDialog(index: DotAiIndex): void { + this.store.dismissIndexNotice(); + this.#dialogOpen.set(true); + + this.#dialogService + .open(DotAiIndexRemoveContentComponent, { + header: this.#messageService.get( + 'dotai.embeddings.remove-content.header', + index.name + ), + width: '700px', + closable: true, + closeOnEscape: true, + draggable: false, + data: { indexName: index.name } + }) + // Same reason as the build dialog: `onDestroy` fires once, on every close path, + // after the dialog has let go — `onClose` fires before the leave animation. + .onDestroy.pipe(take(1)) + .subscribe(() => this.#dialogOpen.set(false)); + } + + /** 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.outcome === 'ok' ? 'success' : notice.outcome === 'empty' ? 'warn' : 'error'; + + const key = `dotai.embeddings.${ + { + [DOT_AI_INDEX_OPERATION.BUILD]: 'build', + [DOT_AI_INDEX_OPERATION.REMOVE_CONTENT]: 'remove-content', + [DOT_AI_INDEX_OPERATION.DELETE_INDEX]: 'delete', + [DOT_AI_INDEX_OPERATION.REBUILD_DB]: 'rebuild' + }[notice.operation] + }.${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 a generic mapper cannot exist. + detail: this.#messageService.get(key, 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 5ec75f75b27c..3acbe8c332b1 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 }} -
    - } +
    + + + {{ 'dotai.index.create.template.hint' | dm }} +
    + + {{ 'dotai.embeddings.remove-content.explainer' | dm: [indexName] }} + + +
    + + + + {{ 'dotai.embeddings.remove-content.query.hint' | dm }} + +
    + + @if ($notice(); as notice) { + + @if (notice.outcome === 'empty') { + {{ 'dotai.embeddings.remove-content.empty' | dm: [notice.indexName] }} + } @else { + {{ + 'dotai.embeddings.remove-content.failed' + | dm: [notice.indexName, notice.detail ?? ''] + }} + } + + } + +
    + + +
    + diff --git a/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-remove-content/dot-ai-index-remove-content.component.spec.ts b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-remove-content/dot-ai-index-remove-content.component.spec.ts new file mode 100644 index 000000000000..98d8efb9f289 --- /dev/null +++ b/core-web/libs/portlets/dot-ai/src/lib/tabs/dot-ai-embeddings/dot-ai-index-remove-content/dot-ai-index-remove-content.component.spec.ts @@ -0,0 +1,165 @@ +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator +} from '@openng/spectator/vitest'; + +import { signal } from '@angular/core'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotAiIndexRemoveContentComponent } from './dot-ai-index-remove-content.component'; + +import { DOT_AI_INDEX_OPERATION, DotAiIndexNotice } from '../../../models/dot-ai-portlet.models'; +import { DotAiStore } from '../../../store/dot-ai.store'; + +describe('DotAiIndexRemoveContentComponent', () => { + let spectator: Spectator; + let dialogRef: DynamicDialogRef; + let store: { + indexNotice: ReturnType>; + removeFromIndex: ReturnType; + dismissIndexNotice: ReturnType; + }; + + const createComponent = createComponentFactory({ + component: DotAiIndexRemoveContentComponent, + providers: [ + mockProvider(DynamicDialogRef), + mockProvider(DotMessageService, { get: (key: string) => key }), + { provide: DynamicDialogConfig, useValue: { data: { indexName: 'blogs' } } } + ], + shallow: true + }); + + beforeEach(() => { + store = { + indexNotice: signal(null), + removeFromIndex: vi.fn(), + dismissIndexNotice: vi.fn() + }; + + spectator = createComponent({ providers: [{ provide: DotAiStore, useValue: store }] }); + dialogRef = spectator.inject(DynamicDialogRef); + }); + + /** PrimeNG puts its click handler on the inner