diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 4bec56b55..2f23096b9 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -1184,7 +1184,55 @@ "BUILD_LIST_REMOVE_MSG": "Are you sure you wish to cancel the build job for {{ driver }} from {{ repo }}?", "BUILD_LIST_REMOVE_LOADING": "Cancelling build job...", "BUILD_LIST_REMOVE_ERROR": "Failed to cancel build job. Error: {{ error }}", - "BUILD_LIST_REMOVE_SUCCESS": "Successfully cancelled build job." + "BUILD_LIST_REMOVE_SUCCESS": "Successfully cancelled build job.", + "AI_PROVIDERS_EMPTY": "No AI provider is configured. Signage image generation stays hidden until one is added.", + "AI_PROVIDERS_HEADER": "Signage AI providers", + "AI_PROVIDERS_LOAD_FAILED": "The AI providers could not be loaded.", + "AI_PROVIDERS_LOADING": "Loading providers...", + "AI_PROVIDERS_SCOPE": "Providers and usage for this domain. A provider belonging to another domain is not shown here.", + "AI_PROVIDER_ADD": "Add provider", + "AI_PROVIDER_API_KEY": "API key", + "AI_PROVIDER_API_VERSION": "API version", + "AI_PROVIDER_CLIENT_EMAIL": "Service account email", + "AI_PROVIDER_CREDENTIALS_REQUIRED": "Credentials are needed to add a provider", + "AI_PROVIDER_DEPLOYMENT": "Deployment", + "AI_PROVIDER_DOMAIN_QUOTA": "Images per domain per month", + "AI_PROVIDER_EDIT": "Edit AI provider", + "AI_PROVIDER_ENABLED": "Enabled", + "AI_PROVIDER_ENDPOINT": "Endpoint (optional)", + "AI_PROVIDER_IS_DEFAULT": "Default", + "AI_PROVIDER_LOCATION": "Region (Google Vertex only)", + "AI_PROVIDER_LOCATION_REQUIRED": "A region is required for Google Vertex", + "AI_PROVIDER_MODEL": "Default model", + "AI_PROVIDER_NAME": "Name", + "AI_PROVIDER_NAME_REQUIRED": "A name is required", + "AI_PROVIDER_NEW": "New AI provider", + "AI_PROVIDER_PRIVATE_KEY": "Service account private key", + "AI_PROVIDER_PROJECT": "Google project id", + "AI_PROVIDER_REMOVE": "Remove provider", + "AI_PROVIDER_REMOVE_FAILED": "That provider could not be removed", + "AI_PROVIDER_REMOVE_MSG": "Remove {{ name }}? Artwork already generated is kept, but no new images can be made until another provider is configured.", + "AI_PROVIDER_REMOVE_TITLE": "Remove AI provider", + "AI_PROVIDER_REMOVING": "Removing provider...", + "AI_PROVIDER_SAVE_ERROR": "The provider could not be saved", + "AI_PROVIDER_SAVE_SUCCESS": "Provider saved", + "AI_PROVIDER_SAVING": "Saving provider...", + "AI_PROVIDER_TEST": "Test credentials", + "AI_PROVIDER_TESTING": "Asking the vendor for a test image...", + "AI_PROVIDER_TEST_FAILED": "The vendor did not answer", + "AI_PROVIDER_TEST_MSG": "This asks {{ name }} for one small image, which the vendor bills for. The image is discarded.", + "AI_PROVIDER_TEST_OK": "Working, answered in {{ ms }} ms", + "AI_PROVIDER_TEST_TITLE": "Test this provider?", + "AI_PROVIDER_USER_QUOTA": "Images per person per day", + "AI_PROVIDER_VENDOR": "Vendor", + "AI_USAGE_ASKED": "Images asked for", + "AI_USAGE_EMPTY": "Nothing generated yet.", + "AI_USAGE_HEADER": "Usage, last 30 days", + "AI_USAGE_HINT": "What this domain has asked for, per vendor and model.", + "AI_USAGE_JOBS": "Requests", + "AI_USAGE_LOAD_FAILED": "AI usage could not be loaded.", + "AI_USAGE_MADE": "Images made", + "TAB_SIGNAGE_AI": "Signage AI" }, "CASCADE": { "RESOLVING": "Working out what would be removed...", diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 1c06048d3..2e36b3847 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -175,6 +175,11 @@ export class PlaceComponent extends AsyncHandler implements OnInit { name: i18n('ADMIN.TAB_UPLOADS_LIBRARY'), icon: { value: 'photo_album' }, }, + { + id: 'signage-ai', + name: i18n('ADMIN.TAB_SIGNAGE_AI'), + icon: { value: 'auto_awesome' }, + }, { id: 'signage-plugins', name: i18n('ADMIN.TAB_SIGNAGE_PLUGINS'), diff --git a/src/app/admin/admin.routes.ts b/src/app/admin/admin.routes.ts index bec795362..b2f8ab05b 100644 --- a/src/app/admin/admin.routes.ts +++ b/src/app/admin/admin.routes.ts @@ -107,6 +107,13 @@ export const ROUTES: Routes = [ (m) => m.StorageComponent, ), }, + { + path: 'signage-ai', + loadComponent: () => + import('./signage-ai/signage-ai.component').then( + (m) => m.SignageAIComponent, + ), + }, { path: 'upload-library', loadComponent: () => diff --git a/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts new file mode 100644 index 000000000..f8b1ba834 --- /dev/null +++ b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts @@ -0,0 +1,323 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { form, FormField, required, submit } from '@angular/forms/signals'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { i18n } from '../../common/locale.service'; +import { notifyError, notifySuccess } from '../../common/notifications'; +import { FullscreenModalShellComponent } from '../../ui/fullscreen-modal-shell.component'; +import { SettingsToggleComponent } from '../../ui/settings-toggle.component'; +import { TranslatePipe } from '../../ui/translate.pipe'; +import { + saveSignageAIProvider, + SignageAIProvider, + SignageAIProviderType, +} from './signage-ai.fn'; + +type SignageAICredentialField = + | 'api_key' + | 'deployment' + | 'api_version' + | 'project_id' + | 'client_email' + | 'private_key'; + +const SIGNAGE_AI_CREDENTIAL_FIELDS: Record< + SignageAIProviderType, + readonly SignageAICredentialField[] +> = { + OPENAI: ['api_key'], + AZURE_OPENAI: ['api_key', 'deployment', 'api_version'], + GOOGLE_VERTEX: ['project_id', 'client_email', 'private_key'], +}; + +@Component({ + selector: 'app-signage-ai-provider-modal', + template: ` + +
+
+ + {{ + 'ADMIN.AI_PROVIDER_NAME' | translate + }} + + {{ + 'ADMIN.AI_PROVIDER_NAME_REQUIRED' | translate + }} + +
+
+ + {{ + 'ADMIN.AI_PROVIDER_VENDOR' | translate + }} + + OpenAI + Azure OpenAI + Google (Vertex) + + +
+ + @if (is_google()) { +
+ + {{ + 'ADMIN.AI_PROVIDER_PROJECT' | translate + }} + + +
+
+ + {{ + 'ADMIN.AI_PROVIDER_CLIENT_EMAIL' | translate + }} + + +
+
+ + {{ + 'ADMIN.AI_PROVIDER_PRIVATE_KEY' | translate + }} + + +
+
+ + {{ + 'ADMIN.AI_PROVIDER_LOCATION' | translate + }} + + {{ + 'ADMIN.AI_PROVIDER_LOCATION_REQUIRED' + | translate + }} + +
+ } @else { +
+ + {{ + 'ADMIN.AI_PROVIDER_API_KEY' | translate + }} + + +
+ @if (is_azure()) { +
+
+ + {{ + 'ADMIN.AI_PROVIDER_DEPLOYMENT' + | translate + }} + + +
+
+ + {{ + 'ADMIN.AI_PROVIDER_API_VERSION' + | translate + }} + + +
+
+ } + } + +
+ + {{ + 'ADMIN.AI_PROVIDER_ENDPOINT' | translate + }} + + +
+
+ + {{ + 'ADMIN.AI_PROVIDER_MODEL' | translate + }} + + +
+
+
+ + {{ + 'ADMIN.AI_PROVIDER_USER_QUOTA' | translate + }} + + +
+
+ + {{ + 'ADMIN.AI_PROVIDER_DOMAIN_QUOTA' | translate + }} + + +
+
+ {{ + 'ADMIN.AI_PROVIDER_ENABLED' | translate + }} + {{ + 'ADMIN.AI_PROVIDER_IS_DEFAULT' | translate + }} +
+
+ `, + styles: [``], + imports: [ + FullscreenModalShellComponent, + SettingsToggleComponent, + TranslatePipe, + MatFormFieldModule, + MatSelectModule, + FormField, + MatInputModule, + ], +}) +export class SignageAIProviderModalComponent { + private _data = inject<{ item?: SignageAIProvider }>(MAT_DIALOG_DATA); + private _dialog_ref = inject(MatDialogRef); + + public readonly item = this._data.item; + public readonly loading = signal(''); + + public readonly form_model = signal({ + id: this._data.item?.id || '', + authority_id: this._data.item?.authority_id ?? '', + name: this._data.item?.name || '', + provider: this._data.item?.provider || 'OPENAI', + endpoint: this._data.item?.endpoint || '', + location: this._data.item?.location || '', + default_model: this._data.item?.default_model || 'gpt-image-2', + enabled: this._data.item?.enabled ?? true, + is_default: this._data.item?.is_default ?? false, + user_per_day: this._data.item?.quotas?.user_per_day ?? 60, + domain_per_month: this._data.item?.quotas?.domain_per_month ?? 2000, + // credentials, never returned by the API + api_key: '', + deployment: '', + api_version: '', + project_id: '', + client_email: '', + private_key: '', + }); + + public readonly form = form(this.form_model, (path) => { + required(path.name); + required(path.provider); + required(path.location, { + when: ({ valueOf }) => valueOf(path.provider) === 'GOOGLE_VERTEX', + }); + }); + + private readonly _credential_fields = computed( + () => SIGNAGE_AI_CREDENTIAL_FIELDS[this.form_model().provider], + ); + public readonly is_google = computed(() => + this._credential_fields().includes('project_id'), + ); + public readonly is_azure = computed(() => + this._credential_fields().includes('deployment'), + ); + + public async save() { + await submit(this.form, async () => undefined); + if (this.form().invalid()) return; + + const model = this.form_model(); + const credentials = this._credentials(model); + if (!model.id && !Object.keys(credentials).length) { + notifyError(i18n('ADMIN.AI_PROVIDER_CREDENTIALS_REQUIRED')); + return; + } + + this.loading.set(i18n('ADMIN.AI_PROVIDER_SAVING')); + this._dialog_ref.disableClose = true; + + const body: Partial & { + credentials?: Record; + } = { + id: model.id || undefined, + name: model.name, + provider: model.provider, + authority_id: model.authority_id || null, + // sent as an empty string rather than null when cleared: null means + // "leave it alone", empty means "unset it" + endpoint: model.endpoint, + location: model.location, + default_model: model.default_model, + enabled: model.enabled, + is_default: model.is_default, + quotas: { + user_per_day: +model.user_per_day, + domain_per_month: +model.domain_per_month, + }, + }; + // an edit that leaves the boxes empty keeps the stored credentials + if (Object.keys(credentials).length) body.credentials = credentials; + + await saveSignageAIProvider(body).catch((error) => { + notifyError(i18n('ADMIN.AI_PROVIDER_SAVE_ERROR')); + this.loading.set(''); + this._dialog_ref.disableClose = false; + throw error; + }); + + this.loading.set(''); + this._dialog_ref.disableClose = false; + notifySuccess(i18n('ADMIN.AI_PROVIDER_SAVE_SUCCESS')); + this._dialog_ref.close(true); + } + + private _credentials(model: ReturnType) { + const out: Record = {}; + for (const field of SIGNAGE_AI_CREDENTIAL_FIELDS[model.provider]) { + if (model[field]) out[field] = model[field]; + } + return out; + } +} diff --git a/src/app/admin/signage-ai/signage-ai.component.ts b/src/app/admin/signage-ai/signage-ai.component.ts new file mode 100644 index 000000000..98f4512dd --- /dev/null +++ b/src/app/admin/signage-ai/signage-ai.component.ts @@ -0,0 +1,348 @@ +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { i18n } from '../../common/locale.service'; +import { notifyError, notifySuccess } from '../../common/notifications'; +import { openConfirmModal } from '../../overlays/confirm-modal.component'; +import { IconComponent } from '../../ui/icon.component'; +import { DateFromPipe } from '../../ui/pipes/date-from.pipe'; +import { SimpleTableComponent } from '../../ui/simple-table.component'; +import { TranslatePipe } from '../../ui/translate.pipe'; +import { AdminDataService } from '../admin-data.service'; +import { SignageAIProviderModalComponent } from './signage-ai-provider-modal.component'; +import { + querySignageAIProviders, + removeSignageAIProvider, + SignageAIProvider, + signageAIUsage, + SignageAIUsageRow, + testSignageAIProvider, +} from './signage-ai.fn'; + +@Component({ + selector: 'app-signage-ai', + template: ` +
+
+
+
+ {{ 'ADMIN.AI_PROVIDERS_HEADER' | translate }} +
+

+ {{ 'ADMIN.AI_PROVIDERS_SCOPE' | translate }} +

+
+
+ +
+
+
+ + @if (provider_error()) { +
+ {{ provider_error() }} +
+ } + + +
+ {{ 'ADMIN.AI_USAGE_HEADER' | translate }} +
+

+ {{ 'ADMIN.AI_USAGE_HINT' | translate }} +

+ @if (usage_error()) { +
+ {{ usage_error() }} +
+ } + +
+
+ +
+
{{ row.name }}
+
+ {{ row.domain?.name || row.authority_id || 'Any domain' }} +
+
+
+ +
+ {{ data }} +
+
+ +
{{ data }}
+
+ +
{{ +data * 1000 | dateFrom }}
+
+ +
+
+ {{ data ? 'check' : 'close' }} +
+
+
+ +
+ + + +
+
+ `, + styles: [``], + imports: [ + IconComponent, + MatRippleModule, + TranslatePipe, + MatTooltipModule, + SimpleTableComponent, + MatProgressBarModule, + DateFromPipe, + ], +}) +export class SignageAIComponent implements OnInit { + private _dialog = inject(MatDialog); + private _admin_data = inject(AdminDataService); + + public readonly loading = signal(''); + public readonly providers = signal([]); + public readonly usage = signal([]); + public readonly provider_error = signal(''); + public readonly usage_error = signal(''); + public readonly domain_list = this._admin_data.domain_list; + + public readonly provider_list = computed(() => + this.providers().map((provider) => ({ + ...provider, + domain: this.domain_list().find( + (d) => d.id === provider.authority_id, + ), + })), + ); + + public async ngOnInit() { + this.loading.set(i18n('ADMIN.AI_PROVIDERS_LOADING')); + await this._admin_data.loadDomains(); + await this.load(); + this.loading.set(''); + } + + public edit(item?: SignageAIProvider) { + const ref = this._dialog.open(SignageAIProviderModalComponent, { + data: { item }, + }); + ref.afterClosed().subscribe(() => this.load()); + } + + public readonly testing = signal(''); + + public async test(item: SignageAIProvider) { + if (this.testing()) return; + const resp = await openConfirmModal( + { + title: i18n('ADMIN.AI_PROVIDER_TEST_TITLE'), + content: i18n('ADMIN.AI_PROVIDER_TEST_MSG', { + name: item.name, + }), + icon: { content: 'bolt' }, + }, + this._dialog, + ); + if (resp.reason !== 'done') return; + resp.close(); + + this.testing.set(item.id); + this.loading.set(i18n('ADMIN.AI_PROVIDER_TESTING')); + const result = await testSignageAIProvider(item.id).catch(() => null); + this.loading.set(''); + this.testing.set(''); + if (result?.ok) { + notifySuccess( + i18n('ADMIN.AI_PROVIDER_TEST_OK', { + ms: `${result.latency_ms}`, + }), + ); + } else { + notifyError(result?.error || i18n('ADMIN.AI_PROVIDER_TEST_FAILED')); + } + } + + public async remove(item: SignageAIProvider) { + const resp = await openConfirmModal( + { + title: i18n('ADMIN.AI_PROVIDER_REMOVE_TITLE'), + content: i18n('ADMIN.AI_PROVIDER_REMOVE_MSG', { + name: item.name, + }), + icon: { content: 'delete_forever' }, + }, + this._dialog, + ); + if (resp.reason !== 'done') return; + resp.loading(i18n('ADMIN.AI_PROVIDER_REMOVING')); + try { + await removeSignageAIProvider(item.id); + } catch (error) { + // the confirm modal owns its own spinner, so it has to be told + resp.close(); + notifyError( + (error as Error)?.message || + i18n('ADMIN.AI_PROVIDER_REMOVE_FAILED'), + ); + return; + } + resp.close(); + this.load(); + } + + public async load() { + this.loading.set(i18n('ADMIN.AI_PROVIDERS_LOADING')); + this.provider_error.set(''); + this.usage_error.set(''); + + const [provider_result, usage_result] = await Promise.allSettled([ + querySignageAIProviders(), + signageAIUsage(), + ]); + if (provider_result.status === 'fulfilled') { + this.providers.set(provider_result.value); + } else { + this.provider_error.set(i18n('ADMIN.AI_PROVIDERS_LOAD_FAILED')); + } + if (usage_result.status === 'fulfilled') { + this.usage.set(usage_result.value); + } else { + this.usage_error.set(i18n('ADMIN.AI_USAGE_LOAD_FAILED')); + } + this.loading.set(''); + } +} diff --git a/src/app/admin/signage-ai/signage-ai.fn.ts b/src/app/admin/signage-ai/signage-ai.fn.ts new file mode 100644 index 000000000..b010ddc63 --- /dev/null +++ b/src/app/admin/signage-ai/signage-ai.fn.ts @@ -0,0 +1,91 @@ +import { apiEndpoint, del, get, patch, post } from '@placeos/ts-client'; + +import { toQueryString } from '../../common/api'; + +const PATH = () => `${apiEndpoint()}/signage/ai`; + +export type SignageAIProviderType = 'OPENAI' | 'AZURE_OPENAI' | 'GOOGLE_VERTEX'; + +export class SignageAIProvider { + public readonly id: string; + public readonly name: string; + public readonly provider: SignageAIProviderType; + public readonly authority_id: string | null; + public readonly endpoint: string | null; + public readonly location: string | null; + public readonly default_model: string | null; + public readonly allowed_models: string[]; + public readonly enabled: boolean; + public readonly is_default: boolean; + public readonly quotas: Record; + public readonly created_at: number; + public readonly updated_at: number; + + constructor(item: Partial = {}) { + Object.assign(this, item); + } +} + +export interface SignageAIUsageRow { + provider: string; + model: string; + jobs: number; + candidates: number; + images_produced: number; + cost_units: number; +} + +export interface SignageAITestResult { + ok: boolean; + latency_ms: number; + model?: string; + error?: string; + kind?: string; +} + +function search(params: Record) { + const query = toQueryString(params); + return query ? `?${query}` : ''; +} + +export async function querySignageAIProviders( + params: { + authority_id?: string; + include_shared?: boolean; + } = {}, +): Promise { + const list = (await get( + `${PATH()}/providers${search(params)}`, + )) as unknown as Partial[]; + return (list || []).map((item) => new SignageAIProvider(item)); +} + +export async function saveSignageAIProvider( + item: Partial & { + credentials?: Record; + }, +): Promise { + const { id, ...body } = item; + const result = id + ? await patch(`${PATH()}/providers/${encodeURIComponent(id)}`, body) + : await post(`${PATH()}/providers`, body); + return new SignageAIProvider(result as Partial); +} + +export function removeSignageAIProvider(id: string) { + return del(`${PATH()}/providers/${encodeURIComponent(id)}`); +} + +/** Prove the credentials work: one small image, discarded. */ +export function testSignageAIProvider(id: string) { + return post( + `${PATH()}/providers/${encodeURIComponent(id)}/test`, + {}, + ) as unknown as Promise; +} + +export function signageAIUsage(params: { from?: number; to?: number } = {}) { + return get(`${PATH()}/usage${search(params)}`) as unknown as Promise< + SignageAIUsageRow[] + >; +} diff --git a/src/tests/admin/signage-ai-provider-modal.component.spec.ts b/src/tests/admin/signage-ai-provider-modal.component.spec.ts new file mode 100644 index 000000000..d00b82160 --- /dev/null +++ b/src/tests/admin/signage-ai-provider-modal.component.spec.ts @@ -0,0 +1,59 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SignageAIProviderModalComponent } from '../../app/admin/signage-ai/signage-ai-provider-modal.component'; + +vi.mock('@placeos/ts-client', () => ({ + apiEndpoint: vi.fn(() => '/api/engine/v2'), + del: vi.fn(() => Promise.resolve({})), + get: vi.fn(() => Promise.resolve([])), + patch: vi.fn(() => Promise.resolve({})), + post: vi.fn(() => Promise.resolve({})), + showMetadata: vi.fn(() => Promise.resolve({})), +})); + +describe('SignageAIProviderModalComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SignageAIProviderModalComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: {} }, + { + provide: MatDialogRef, + useValue: { close: vi.fn(), disableClose: false }, + }, + ], + }) + .overrideComponent(SignageAIProviderModalComponent, { + set: { template: '' }, + }) + .compileComponents(); + }); + + it('requires a region only for Google Vertex', () => { + const fixture = TestBed.createComponent( + SignageAIProviderModalComponent, + ); + const component = fixture.componentInstance; + + component.form_model.update((model) => ({ + ...model, + provider: 'GOOGLE_VERTEX', + location: '', + })); + expect(component.form.location().invalid()).toBe(true); + + component.form_model.update((model) => ({ + ...model, + location: 'australia-southeast1', + })); + expect(component.form.location().valid()).toBe(true); + + component.form_model.update((model) => ({ + ...model, + provider: 'OPENAI', + location: '', + })); + expect(component.form.location().valid()).toBe(true); + }); +}); diff --git a/src/tests/admin/signage-ai.component.spec.ts b/src/tests/admin/signage-ai.component.spec.ts new file mode 100644 index 000000000..cc9fcd21a --- /dev/null +++ b/src/tests/admin/signage-ai.component.spec.ts @@ -0,0 +1,69 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AdminDataService } from '../../app/admin/admin-data.service'; +import { SignageAIComponent } from '../../app/admin/signage-ai/signage-ai.component'; + +const get_mock = vi.hoisted(() => + vi.fn<(url: string) => Promise>(() => Promise.resolve([])), +); + +vi.mock('@placeos/ts-client', () => ({ + apiEndpoint: vi.fn(() => '/api/engine/v2'), + del: vi.fn(() => Promise.resolve({})), + get: get_mock, + patch: vi.fn(() => Promise.resolve({})), + post: vi.fn(() => Promise.resolve({})), +})); + +describe('SignageAIComponent', () => { + beforeEach(async () => { + get_mock.mockReset(); + await TestBed.configureTestingModule({ + imports: [SignageAIComponent], + providers: [ + { provide: MatDialog, useValue: { open: vi.fn() } }, + { + provide: AdminDataService, + useValue: { + domain_list: signal([]), + loadDomains: vi.fn(() => Promise.resolve([])), + }, + }, + ], + }) + .overrideComponent(SignageAIComponent, { + set: { template: '' }, + }) + .compileComponents(); + }); + + it('shows a provider error instead of the provider empty state', async () => { + get_mock.mockImplementation((url) => + url.endsWith('/providers') + ? Promise.reject(new Error('provider failed')) + : Promise.resolve([]), + ); + const fixture = TestBed.createComponent(SignageAIComponent); + + await fixture.componentInstance.load(); + + expect(fixture.componentInstance.provider_error()).not.toBe(''); + expect(fixture.componentInstance.usage_error()).toBe(''); + }); + + it('shows a usage error without hiding the provider result', async () => { + get_mock.mockImplementation((url) => + url.endsWith('/usage') + ? Promise.reject(new Error('usage failed')) + : Promise.resolve([]), + ); + const fixture = TestBed.createComponent(SignageAIComponent); + + await fixture.componentInstance.load(); + + expect(fixture.componentInstance.provider_error()).toBe(''); + expect(fixture.componentInstance.usage_error()).not.toBe(''); + }); +}); diff --git a/src/tests/admin/signage-ai.fn.spec.ts b/src/tests/admin/signage-ai.fn.spec.ts new file mode 100644 index 000000000..7ce0f560a --- /dev/null +++ b/src/tests/admin/signage-ai.fn.spec.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + querySignageAIProviders, + removeSignageAIProvider, + saveSignageAIProvider, + SignageAIProvider, + signageAIUsage, + testSignageAIProvider, +} from '../../app/admin/signage-ai/signage-ai.fn'; + +const client_mock = vi.hoisted(() => ({ + apiEndpoint: vi.fn(() => '/api/engine/v2'), + del: vi.fn<(url: string) => Promise>(() => Promise.resolve({})), + get: vi.fn<(url: string) => Promise>(() => Promise.resolve([])), + patch: vi.fn<(url: string, body: unknown) => Promise>(() => + Promise.resolve({}), + ), + post: vi.fn<(url: string, body: unknown) => Promise>(() => + Promise.resolve({}), + ), +})); + +vi.mock('@placeos/ts-client', () => client_mock); + +describe('signage-ai.fn', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('SignageAIProvider class', () => { + it('should create an empty provider', () => { + const provider = new SignageAIProvider(); + expect(provider).toBeDefined(); + }); + + it('should keep the fields it is given', () => { + const provider = new SignageAIProvider({ + id: 'provider-1', + name: 'OpenAI', + provider: 'OPENAI', + enabled: true, + is_default: true, + }); + expect(provider.id).toBe('provider-1'); + expect(provider.name).toBe('OpenAI'); + expect(provider.provider).toBe('OPENAI'); + expect(provider.enabled).toBe(true); + expect(provider.is_default).toBe(true); + }); + + it('should never carry credentials, which the API does not return', () => { + const provider = new SignageAIProvider({ id: 'provider-1' }); + expect('credentials' in provider).toBe(false); + }); + }); + + describe('querySignageAIProviders', () => { + it('should ask for the providers and wrap each row', async () => { + client_mock.get.mockResolvedValueOnce([ + { id: 'provider-1', name: 'OpenAI' }, + ]); + + const list = await querySignageAIProviders(); + + expect(client_mock.get).toHaveBeenCalledWith( + '/api/engine/v2/signage/ai/providers', + ); + expect(list).toHaveLength(1); + expect(list[0]).toBeInstanceOf(SignageAIProvider); + expect(list[0].name).toBe('OpenAI'); + }); + + it('should pass through the shared row flag', async () => { + await querySignageAIProviders({ include_shared: false }); + expect(client_mock.get).toHaveBeenCalledWith( + '/api/engine/v2/signage/ai/providers?include_shared=false', + ); + }); + + it('should survive an empty answer', async () => { + client_mock.get.mockResolvedValueOnce(null); + await expect(querySignageAIProviders()).resolves.toEqual([]); + }); + }); + + describe('saveSignageAIProvider', () => { + it('should post a row that has no id', async () => { + client_mock.post.mockResolvedValueOnce({ id: 'new' }); + + const saved = await saveSignageAIProvider({ name: 'OpenAI' }); + + expect(client_mock.post).toHaveBeenCalledWith( + '/api/engine/v2/signage/ai/providers', + { name: 'OpenAI' }, + ); + expect(saved).toBeInstanceOf(SignageAIProvider); + }); + + it('should patch a row that has one, with the id in the path only', async () => { + client_mock.patch.mockResolvedValueOnce({ id: 'p1' }); + + await saveSignageAIProvider({ id: 'p1', name: 'Renamed' }); + + expect(client_mock.patch).toHaveBeenCalledWith( + '/api/engine/v2/signage/ai/providers/p1', + { name: 'Renamed' }, + ); + expect(client_mock.post).not.toHaveBeenCalled(); + }); + }); + + describe('removeSignageAIProvider', () => { + it('should delete by id', async () => { + await removeSignageAIProvider('p1'); + expect(client_mock.del).toHaveBeenCalledWith( + '/api/engine/v2/signage/ai/providers/p1', + ); + }); + }); + + describe('testSignageAIProvider', () => { + it('should post to the test action for the row', async () => { + await testSignageAIProvider('p1'); + expect(client_mock.post).toHaveBeenCalledWith( + '/api/engine/v2/signage/ai/providers/p1/test', + {}, + ); + }); + }); + + describe('signageAIUsage', () => { + it('should ask for usage with no window by default', async () => { + await signageAIUsage(); + expect(client_mock.get).toHaveBeenCalledWith( + '/api/engine/v2/signage/ai/usage', + ); + }); + + it('should pass a window through as query parameters', async () => { + await signageAIUsage({ from: 100, to: 200 }); + expect(client_mock.get).toHaveBeenCalledWith( + '/api/engine/v2/signage/ai/usage?from=100&to=200', + ); + }); + }); +});