From 96215d05ecb6a8e92a9daa35d8ba889fb1f80756 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:43:29 +0200 Subject: [PATCH 01/25] feat: AI gateway integration via deployment region config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AI usecase (state/thunks/selectors) with initializeStart/initializeSucceed/initializeFailed lifecycle actions - getToken() returns a discriminated result type in the Ai port — no-account (403) vs error cases handled without leaking adapter details into usecases - Gracefully disable AI features on init failure; show a link to the gateway URL when user has no account - Add AccountAiGatewayTab with token/model display and full i18n for all 9 languages Co-Authored-By: Claude Sonnet 4.6 --- api | 2 +- web/src/core/adapters/ai/index.ts | 1 + web/src/core/adapters/ai/mock.ts | 12 ++ web/src/core/adapters/ai/openWebUi.ts | 45 +++++ web/src/core/adapters/onyxiaApi/ApiTypes.ts | 5 + web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 11 ++ web/src/core/bootstrap.ts | 51 +++++- web/src/core/ports/Ai.ts | 11 ++ .../core/ports/OnyxiaApi/DeploymentRegion.ts | 7 + web/src/core/ports/OnyxiaApi/XOnyxia.ts | 8 + web/src/core/tools/oidcTokenExchange.ts | 38 +++++ web/src/core/usecases/ai/index.ts | 3 + web/src/core/usecases/ai/selectors.ts | 45 +++++ web/src/core/usecases/ai/state.ts | 80 +++++++++ web/src/core/usecases/ai/thunks.ts | 83 +++++++++ web/src/core/usecases/index.ts | 2 + web/src/core/usecases/launcher/thunks.ts | 15 ++ web/src/ui/i18n/resources/de.tsx | 30 +++- web/src/ui/i18n/resources/en.tsx | 29 +++- web/src/ui/i18n/resources/es.tsx | 30 +++- web/src/ui/i18n/resources/fi.tsx | 30 +++- web/src/ui/i18n/resources/fr.tsx | 30 +++- web/src/ui/i18n/resources/it.tsx | 36 +++- web/src/ui/i18n/resources/nl.tsx | 29 +++- web/src/ui/i18n/resources/no.tsx | 29 +++- web/src/ui/i18n/resources/zh-CN.tsx | 28 +++- web/src/ui/pages/account/AccountAiTab.tsx | 158 ++++++++++++++++++ web/src/ui/pages/account/Page.tsx | 6 +- web/src/ui/pages/account/accountTabIds.ts | 1 + 29 files changed, 837 insertions(+), 18 deletions(-) create mode 100644 web/src/core/adapters/ai/index.ts create mode 100644 web/src/core/adapters/ai/mock.ts create mode 100644 web/src/core/adapters/ai/openWebUi.ts create mode 100644 web/src/core/ports/Ai.ts create mode 100644 web/src/core/tools/oidcTokenExchange.ts create mode 100644 web/src/core/usecases/ai/index.ts create mode 100644 web/src/core/usecases/ai/selectors.ts create mode 100644 web/src/core/usecases/ai/state.ts create mode 100644 web/src/core/usecases/ai/thunks.ts create mode 100644 web/src/ui/pages/account/AccountAiTab.tsx diff --git a/api b/api index 3fb6e1d54..2086d2ebc 160000 --- a/api +++ b/api @@ -1 +1 @@ -Subproject commit 3fb6e1d547afa7b4afa3679a4c6d7f687ea88caf +Subproject commit 2086d2ebca3019373c242890d5709d79660f580b diff --git a/web/src/core/adapters/ai/index.ts b/web/src/core/adapters/ai/index.ts new file mode 100644 index 000000000..70fa02f89 --- /dev/null +++ b/web/src/core/adapters/ai/index.ts @@ -0,0 +1 @@ +export * from "./openWebUi"; diff --git a/web/src/core/adapters/ai/mock.ts b/web/src/core/adapters/ai/mock.ts new file mode 100644 index 000000000..9b629904d --- /dev/null +++ b/web/src/core/adapters/ai/mock.ts @@ -0,0 +1,12 @@ +import type { Ai } from "core/ports/Ai"; + +export function createAi(params: { webUiUrl: string }): Ai { + const { webUiUrl } = params; + + return { + webUiUrl, + apiBase: `${webUiUrl}/api`, + getToken: async () => ({ status: "success" as const, token: "mock-ai-token" }), + listModels: async () => ["llama3.2", "mistral-7b", "codestral"] + }; +} diff --git a/web/src/core/adapters/ai/openWebUi.ts b/web/src/core/adapters/ai/openWebUi.ts new file mode 100644 index 000000000..a2b07e9c4 --- /dev/null +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -0,0 +1,45 @@ +import type { Ai, GetTokenResult } from "core/ports/Ai"; +import { oidcTokenExchange, OidcTokenExchangeError } from "core/tools/oidcTokenExchange"; + +export function createAi(params: { + webUiUrl: string; + oauthProvider: string; + getOidcAccessToken: () => Promise; +}): Ai { + const { webUiUrl, oauthProvider, getOidcAccessToken } = params; + + const apiBase = `${webUiUrl}/api`; + + return { + webUiUrl, + apiBase, + getToken: async (): Promise => { + const oidcAccessToken = await getOidcAccessToken(); + + return oidcTokenExchange({ + tokenExchangeEndpoint: `${webUiUrl}/api/v1/auths/oauth/${oauthProvider}/token/exchange`, + oidcAccessToken + }) + .then(token => ({ status: "success" as const, token })) + .catch((error: unknown) => { + if (error instanceof OidcTokenExchangeError && error.status === 403) { + return { status: "no-account" as const }; + } + return { status: "error" as const }; + }); + }, + listModels: async (token: string) => { + const response = await fetch(`${apiBase}/models`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error(`Failed to list models (${response.status})`); + } + + const data = await response.json(); + + return (data.data as { id: string }[]).map(m => m.id); + } + }; +} diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 26b421d6d..1e571dd24 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -82,6 +82,11 @@ export type ApiTypes = { }; }; data?: { + ai?: { + URL: string; + oauthProvider: string; + oidcConfiguration?: Partial; + }; S3?: ArrayOrNot<{ URL: string; pathStyleAccess?: true; diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 390a3a020..878665426 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -435,6 +435,17 @@ export function createOnyxiaApi(params: { apiRegion.vault.oidcConfiguration ) }, + ai: + apiRegion.data?.ai === undefined + ? undefined + : { + url: apiRegion.data.ai.URL, + oauthProvider: apiRegion.data.ai.oauthProvider, + oidcParams: + apiTypesOidcConfigurationToOidcParams_Partial( + apiRegion.data.ai.oidcConfiguration + ) + }, proxyInjection: apiRegion.proxyInjection === undefined ? undefined diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index c07bf863f..712f16bd4 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -8,6 +8,7 @@ import type { OnyxiaApi } from "core/ports/OnyxiaApi"; import type { SqlOlap } from "core/ports/SqlOlap"; import { usecases } from "./usecases"; import type { SecretsManager } from "core/ports/SecretsManager"; +import type { Ai } from "core/ports/Ai"; import type { Oidc } from "core/ports/Oidc"; import type { Language } from "core/ports/OnyxiaApi/Language"; import { createDuckDbSqlOlap } from "core/adapters/sqlOlap"; @@ -38,6 +39,7 @@ export type Context = { onyxiaApi: OnyxiaApi; secretsManager: SecretsManager; sqlOlap: SqlOlap; + ai: Ai | undefined; }; export type Core = GenericCore; @@ -83,7 +85,6 @@ export async function bootstrapCore( ); } catch (error) { if (error instanceof AccessError) { - // NOTE: Not initialized yet, it's not a bug. return undefined; } throw error; @@ -105,7 +106,6 @@ export async function bootstrapCore( ); } catch (error) { if (error instanceof AccessError) { - // NOTE: Not initialized yet, it's not a bug. return undefined; } throw error; @@ -137,7 +137,6 @@ export async function bootstrapCore( if (isAuthGloballyRequired && !oidc.isUserLoggedIn) { await oidc.login({ doesCurrentHrefRequiresAuth: true }); - // NOTE: Never reached } const context: Context = { @@ -177,7 +176,8 @@ export async function bootstrapCore( s3_region: s3Profile.paramsOfCreateS3Client.region }; } - }) + }), + ai: undefined }; const { core, dispatch, getState } = createCore({ @@ -275,6 +275,49 @@ export async function bootstrapCore( await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize()); } + init_ai: { + if (!oidc.isUserLoggedIn) { + break init_ai; + } + + const deploymentRegion = + usecases.deploymentRegionManagement.selectors.currentDeploymentRegion( + getState() + ); + + if (deploymentRegion.ai === undefined) { + break init_ai; + } + + const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] = + await Promise.all([ + import("core/adapters/ai"), + import("core/adapters/oidc"), + onyxiaApi.getAvailableRegionsAndOidcParams() + ]); + + assert(oidcParams !== undefined); + + const oidc_ai = await createOidc({ + ...mergeOidcParams({ + oidcParams, + oidcParams_partial: deploymentRegion.ai.oidcParams + }), + transformBeforeRedirectForKeycloakTheme, + getCurrentLang, + autoLogin: true, + enableDebugLogs: enableOidcDebugLogs + }); + + context.ai = createAi({ + webUiUrl: deploymentRegion.ai.url, + oauthProvider: deploymentRegion.ai.oauthProvider, + getOidcAccessToken: async () => (await oidc_ai.getTokens()).accessToken + }); + + await dispatch(usecases.ai.protectedThunks.initialize()); + } + pluginSystemInitCore({ core, context }); return { core }; diff --git a/web/src/core/ports/Ai.ts b/web/src/core/ports/Ai.ts new file mode 100644 index 000000000..1a4dbde8f --- /dev/null +++ b/web/src/core/ports/Ai.ts @@ -0,0 +1,11 @@ +export type Ai = { + webUiUrl: string; + apiBase: string; + getToken: () => Promise; + listModels: (token: string) => Promise; +}; + +export type GetTokenResult = + | { status: "success"; token: string } + | { status: "no-account" } + | { status: "error" }; diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index f9dec3d16..5138850a8 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -44,6 +44,13 @@ export type DeploymentRegion = { oidcParams: OidcParams_Partial; } | undefined; + ai: + | { + url: string; + oauthProvider: string; + oidcParams: OidcParams_Partial; + } + | undefined; proxyInjection: | { enabled: boolean | undefined; diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index ed76d4d3a..b21bd5d64 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -182,6 +182,14 @@ export type XOnyxiaContext = { useCertManager: boolean; certManagerClusterIssuer: string | undefined; }; + ai: + | { + enabled: true; + token: string; + apiBase: string; + model: string; + } + | undefined; proxyInjection: | { enabled: boolean | undefined; diff --git a/web/src/core/tools/oidcTokenExchange.ts b/web/src/core/tools/oidcTokenExchange.ts new file mode 100644 index 000000000..fdb320251 --- /dev/null +++ b/web/src/core/tools/oidcTokenExchange.ts @@ -0,0 +1,38 @@ +export class OidcTokenExchangeError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message); + } +} + +export async function oidcTokenExchange(params: { + tokenExchangeEndpoint: string; + oidcAccessToken: string; +}): Promise { + const { tokenExchangeEndpoint, oidcAccessToken } = params; + + const response = await fetch(tokenExchangeEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: oidcAccessToken }) + }); + + if (!response.ok) { + throw new OidcTokenExchangeError( + response.status, + `OIDC token exchange failed (${response.status}): ${await response.text()}` + ); + } + + const data = await response.json(); + + const token: string = data.token ?? data.access_token; + + if (!token) { + throw new Error("Token exchange response contained no token"); + } + + return token; +} diff --git a/web/src/core/usecases/ai/index.ts b/web/src/core/usecases/ai/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/ai/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts new file mode 100644 index 000000000..f34e34614 --- /dev/null +++ b/web/src/core/usecases/ai/selectors.ts @@ -0,0 +1,45 @@ +import { createSelector } from "clean-architecture"; +import type { State as RootState } from "core/bootstrap"; +import { name } from "./state"; +import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; +import { assert } from "tsafe"; + +const state = (rootState: RootState) => rootState[name]; + +const main = createSelector( + state, + deploymentRegionManagement.selectors.currentDeploymentRegion, + (state, region) => { + if (!state.isEnabled) { + const { initializationStatus } = state; + + if (initializationStatus === "no-account") { + assert( + region.ai !== undefined, + "region.ai should exists in case of no-account status" + ); + + return { + isEnabled: false as const, + initializationStatus, + webUiUrl: region.ai.url + }; + } + + return { isEnabled: false as const, initializationStatus }; + } + + const { webUiUrl, apiBase, token, availableModels, selectedModel } = state; + + return { + isEnabled: true as const, + webUiUrl, + apiBase, + token, + availableModels, + selectedModel + }; + } +); + +export const selectors = { main }; diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts new file mode 100644 index 000000000..99bf8510c --- /dev/null +++ b/web/src/core/usecases/ai/state.ts @@ -0,0 +1,80 @@ +import { createUsecaseActions } from "clean-architecture"; +import { id } from "tsafe/id"; + +export const name = "ai"; + +type State = State.Disabled | State.Enabled; + +export declare namespace State { + export type Disabled = { + isEnabled: false; + initializationStatus: "not-started" | "pending" | "error" | "no-account"; + }; + + export type Enabled = { + isEnabled: true; + webUiUrl: string; + apiBase: string; + token: string | undefined; + availableModels: string[]; + selectedModel: string | undefined; + }; +} + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id( + id({ isEnabled: false, initializationStatus: "not-started" }) + ), + reducers: { + initializeStart: state => { + if (state.isEnabled) return; + state.initializationStatus = "pending"; + }, + initializeFailed: ( + _, + { payload }: { payload: { cause: "error" | "no-account" } } + ) => + id({ + isEnabled: false, + initializationStatus: payload.cause + }), + initializeSucceed: ( + _, + { + payload + }: { + payload: { + webUiUrl: string; + apiBase: string; + token: string; + availableModels: string[]; + selectedModel: string | undefined; + }; + } + ) => { + const { webUiUrl, apiBase, token, availableModels, selectedModel } = payload; + + return id({ + isEnabled: true, + webUiUrl, + apiBase, + token, + availableModels, + selectedModel: selectedModel ?? availableModels[0] + }); + }, + tokenRefreshed: (state, { payload }: { payload: { token: string } }) => { + if (!state.isEnabled) return; + state.token = payload.token; + }, + tokenRefreshFailed: state => { + if (!state.isEnabled) return; + state.token = undefined; + }, + selectedModelSet: (state, { payload }: { payload: { model: string } }) => { + if (!state.isEnabled) return; + state.selectedModel = payload.model; + } + } +}); diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts new file mode 100644 index 000000000..c4f8ffcde --- /dev/null +++ b/web/src/core/usecases/ai/thunks.ts @@ -0,0 +1,83 @@ +import type { Thunks } from "core/bootstrap"; +import { actions } from "./state"; +import { getLocalStorage } from "core/tools/safeLocalStorage"; +import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; +import { assert } from "tsafe"; + +const SELECTED_MODEL_STORAGE_KEY = "onyxia:ai:selectedModel"; + +export const thunks = { + isAvailable: + () => + (...args): boolean => { + const [, getState] = args; + const region = + deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); + return region.ai !== undefined; + }, + refreshToken: + () => + async (...args) => { + const [dispatch, , { ai }] = args; + + assert(ai !== undefined); + + const result = await ai.getToken(); + + if (result.status !== "success") { + dispatch(actions.tokenRefreshFailed()); + return; + } + + dispatch(actions.tokenRefreshed({ token: result.token })); + }, + setSelectedModel: + (params: { model: string }) => + (...args) => { + const { model } = params; + const [dispatch] = args; + + const { localStorage } = getLocalStorage(); + + localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, model); + + dispatch(actions.selectedModelSet({ model })); + } +} satisfies Thunks; + +export const protectedThunks = { + initialize: + () => + async (...args) => { + const [dispatch, , { ai }] = args; + + if (ai === undefined) { + return; + } + + const { localStorage } = getLocalStorage(); + + dispatch(actions.initializeStart()); + + const tokenResult = await ai.getToken(); + + if (tokenResult.status !== "success") { + dispatch(actions.initializeFailed({ cause: tokenResult.status })); + return; + } + + const { token } = tokenResult; + const availableModels = await ai.listModels(token); + + dispatch( + actions.initializeSucceed({ + webUiUrl: ai.webUiUrl, + apiBase: ai.apiBase, + token, + availableModels, + selectedModel: + localStorage.getItem(SELECTED_MODEL_STORAGE_KEY) ?? undefined + }) + ); + } +} satisfies Thunks; diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 3aba184a3..dbc4835df 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -1,3 +1,4 @@ +import * as ai from "./ai"; import * as autoLogoutCountdown from "./autoLogoutCountdown"; import * as catalog from "./catalog"; import * as clusterEventsMonitor from "./clusterEventsMonitor"; @@ -26,6 +27,7 @@ import * as s3ProfilesCreationUiController from "./s3ProfilesCreationUiControlle import * as s3ExplorerUiController from "./s3ExplorerUiController"; export const usecases = { + ai, autoLogoutCountdown, catalog, clusterEventsMonitor, diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index 6f4b06399..742daa19b 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -1,6 +1,7 @@ import type { Thunks } from "core/bootstrap"; import { assert, type Equals, is } from "tsafe/assert"; import * as userAuthentication from "../userAuthentication"; +import * as aiUsecase from "core/usecases/ai"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import * as projectManagement from "core/usecases/projectManagement"; import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; @@ -768,6 +769,20 @@ export const protectedThunks = { useCertManager: region.certManager?.useCertManager, certManagerClusterIssuer: region.certManager?.certManagerClusterIssuer }, + ai: (() => { + const aiState = aiUsecase.selectors.main(getState()); + + if (!aiState.isEnabled || aiState.token === undefined) { + return undefined; + } + + return { + enabled: true as const, + token: aiState.token, + apiBase: aiState.apiBase, + model: aiState.selectedModel ?? "" + }; + })(), proxyInjection: region.proxyInjection, packageRepositoryInjection: region.packageRepositoryInjection, certificateAuthorityInjection: region.certificateAuthorityInjection diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index e5345a6c7..453f09161 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -17,7 +17,8 @@ export const translations: Translations<"de"> = { text2: "Greifen Sie auf Ihre verschiedenen Kontoinformationen zu.", text3: "Konfigurieren Sie Ihre persönlichen Logins, E-Mails, Passwörter und persönlichen Zugriffstoken, die direkt mit Ihren Diensten verbunden sind.", "personal tokens tooltip": 'Oder auf Englisch "Token".', - vault: "Vault" + vault: "Vault", + ai: "KI" }, AccountProfileTab: { "account id": "Kontoidentifikator", @@ -97,6 +98,33 @@ export const translations: Translations<"de"> = { "expires in": ({ howMuchTime }) => `Diese Anmeldedaten sind für die nächsten ${howMuchTime} gültig` }, + AccountAiGatewayTab: { + "credentials section title": "KI-Gateway-Anmeldedaten", + "credentials section helper": ({ webUiUrl }) => ( + <> + Ihre OIDC-Sitzung gibt Ihnen nahtlosen Zugriff auf das KI-Gateway.{" "} + + KI-Gateway öffnen + + + ), + "api base url": "API-Basis-URL", + token: "Token", + "model section title": "Standardmodell", + "model section helper": + "Dieses Modell wird vorkonfiguriert, wenn Sie einen Dienst starten, der KI-Unterstützung unterstützt.", + "model label": "Modell", + "no account": ({ webUiUrl }) => ( + <> + Sie haben noch kein Konto beim KI-Gateway. Bitte melden Sie sich zuerst an + bei{" "} + + {webUiUrl} + {" "} + um Ihr Konto zu erstellen. + + ) + }, AccountVaultTab: { "credentials section title": "Vault-Anmeldeinformationen", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index bf0d71b2e..f1d31d378 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -17,7 +17,8 @@ export const translations: Translations<"en"> = { text3: "Configure your usernames, emails, passwords and personal access tokens directly connected to your services.", "personal tokens tooltip": "Password that are generated for you and that have a given validity period", - vault: "Vault" + vault: "Vault", + ai: "AI" }, AccountProfileTab: { "account id": "Account identifier", @@ -95,6 +96,32 @@ export const translations: Translations<"en"> = { "expires in": ({ howMuchTime }) => `These credentials are valid for the next ${howMuchTime}` }, + AccountAiGatewayTab: { + "credentials section title": "AI Gateway credentials", + "credentials section helper": ({ webUiUrl }) => ( + <> + Your OIDC session gives you seamless access to the AI gateway.{" "} + + Open AI gateway + + + ), + "api base url": "API base URL", + token: "Token", + "model section title": "Default model", + "model section helper": + "This model will be pre-configured when you launch a service that supports AI assistance.", + "model label": "Model", + "no account": ({ webUiUrl }) => ( + <> + You don't have an AI gateway account yet. Please log in to{" "} + + {webUiUrl} + {" "} + first to create your account. + + ) + }, AccountVaultTab: { "credentials section title": "Vault credentials", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index b60717f9d..260a45782 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -18,7 +18,8 @@ export const translations: Translations<"es"> = { text3: "Configura tus nombres de usuario, correos electrónicos, contraseñas y tokens de acceso personal directamente conectados a tus servicios.", "personal tokens tooltip": "Contraseñas que se generan para ti y que tienen un período de validez determinado", - vault: "Vault" + vault: "Vault", + ai: "IA" }, AccountProfileTab: { "account id": "Identificador de cuenta", @@ -96,6 +97,33 @@ export const translations: Translations<"es"> = { "expires in": ({ howMuchTime }) => `Estas credenciales son válidas por los próximos ${howMuchTime}` }, + AccountAiGatewayTab: { + "credentials section title": "Credenciales de la pasarela de IA", + "credentials section helper": ({ webUiUrl }) => ( + <> + Su sesión OIDC le da acceso sin interrupciones a la pasarela de IA.{" "} + + Abrir pasarela de IA + + + ), + "api base url": "URL base de la API", + token: "Token", + "model section title": "Modelo predeterminado", + "model section helper": + "Este modelo se preconfigurará al lanzar un servicio que admita asistencia de IA.", + "model label": "Modelo", + "no account": ({ webUiUrl }) => ( + <> + Aún no tiene una cuenta en la pasarela de IA. Por favor, inicie sesión + primero en{" "} + + {webUiUrl} + {" "} + para crear su cuenta. + + ) + }, AccountVaultTab: { "credentials section title": "Credenciales de Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 2afa0521d..dc42cad0b 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -18,7 +18,8 @@ export const translations: Translations<"fi"> = { text3: "Määritä käyttäjänimesi, sähköpostiosoitteesi, salasanat ja henkilökohtaiset pääsytunnukset, jotka ovat suoraan yhteydessä palveluihisi.", "personal tokens tooltip": "Sinulle generoidut salasanat, joilla on määritelty voimassaoloaika", - vault: "Vault" + vault: "Vault", + ai: "Tekoäly" }, AccountProfileTab: { "account id": "Tilin tunniste", @@ -96,6 +97,33 @@ export const translations: Translations<"fi"> = { "expires in": ({ howMuchTime }) => `Nämä käyttöoikeudet ovat voimassa seuraavat ${howMuchTime}` }, + AccountAiGatewayTab: { + "credentials section title": "Tekoälyyhdyskäytävän tunnistetiedot", + "credentials section helper": ({ webUiUrl }) => ( + <> + OIDC-istuntosi antaa sinulle saumattoman pääsyn tekoälyyhdyskäytävään.{" "} + + Avaa tekoälyyhdyskäytävä + + + ), + "api base url": "API-perus-URL", + token: "Token", + "model section title": "Oletusmalli", + "model section helper": + "Tämä malli esikonfiguroidaan, kun käynnistät palvelun, joka tukee tekoälyavustusta.", + "model label": "Malli", + "no account": ({ webUiUrl }) => ( + <> + Sinulla ei vielä ole tiliä tekoälyyhdyskäytävässä. Kirjaudu ensin sisään + osoitteeseen{" "} + + {webUiUrl} + {" "} + luodaksesi tilisi. + + ) + }, AccountVaultTab: { "credentials section title": "Vault-todennustiedot", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index c82b1b715..1dbfa638f 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -17,7 +17,8 @@ export const translations: Translations<"fr"> = { text2: "Accédez à vos différentes informations de compte.", text3: "Configurez vos identifiants, e-mails, mots de passe et jetons d'accès personnels directement connectés à vos services.", "personal tokens tooltip": 'Ou en anglais "token".', - vault: "Vault" + vault: "Vault", + ai: "IA" }, AccountProfileTab: { "account id": "Identifiant de compte", @@ -97,6 +98,33 @@ export const translations: Translations<"fr"> = { "expires in": ({ howMuchTime }) => `Ces identifiants sont valables pour les ${howMuchTime} prochaines` }, + AccountAiGatewayTab: { + "credentials section title": "Identifiants de la passerelle IA", + "credentials section helper": ({ webUiUrl }) => ( + <> + Votre session OIDC vous donne accès à la passerelle IA.{" "} + + Ouvrir la passerelle IA + + + ), + "api base url": "URL de base de l'API", + token: "Jeton", + "model section title": "Modèle par défaut", + "model section helper": + "Ce modèle sera pré-configuré lors du lancement d'un service compatible avec l'assistance IA.", + "model label": "Modèle", + "no account": ({ webUiUrl }) => ( + <> + Vous n'avez pas encore de compte sur la passerelle IA. Veuillez + d'abord vous connecter sur{" "} + + {webUiUrl} + {" "} + pour créer votre compte. + + ) + }, AccountVaultTab: { "credentials section title": "Identifiants Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index fff3fd947..4ed6bfa14 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -17,7 +17,8 @@ export const translations: Translations<"it"> = { text2: "Accedi alle diverse informazioni del tuo account.", text3: "Configura le tue credenziali, email, password e token di accesso personale direttamente collegati ai tuoi servizi.", "personal tokens tooltip": 'O in inglese solo "token".', - vault: "Vault" + vault: "Vault", + ai: "IA" }, AccountProfileTab: { "account id": "Identificatore dell'account", @@ -95,6 +96,32 @@ export const translations: Translations<"it"> = { "expires in": ({ howMuchTime }) => `Queste credenziali sono valide per i prossimi ${howMuchTime}` }, + AccountAiGatewayTab: { + "credentials section title": "Credenziali del gateway IA", + "credentials section helper": ({ webUiUrl }) => ( + <> + La tua sessione OIDC ti dà accesso senza interruzioni al gateway IA.{" "} + + Apri gateway IA + + + ), + "api base url": "URL base dell'API", + token: "Token", + "model section title": "Modello predefinito", + "model section helper": + "Questo modello sarà preconfigurato quando avvierai un servizio che supporta l'assistenza IA.", + "model label": "Modello", + "no account": ({ webUiUrl }) => ( + <> + Non hai ancora un account sul gateway IA. Per favore accedi prima su{" "} + + {webUiUrl} + {" "} + per creare il tuo account. + + ) + }, AccountVaultTab: { "credentials section title": "Credenziali Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( @@ -509,9 +536,10 @@ export const translations: Translations<"it"> = { la nostra documentazione - {". \u00a0"} - Configurare il tuo Vault CLI locale - {"."} + .   + + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index e70227e01..86bd88252 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -17,7 +17,8 @@ export const translations: Translations<"nl"> = { text2: "Toegang tot uw accountgegevens.", text3: "Uw gebruikersnamen, e-mails, wachtwoorden en persoonlijke toegangstokens die direct verbonden zijn aan uw diensten configureren.", "personal tokens tooltip": 'Of "token" in het Engels.', - vault: "Vault" + vault: "Vault", + ai: "AI" }, AccountProfileTab: { "account id": "Account-ID", @@ -96,6 +97,32 @@ export const translations: Translations<"nl"> = { "expires in": ({ howMuchTime }) => `Deze inloggegevens zijn geldig voor de komende ${howMuchTime}` }, + AccountAiGatewayTab: { + "credentials section title": "AI-gateway-inloggegevens", + "credentials section helper": ({ webUiUrl }) => ( + <> + Uw OIDC-sessie geeft u naadloze toegang tot de AI-gateway.{" "} + + AI-gateway openen + + + ), + "api base url": "API-basis-URL", + token: "Token", + "model section title": "Standaardmodel", + "model section helper": + "Dit model wordt vooraf geconfigureerd wanneer u een service start die AI-ondersteuning ondersteunt.", + "model label": "Model", + "no account": ({ webUiUrl }) => ( + <> + U heeft nog geen account bij de AI-gateway. Meld u eerst aan bij{" "} + + {webUiUrl} + {" "} + om uw account aan te maken. + + ) + }, AccountVaultTab: { "credentials section title": "Gebrukersnamen Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 8912113f0..11707d40e 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -18,7 +18,8 @@ export const translations: Translations<"no"> = { text3: "Konfigurer brukernavn, e-postadresser, passord og personlige tilgangstokens direkte tilkoblet tjenestene dine.", "personal tokens tooltip": "Passord som genereres for deg og har en gitt gyldighetsperiode", - vault: "Vault" + vault: "Vault", + ai: "KI" }, AccountProfileTab: { "account id": "Kontoidentifikator", @@ -96,6 +97,32 @@ export const translations: Translations<"no"> = { "expires in": ({ howMuchTime }) => `Disse legitimasjonene er gyldige for de neste ${howMuchTime}` }, + AccountAiGatewayTab: { + "credentials section title": "AI-gateway-legitimasjon", + "credentials section helper": ({ webUiUrl }) => ( + <> + Din OIDC-økt gir deg sømløs tilgang til AI-gatewayen.{" "} + + Åpne AI-gateway + + + ), + "api base url": "API-basis-URL", + token: "Token", + "model section title": "Standardmodell", + "model section helper": + "Denne modellen vil bli forhåndskonfigurert når du starter en tjeneste som støtter AI-assistanse.", + "model label": "Modell", + "no account": ({ webUiUrl }) => ( + <> + Du har ikke en konto på AI-gatewayen ennå. Logg inn først på{" "} + + {webUiUrl} + {" "} + for å opprette kontoen din. + + ) + }, AccountVaultTab: { "credentials section title": "Vault credentials", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index d65a93ad2..2e1ed1b27 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -17,7 +17,8 @@ export const translations: Translations<"zh-CN"> = { text2: "访问我的账号信息", text3: "设置您的用户名, 电子邮件, 密码和访问令牌", "personal tokens tooltip": "服务的访问令牌", - vault: "Vault" + vault: "Vault", + ai: "AI" }, AccountProfileTab: { "account id": "账户标识符", @@ -87,6 +88,31 @@ export const translations: Translations<"zh-CN"> = { ), "expires in": ({ howMuchTime }) => `这些凭证在接下来的 ${howMuchTime} 内有效` }, + AccountAiGatewayTab: { + "credentials section title": "AI 网关凭据", + "credentials section helper": ({ webUiUrl }) => ( + <> + 您的 OIDC 会话使您可以无缝访问 AI 网关。{" "} + + 打开 AI 网关 + + + ), + "api base url": "API 基础 URL", + token: "令牌", + "model section title": "默认模型", + "model section helper": "当您启动支持 AI 辅助的服务时,将预先配置此模型。", + "model label": "模型", + "no account": ({ webUiUrl }) => ( + <> + 您还没有 AI 网关账户。请先登录{" "} + + {webUiUrl} + {" "} + 以创建您的账户。 + + ) + }, AccountVaultTab: { "credentials section title": "保险库凭证", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx new file mode 100644 index 000000000..9673d6d92 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -0,0 +1,158 @@ +import { useEffect, memo } from "react"; +import { useTranslation } from "ui/i18n"; +import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; +import { SettingField } from "ui/shared/SettingField"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { copyToClipboard } from "ui/tools/copyToClipboard"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { IconButton } from "onyxia-ui/IconButton"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { useCoreState, getCoreSync } from "core"; +import { smartTrim } from "ui/tools/smartTrim"; +import { getIconUrlByName } from "lazy-icons"; +import Divider from "@mui/material/Divider"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import { Text } from "onyxia-ui/Text"; + +export type Props = { + className?: string; +}; + +const AccountAiGatewayTab = memo((props: Props) => { + const { className } = props; + + const { classes } = useStyles(); + + const { + functions: { ai } + } = getCoreSync(); + + const uiState = useCoreState("ai", "main"); + + useEffect(() => { + if (uiState.isEnabled && uiState.token === undefined) { + ai.refreshToken(); + } + }, []); + + const { t } = useTranslation({ AccountAiGatewayTab }); + + const onFieldRequestCopyFactory = useCallbackFactory(([text]: [string]) => + copyToClipboard(text) + ); + + const onRefreshClick = useConstCallback(() => ai.refreshToken()); + + const onModelChange = useConstCallback((event: { target: { value: string } }) => + ai.setSelectedModel({ model: event.target.value }) + ); + + if (!uiState.isEnabled) { + const { initializationStatus } = uiState; + + if (initializationStatus === "pending") { + return ; + } + + if ( + initializationStatus === "no-account" && + "webUiUrl" in uiState && + uiState.webUiUrl !== undefined + ) { + return ( + + {t("no account", { webUiUrl: uiState.webUiUrl })} + + ); + } + + return null; + } + + if (uiState.token === undefined) { + return ; + } + + const { token, apiBase, webUiUrl, availableModels, selectedModel } = uiState; + + return ( +
+ + {t("credentials section helper", { webUiUrl })} +   + + + } + /> + + + + + + {t("model label")} + + +
+ ); +}); + +export default AccountAiGatewayTab; + +const { i18n } = declareComponentKeys< + | "credentials section title" + | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } + | "api base url" + | "token" + | "model section title" + | "model section helper" + | "model label" + | { K: "no account"; P: { webUiUrl: string }; R: JSX.Element } +>()({ AccountAiGatewayTab }); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ + divider: { + ...theme.spacing.topBottom("margin", 4) + }, + modelSelect: { + minWidth: 300, + marginTop: theme.spacing(2) + } +})); diff --git a/web/src/ui/pages/account/Page.tsx b/web/src/ui/pages/account/Page.tsx index 3be3dce6b..c275529b0 100644 --- a/web/src/ui/pages/account/Page.tsx +++ b/web/src/ui/pages/account/Page.tsx @@ -21,6 +21,7 @@ const Page = withLoader({ }); export default Page; +const AccountAiGatewayTab = lazy(() => import("./AccountAiTab")); const AccountGitTab = lazy(() => import("./AccountGitTab")); const AccountKubernetesTab = lazy(() => import("./AccountKubernetesTab")); const AccountProfileTab = lazy(() => import("./AccountProfileTab")); @@ -34,7 +35,7 @@ function Account() { const { t } = useTranslation({ Account }); const { - functions: { k8sCodeSnippets, vaultCredentials } + functions: { k8sCodeSnippets, vaultCredentials, ai } } = getCoreSync(); const tabs = useMemo( @@ -48,6 +49,7 @@ function Account() { .filter(accountTabId => accountTabId !== "vault" ? true : vaultCredentials.isAvailable() ) + .filter(accountTabId => (accountTabId !== "ai" ? true : ai.isAvailable())) .map(id => ({ id, title: t(id) })), [t] ); @@ -88,6 +90,8 @@ function Account() { return ; case "vault": return ; + case "ai": + return ; } assert>(false); })()} diff --git a/web/src/ui/pages/account/accountTabIds.ts b/web/src/ui/pages/account/accountTabIds.ts index 266eef08c..f1e1c5852 100644 --- a/web/src/ui/pages/account/accountTabIds.ts +++ b/web/src/ui/pages/account/accountTabIds.ts @@ -1,6 +1,7 @@ export const accountTabIds = [ "profile", "git", + "ai", "k8sCodeSnippets", "vault", "user-interface" From c3e3697be7122e1b941abdc76cfa4000ef6dc935 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Tue, 5 May 2026 10:55:00 +0200 Subject: [PATCH 02/25] following up --- web/CLAUDE.md | 108 +++++++ web/src/core/usecases/ai/selectors.ts | 12 +- web/src/core/usecases/ai/state.ts | 64 ++++- web/src/core/usecases/ai/thunks.ts | 152 +++++++++- web/src/ui/i18n/resources/de.tsx | 17 +- web/src/ui/i18n/resources/en.tsx | 15 +- web/src/ui/i18n/resources/es.tsx | 16 +- web/src/ui/i18n/resources/fi.tsx | 15 +- web/src/ui/i18n/resources/fr.tsx | 19 +- web/src/ui/i18n/resources/it.tsx | 17 +- web/src/ui/i18n/resources/nl.tsx | 17 +- web/src/ui/i18n/resources/no.tsx | 15 +- web/src/ui/i18n/resources/zh-CN.tsx | 14 +- web/src/ui/pages/account/AccountAiTab.tsx | 331 ++++++++++++++++++++-- 14 files changed, 754 insertions(+), 58 deletions(-) create mode 100644 web/CLAUDE.md diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 100644 index 000000000..6431ed7f4 --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +All commands use **Yarn** (not npm). + +```bash +yarn dev # Start dev server (processes env YAML first via scripts/unyamlify-env-local.ts) +yarn build # Type-check (tsc) then build for production +yarn test # Run all tests once (Vitest, non-watch) +yarn format # Format all .ts/.tsx/.json/.md files with Prettier +yarn format:check # Check formatting without writing +yarn storybook # Launch Storybook on port 6006 +``` + +**Run a single test file:** + +```bash +yarn vitest run src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts +``` + +**Run tests matching a name pattern:** + +```bash +yarn vitest run --reporter=verbose -t "pattern" +``` + +Pre-commit hooks run `eslint --fix` and `prettier --write` via lint-staged. + +## Architecture + +Onyxia Web is a React SPA — a data science platform portal for launching Kubernetes services (Helm charts), browsing catalogs, managing S3 files, managing Vault secrets, and querying data via DuckDB. It is deployed as static files served by nginx. + +### Core principles + +- **React is only for rendering.** Business logic is React-agnostic and lives in `src/core/`. The `src/ui/` layer is strictly for React components and hooks. +- **Unidirectional dependencies.** `src/core/` never imports from `src/ui/`, not even for types. +- **Reactive over promise-based.** Thunks update observable state; the UI reacts to state changes. Prefer dispatching actions and reading state over returning values from thunks. +- **Constants outside Redux state.** Values that don't change are not stored in state — they are retrieved from thunks when needed, to avoid unnecessary re-renders. + +### `src/core/` — Business logic + +Follows a clean-architecture / ports-and-adapters pattern using the `clean-architecture` npm package (a Redux-like store without Redux). + +- **`ports/`** — TypeScript interfaces defining contracts for external dependencies (`OnyxiaApi`, `Oidc`, `S3Client`, `SecretsManager`, `SqlOlap`). +- **`adapters/`** — Concrete implementations: `onyxiaApi/` (axios-based HTTP), `oidc/` (oidc-spa), `s3Client/` (AWS SDK v3), `secretManager/` (Vault), `sqlOlap/` (DuckDB WASM). Each adapter has a mock counterpart for dev/testing. +- **`usecases/`** — One folder per feature (20+ total: `catalog`, `launcher`, `serviceManagement`, `fileExplorer`, `secretExplorer`, `dataExplorer`, etc.). Each usecase follows the pattern: + - `state.ts` — state shape + `createUsecaseActions` (slice-like) + - `thunks.ts` — async side effects, accesses adapters via `createUsecaseContextApi` + - `selectors.ts` — memoized state derivations + - `index.ts` — re-exports all three +- **`bootstrap.ts`** — Wires adapters together and creates the core store. +- **`index.ts`** — Exports `useCoreState`, `getCore`, `createReactApi` bindings consumed by `src/ui/`. + +**Complex use-cases** (especially `launcher/`) have a `decoupledLogic/` subfolder with pure functions and no framework dependencies — this is where most unit tests live. + +### `src/ui/` — React layer + +- **`App/`** — Root layout: Header, LeftBar, Main, Footer. `App.tsx` triggers core bootstrap; `Main.tsx` is the route-based page switcher. +- **`pages/`** — One folder per route/page. Each page exports `routeDefs` (via `type-route`'s `defineRoute`) and `routeGroup`. All are merged in `pages/index.ts`. +- **`routes.tsx`** — Router instantiation. Navigation uses `routes.catalog(...).push()` or `session.push()`. +- **`i18n/`** — i18nifty setup. Translation keys are declared at the component level via `declareComponentKeys`, collected into a `ComponentKey` union in `i18n/types.ts`. Nine languages: en, fr, zh-CN, no, fi, nl, it, es, de. +- **`theme/`** — onyxia-ui theme setup (palette, fonts, favicon). +- **`shared/`** — Reusable components (CommandBar, CodeBlock, SettingField, etc.). + +### Key patterns + +**Consuming core state in React:** + +```ts +import { useCoreState, getCore } from "core"; +const helmReleases = useCoreState(state => state.serviceManagement.helmReleases); +await getCore().dispatch(usecases.serviceManagement.thunks.initialize()); +``` + +**Styling — tss-react** (not plain CSS modules): + +```ts +import { tss } from "tss"; +const useStyles = tss.withName({ MyComponent }).create(({ theme }) => ({ ... })); +const { classes, cx } = useStyles(); +``` + +**Absolute imports** — `tsconfig.json` sets `baseUrl: "src"`, so use `import { foo } from "core/usecases/catalog"` (not relative paths). + +**Environment variables** — All env vars are centrally parsed and validated in `src/env.ts`. The `index.html` is an EJS template processed by `vite-envs` at build time. + +**Authentication** — OIDC init (`oidc-spa`) happens before React renders, in `main.tsx`. Use the `Oidc` port interface, not the adapter directly. + +**Plugin system** — `src/pluginSystem.ts` exposes `window.onyxia` after boot and fires an `"onyxiaready"` `CustomEvent`, allowing external JS to interact with core state, routes, theme, and i18n. + +**Keycloak theme** — `src/keycloak-theme/` is a Keycloakify login theme that shares env and i18n infrastructure with the main app. Build with `yarn build-keycloak-theme`. + +## Key libraries + +| Library | Role | +| -------------------- | ------------------------------------------------------------ | +| `onyxia-ui` | In-house design system on top of MUI v6 | +| `type-route` | Strongly-typed client-side router | +| `i18nifty` | Component-level i18n | +| `clean-architecture` | Redux-like store (ports/usecases pattern) | +| `oidc-spa` | OIDC/OAuth2 authentication | +| `keycloakify` | Keycloak login theme from React components | +| `tss-react` | CSS-in-JS bound to onyxia-ui theme | +| `vite-envs` | Env var injection into EJS `index.html` at build time | +| DuckDB WASM | In-browser SQL OLAP queries (`dataExplorer`, `sqlOlapShell`) | diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index f34e34614..ab254f608 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -29,7 +29,14 @@ const main = createSelector( return { isEnabled: false as const, initializationStatus }; } - const { webUiUrl, apiBase, token, availableModels, selectedModel } = state; + const { + webUiUrl, + apiBase, + token, + availableModels, + selectedModel, + customProviders + } = state; return { isEnabled: true as const, @@ -37,7 +44,8 @@ const main = createSelector( apiBase, token, availableModels, - selectedModel + selectedModel, + customProviders }; } ); diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts index 99bf8510c..8bc99a013 100644 --- a/web/src/core/usecases/ai/state.ts +++ b/web/src/core/usecases/ai/state.ts @@ -3,6 +3,16 @@ import { id } from "tsafe/id"; export const name = "ai"; +export type CustomAiProvider = { + id: string; + label: string; + apiBase: string; + apiKey: string; + availableModels: string[]; + selectedModel: string | undefined; + modelsFetchStatus: "fetching" | "success" | "error"; +}; + type State = State.Disabled | State.Enabled; export declare namespace State { @@ -18,6 +28,7 @@ export declare namespace State { token: string | undefined; availableModels: string[]; selectedModel: string | undefined; + customProviders: CustomAiProvider[]; }; } @@ -50,10 +61,18 @@ export const { reducer, actions } = createUsecaseActions({ token: string; availableModels: string[]; selectedModel: string | undefined; + customProviders: CustomAiProvider[]; }; } ) => { - const { webUiUrl, apiBase, token, availableModels, selectedModel } = payload; + const { + webUiUrl, + apiBase, + token, + availableModels, + selectedModel, + customProviders + } = payload; return id({ isEnabled: true, @@ -61,7 +80,8 @@ export const { reducer, actions } = createUsecaseActions({ apiBase, token, availableModels, - selectedModel: selectedModel ?? availableModels[0] + selectedModel: selectedModel ?? availableModels[0], + customProviders }); }, tokenRefreshed: (state, { payload }: { payload: { token: string } }) => { @@ -75,6 +95,46 @@ export const { reducer, actions } = createUsecaseActions({ selectedModelSet: (state, { payload }: { payload: { model: string } }) => { if (!state.isEnabled) return; state.selectedModel = payload.model; + }, + customProviderAdded: (state, { payload }: { payload: CustomAiProvider }) => { + if (!state.isEnabled) return; + state.customProviders.push(payload); + }, + customProviderDeleted: (state, { payload }: { payload: { id: string } }) => { + if (!state.isEnabled) return; + const i = state.customProviders.findIndex(p => p.id === payload.id); + if (i !== -1) state.customProviders.splice(i, 1); + }, + customProviderModelsLoaded: ( + state, + { payload }: { payload: { id: string; models: string[] } } + ) => { + if (!state.isEnabled) return; + const provider = state.customProviders.find(p => p.id === payload.id); + if (provider === undefined) return; + provider.availableModels = payload.models; + provider.modelsFetchStatus = "success"; + if (provider.selectedModel === undefined && payload.models.length > 0) { + provider.selectedModel = payload.models[0]; + } + }, + customProviderModelsFetchFailed: ( + state, + { payload }: { payload: { id: string } } + ) => { + if (!state.isEnabled) return; + const provider = state.customProviders.find(p => p.id === payload.id); + if (provider === undefined) return; + provider.modelsFetchStatus = "error"; + }, + customProviderSelectedModelSet: ( + state, + { payload }: { payload: { id: string; model: string } } + ) => { + if (!state.isEnabled) return; + const provider = state.customProviders.find(p => p.id === payload.id); + if (provider === undefined) return; + provider.selectedModel = payload.model; } } }); diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index c4f8ffcde..c826ed6bb 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -1,10 +1,53 @@ import type { Thunks } from "core/bootstrap"; import { actions } from "./state"; +import type { CustomAiProvider } from "./state"; import { getLocalStorage } from "core/tools/safeLocalStorage"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { assert } from "tsafe"; +import { id } from "tsafe/id"; const SELECTED_MODEL_STORAGE_KEY = "onyxia:ai:selectedModel"; +const CUSTOM_PROVIDERS_STORAGE_KEY = "onyxia:ai:customProviders"; + +type PersistedCustomProvider = { + id: string; + label: string; + apiBase: string; + apiKey: string; + selectedModel: string | undefined; +}; + +type LocalStorageLike = Pick; + +function readPersistedProviders( + localStorage: LocalStorageLike +): PersistedCustomProvider[] { + const raw = localStorage.getItem(CUSTOM_PROVIDERS_STORAGE_KEY); + if (raw === null) return []; + try { + return JSON.parse(raw) as PersistedCustomProvider[]; + } catch { + return []; + } +} + +function writePersistedProviders( + localStorage: LocalStorageLike, + providers: PersistedCustomProvider[] +): void { + localStorage.setItem(CUSTOM_PROVIDERS_STORAGE_KEY, JSON.stringify(providers)); +} + +async function fetchModels(apiBase: string, apiKey: string): Promise { + const response = await fetch(`${apiBase}/models`, { + headers: { Authorization: `Bearer ${apiKey}` } + }); + if (!response.ok) { + throw new Error(`Failed to fetch models (${response.status})`); + } + const data = await response.json(); + return (data.data as { id: string }[]).map(m => m.id); +} export const thunks = { isAvailable: @@ -42,6 +85,85 @@ export const thunks = { localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, model); dispatch(actions.selectedModelSet({ model })); + }, + addCustomProvider: + (params: { label: string; apiBase: string; apiKey: string }) => + async (...args) => { + const { label, apiBase, apiKey } = params; + const [dispatch] = args; + + const { localStorage } = getLocalStorage(); + + const providerId = crypto.randomUUID(); + + dispatch( + actions.customProviderAdded( + id({ + id: providerId, + label, + apiBase, + apiKey, + availableModels: [], + selectedModel: undefined, + modelsFetchStatus: "fetching" + }) + ) + ); + + const persisted = readPersistedProviders(localStorage); + persisted.push({ + id: providerId, + label, + apiBase, + apiKey, + selectedModel: undefined + }); + writePersistedProviders(localStorage, persisted); + + try { + const models = await fetchModels(apiBase, apiKey); + dispatch(actions.customProviderModelsLoaded({ id: providerId, models })); + } catch { + dispatch(actions.customProviderModelsFetchFailed({ id: providerId })); + } + }, + deleteCustomProvider: + (params: { id: string }) => + (...args) => { + const { id } = params; + const [dispatch] = args; + + const { localStorage } = getLocalStorage(); + + dispatch(actions.customProviderDeleted({ id })); + + const persisted = readPersistedProviders(localStorage).filter( + p => p.id !== id + ); + writePersistedProviders(localStorage, persisted); + }, + testCustomProvider: + (params: { apiBase: string; apiKey: string }) => + async (..._args): Promise => { + const { apiBase, apiKey } = params; + return fetchModels(apiBase, apiKey); + }, + setCustomProviderSelectedModel: + (params: { id: string; model: string }) => + (...args) => { + const { id, model } = params; + const [dispatch] = args; + + const { localStorage } = getLocalStorage(); + + dispatch(actions.customProviderSelectedModelSet({ id, model })); + + const persisted = readPersistedProviders(localStorage); + const entry = persisted.find(p => p.id === id); + if (entry !== undefined) { + entry.selectedModel = model; + writePersistedProviders(localStorage, persisted); + } } } satisfies Thunks; @@ -69,6 +191,20 @@ export const protectedThunks = { const { token } = tokenResult; const availableModels = await ai.listModels(token); + const persisted = readPersistedProviders(localStorage); + + const customProviders: CustomAiProvider[] = persisted.map(p => + id({ + id: p.id, + label: p.label, + apiBase: p.apiBase, + apiKey: p.apiKey, + availableModels: [], + selectedModel: p.selectedModel, + modelsFetchStatus: "fetching" + }) + ); + dispatch( actions.initializeSucceed({ webUiUrl: ai.webUiUrl, @@ -76,7 +212,21 @@ export const protectedThunks = { token, availableModels, selectedModel: - localStorage.getItem(SELECTED_MODEL_STORAGE_KEY) ?? undefined + localStorage.getItem(SELECTED_MODEL_STORAGE_KEY) ?? undefined, + customProviders + }) + ); + + await Promise.all( + persisted.map(async p => { + try { + const models = await fetchModels(p.apiBase, p.apiKey); + dispatch( + actions.customProviderModelsLoaded({ id: p.id, models }) + ); + } catch { + dispatch(actions.customProviderModelsFetchFailed({ id: p.id })); + } }) ); } diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 453f09161..2fb18827b 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -110,10 +110,21 @@ export const translations: Translations<"de"> = { ), "api base url": "API-Basis-URL", token: "Token", - "model section title": "Standardmodell", - "model section helper": - "Dieses Modell wird vorkonfiguriert, wenn Sie einen Dienst starten, der KI-Unterstützung unterstützt.", "model label": "Modell", + "custom providers section title": "Benutzerdefinierte KI-Anbieter", + "custom providers section helper": + "Fügen Sie Ihre eigenen KI-Anbieter hinzu (OpenAI, Anthropic oder jeden OpenAI-kompatiblen Endpunkt). Die Anmeldedaten werden in Ihrem Browser gespeichert.", + "custom provider label field": "Name", + "custom provider api base field": "API-Basis-URL", + "custom provider api key field": "API-Schlüssel", + "provider test": "Verbindung testen", + "provider test success": "Verbindung erfolgreich", + "provider test error": + "Verbindung fehlgeschlagen — URL und API-Schlüssel prüfen.", + "provider save": "Hinzufügen", + "provider cancel": "Abbrechen", + "models fetch error": + "Modelle konnten nicht abgerufen werden — überprüfen Sie URL und API-Schlüssel.", "no account": ({ webUiUrl }) => ( <> Sie haben noch kein Konto beim KI-Gateway. Bitte melden Sie sich zuerst an diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index f1d31d378..58c63732e 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -108,10 +108,19 @@ export const translations: Translations<"en"> = { ), "api base url": "API base URL", token: "Token", - "model section title": "Default model", - "model section helper": - "This model will be pre-configured when you launch a service that supports AI assistance.", "model label": "Model", + "custom providers section title": "Custom AI providers", + "custom providers section helper": + "Add your own AI providers (OpenAI, Anthropic, or any OpenAI-compatible endpoint). Credentials are stored in your browser.", + "custom provider label field": "Label", + "custom provider api base field": "API base URL", + "custom provider api key field": "API key", + "provider test": "Test connection", + "provider test success": "Connection successful", + "provider test error": "Unable to connect — check URL and API key.", + "provider save": "Add", + "provider cancel": "Cancel", + "models fetch error": "Unable to fetch models — check your URL and API key.", "no account": ({ webUiUrl }) => ( <> You don't have an AI gateway account yet. Please log in to{" "} diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 260a45782..2b19a794a 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -109,10 +109,20 @@ export const translations: Translations<"es"> = { ), "api base url": "URL base de la API", token: "Token", - "model section title": "Modelo predeterminado", - "model section helper": - "Este modelo se preconfigurará al lanzar un servicio que admita asistencia de IA.", "model label": "Modelo", + "custom providers section title": "Proveedores de IA personalizados", + "custom providers section helper": + "Añade tus propios proveedores de IA con una URL base y clave API.", + "custom provider label field": "Etiqueta", + "custom provider api base field": "URL base de la API", + "custom provider api key field": "Clave API", + "provider test": "Probar conexión", + "provider test success": "Conexión exitosa", + "provider test error": "No se puede conectar — compruebe la URL y la clave API.", + "provider save": "Añadir", + "provider cancel": "Cancelar", + "models fetch error": + "No se pueden obtener los modelos — compruebe la URL y la clave API.", "no account": ({ webUiUrl }) => ( <> Aún no tiene una cuenta en la pasarela de IA. Por favor, inicie sesión diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index dc42cad0b..c867f1342 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -109,10 +109,19 @@ export const translations: Translations<"fi"> = { ), "api base url": "API-perus-URL", token: "Token", - "model section title": "Oletusmalli", - "model section helper": - "Tämä malli esikonfiguroidaan, kun käynnistät palvelun, joka tukee tekoälyavustusta.", "model label": "Malli", + "custom providers section title": "Mukautetut tekoälyntarjoajat", + "custom providers section helper": + "Lisää omat tekoälyntarjoajasi perus-URL:lla ja API-avaimella.", + "custom provider label field": "Tunniste", + "custom provider api base field": "API-perus-URL", + "custom provider api key field": "API-avain", + "provider test": "Testaa yhteys", + "provider test success": "Yhteys onnistui", + "provider test error": "Yhteyttä ei voi muodostaa — tarkista URL ja API-avain.", + "provider save": "Lisää", + "provider cancel": "Peruuta", + "models fetch error": "Mallien haku epäonnistui — tarkista URL ja API-avain.", "no account": ({ webUiUrl }) => ( <> Sinulla ei vielä ole tiliä tekoälyyhdyskäytävässä. Kirjaudu ensin sisään diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 1dbfa638f..451870f1b 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -110,10 +110,21 @@ export const translations: Translations<"fr"> = { ), "api base url": "URL de base de l'API", token: "Jeton", - "model section title": "Modèle par défaut", - "model section helper": - "Ce modèle sera pré-configuré lors du lancement d'un service compatible avec l'assistance IA.", - "model label": "Modèle", + "model label": "Modèles", + "custom providers section title": "Providers IA personnalisés", + "custom providers section helper": + "Ajoutez vos propres providers IA (OpenAI, Anthropic, ou tout endpoint compatible OpenAI). Les identifiants sont stockés dans votre navigateur.", + "custom provider label field": "Nom", + "custom provider api base field": "URL de base de l'API", + "custom provider api key field": "Clé API", + "provider test": "Tester la connexion", + "provider test success": "Connexion réussie", + "provider test error": + "Impossible de se connecter — vérifiez l'URL et la clé API.", + "provider save": "Ajouter", + "provider cancel": "Annuler", + "models fetch error": + "Impossible de récupérer les modèles — vérifiez l'URL et la clé API.", "no account": ({ webUiUrl }) => ( <> Vous n'avez pas encore de compte sur la passerelle IA. Veuillez diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 4ed6bfa14..8c19af490 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -108,10 +108,21 @@ export const translations: Translations<"it"> = { ), "api base url": "URL base dell'API", token: "Token", - "model section title": "Modello predefinito", - "model section helper": - "Questo modello sarà preconfigurato quando avvierai un servizio che supporta l'assistenza IA.", "model label": "Modello", + "custom providers section title": "Provider IA personalizzati", + "custom providers section helper": + "Aggiungi i tuoi provider IA con un URL base e una chiave API.", + "custom provider label field": "Etichetta", + "custom provider api base field": "URL base API", + "custom provider api key field": "Chiave API", + "provider test": "Testa connessione", + "provider test success": "Connessione riuscita", + "provider test error": + "Impossibile connettersi — controlla l'URL e la chiave API.", + "provider save": "Aggiungi", + "provider cancel": "Annulla", + "models fetch error": + "Impossibile recuperare i modelli — controlla l'URL e la chiave API.", "no account": ({ webUiUrl }) => ( <> Non hai ancora un account sul gateway IA. Per favore accedi prima su{" "} diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 86bd88252..6ae60a0b3 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -109,10 +109,21 @@ export const translations: Translations<"nl"> = { ), "api base url": "API-basis-URL", token: "Token", - "model section title": "Standaardmodel", - "model section helper": - "Dit model wordt vooraf geconfigureerd wanneer u een service start die AI-ondersteuning ondersteunt.", "model label": "Model", + "custom providers section title": "Aangepaste AI-providers", + "custom providers section helper": + "Voeg uw eigen AI-providers toe met een basis-URL en API-sleutel.", + "custom provider label field": "Label", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-sleutel", + "provider test": "Verbinding testen", + "provider test success": "Verbinding geslaagd", + "provider test error": + "Kan geen verbinding maken — controleer URL en API-sleutel.", + "provider save": "Toevoegen", + "provider cancel": "Annuleren", + "models fetch error": + "Kan modellen niet ophalen — controleer uw URL en API-sleutel.", "no account": ({ webUiUrl }) => ( <> U heeft nog geen account bij de AI-gateway. Meld u eerst aan bij{" "} diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 11707d40e..cdba25800 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -109,10 +109,19 @@ export const translations: Translations<"no"> = { ), "api base url": "API-basis-URL", token: "Token", - "model section title": "Standardmodell", - "model section helper": - "Denne modellen vil bli forhåndskonfigurert når du starter en tjeneste som støtter AI-assistanse.", "model label": "Modell", + "custom providers section title": "Tilpassede AI-leverandører", + "custom providers section helper": + "Legg til dine egne AI-leverandører med en basis-URL og API-nøkkel.", + "custom provider label field": "Etikett", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-nøkkel", + "provider test": "Test tilkobling", + "provider test success": "Tilkobling vellykket", + "provider test error": "Kan ikke koble til — sjekk URL og API-nøkkel.", + "provider save": "Legg til", + "provider cancel": "Avbryt", + "models fetch error": "Kan ikke hente modeller — sjekk URL-en og API-nøkkelen.", "no account": ({ webUiUrl }) => ( <> Du har ikke en konto på AI-gatewayen ennå. Logg inn først på{" "} diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 2e1ed1b27..36f046019 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -100,9 +100,19 @@ export const translations: Translations<"zh-CN"> = { ), "api base url": "API 基础 URL", token: "令牌", - "model section title": "默认模型", - "model section helper": "当您启动支持 AI 辅助的服务时,将预先配置此模型。", "model label": "模型", + "custom providers section title": "自定义 AI 提供商", + "custom providers section helper": + "使用基础 URL 和 API 密钥添加您自己的 AI 提供商。", + "custom provider label field": "标签", + "custom provider api base field": "API 基础 URL", + "custom provider api key field": "API 密钥", + "provider test": "测试连接", + "provider test success": "连接成功", + "provider test error": "无法连接 — 请检查 URL 和 API 密钥。", + "provider save": "添加", + "provider cancel": "取消", + "models fetch error": "无法获取模型 — 请检查您的 URL 和 API 密钥。", "no account": ({ webUiUrl }) => ( <> 您还没有 AI 网关账户。请先登录{" "} diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx index 9673d6d92..16f3346c4 100644 --- a/web/src/ui/pages/account/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, memo } from "react"; +import { useEffect, memo, useState } from "react"; import { useTranslation } from "ui/i18n"; import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; import { SettingField } from "ui/shared/SettingField"; @@ -9,15 +9,17 @@ import { declareComponentKeys } from "i18nifty"; import { useConstCallback } from "powerhooks/useConstCallback"; import { IconButton } from "onyxia-ui/IconButton"; import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; import { useCoreState, getCoreSync } from "core"; import { smartTrim } from "ui/tools/smartTrim"; import { getIconUrlByName } from "lazy-icons"; import Divider from "@mui/material/Divider"; import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; -import FormControl from "@mui/material/FormControl"; -import InputLabel from "@mui/material/InputLabel"; +import TextField from "@mui/material/TextField"; import { Text } from "onyxia-ui/Text"; +import type { CustomAiProvider } from "core/usecases/ai/state"; export type Props = { className?: string; @@ -52,6 +54,63 @@ const AccountAiGatewayTab = memo((props: Props) => { ai.setSelectedModel({ model: event.target.value }) ); + const onCustomProviderModelChangeFactory = useCallbackFactory( + ([id]: [string], [event]: [{ target: { value: string } }]) => + ai.setCustomProviderSelectedModel({ id, model: event.target.value }) + ); + + const onDeleteCustomProviderFactory = useCallbackFactory(([id]: [string]) => + ai.deleteCustomProvider({ id }) + ); + + const [addFormOpen, setAddFormOpen] = useState(false); + const [pendingLabel, setPendingLabel] = useState(""); + const [pendingApiBase, setPendingApiBase] = useState(""); + const [pendingApiKey, setPendingApiKey] = useState(""); + const [testStatus, setTestStatus] = useState< + "idle" | "testing" | "success" | "error" + >("idle"); + const [testModelCount, setTestModelCount] = useState(0); + + const onAddClick = useConstCallback(() => setAddFormOpen(true)); + + const onCancelAdd = useConstCallback(() => { + setAddFormOpen(false); + setPendingLabel(""); + setPendingApiBase(""); + setPendingApiKey(""); + setTestStatus("idle"); + setTestModelCount(0); + }); + + const onTestProvider = useConstCallback(async () => { + setTestStatus("testing"); + try { + const models = await ai.testCustomProvider({ + apiBase: pendingApiBase, + apiKey: pendingApiKey + }); + setTestModelCount(models.length); + setTestStatus("success"); + } catch { + setTestStatus("error"); + } + }); + + const onSaveProvider = useConstCallback(async () => { + await ai.addCustomProvider({ + label: pendingLabel, + apiBase: pendingApiBase, + apiKey: pendingApiKey + }); + setAddFormOpen(false); + setPendingLabel(""); + setPendingApiBase(""); + setPendingApiKey(""); + setTestStatus("idle"); + setTestModelCount(0); + }); + if (!uiState.isEnabled) { const { initializationStatus } = uiState; @@ -78,7 +137,8 @@ const AccountAiGatewayTab = memo((props: Props) => { return ; } - const { token, apiBase, webUiUrl, availableModels, selectedModel } = uiState; + const { token, apiBase, webUiUrl, availableModels, selectedModel, customProviders } = + uiState; return (
@@ -110,25 +170,188 @@ const AccountAiGatewayTab = memo((props: Props) => { onRequestCopy={onFieldRequestCopyFactory(token)} isSensitiveInformation={true} /> +
+
+ {t("model label")} +
+
+ +
+
+ - + + +
+ + {customProviders.map(provider => ( + + ))} + + + setPendingLabel(e.target.value)} + size="small" + fullWidth + /> + { + setPendingApiBase(e.target.value); + setTestStatus("idle"); + }} + size="small" + fullWidth + placeholder="https://api.openai.com/v1" + /> + { + setPendingApiKey(e.target.value); + setTestStatus("idle"); + }} + size="small" + fullWidth + type="password" + /> +
+ + {testStatus === "success" && ( + + {t("provider test success")} ({testModelCount}) + + )} + {testStatus === "error" && ( + + {t("provider test error")} + + )} +
+ + } + buttons={ + <> + + + + } /> - - {t("model label")} - - + + ); +}); + +type CustomProviderCardProps = { + provider: CustomAiProvider; + onModelChange: (event: { target: { value: string } }) => void; + onDelete: () => void; + modelLabel: string; + modelsErrorLabel: string; +}; + +const CustomProviderCard = memo((props: CustomProviderCardProps) => { + const { provider, onModelChange, onDelete, modelLabel, modelsErrorLabel } = props; + + const { classes } = useStyles(); + + return ( +
+
+ {provider.label} + +
+
+
+ {modelLabel} +
+
+ {provider.modelsFetchStatus === "fetching" && ( + + )} + {provider.modelsFetchStatus === "error" && ( + + {modelsErrorLabel} + + )} + {provider.modelsFetchStatus === "success" && + provider.availableModels.length > 0 && ( + + )} +
+
); }); @@ -140,9 +363,18 @@ const { i18n } = declareComponentKeys< | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } | "api base url" | "token" - | "model section title" - | "model section helper" | "model label" + | "custom providers section title" + | "custom providers section helper" + | "custom provider label field" + | "custom provider api base field" + | "custom provider api key field" + | "provider test" + | "provider test success" + | "provider test error" + | "provider save" + | "provider cancel" + | "models fetch error" | { K: "no account"; P: { webUiUrl: string }; R: JSX.Element } >()({ AccountAiGatewayTab }); export type I18n = typeof i18n; @@ -151,8 +383,55 @@ const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ divider: { ...theme.spacing.topBottom("margin", 4) }, - modelSelect: { - minWidth: 300, - marginTop: theme.spacing(2) + modelRow: { + display: "flex", + alignItems: "center", + marginBottom: theme.spacing(3) + }, + modelRowTitle: { + width: 360, + display: "flex", + alignItems: "center" + }, + modelRowControl: { + flex: 1, + display: "flex", + alignItems: "center" + }, + customProvidersSectionHeader: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(1) + }, + providerCard: { + border: `1px solid ${theme.colors.useCases.typography.textDisabled}`, + borderRadius: theme.spacing(1), + padding: theme.spacing(3), + marginTop: theme.spacing(3) + }, + providerCardHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: theme.spacing(2) + }, + modelsError: { + color: theme.colors.useCases.alertSeverity.error.main + }, + addFormFields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(4) + }, + testRow: { + display: "flex", + alignItems: "center", + gap: theme.spacing(3) + }, + testSuccess: { + color: theme.colors.useCases.alertSeverity.success.main + }, + testError: { + color: theme.colors.useCases.alertSeverity.error.main } })); From 3b37d0a56bfd00223eba952d94d8b64b48d56347 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:07:49 +0200 Subject: [PATCH 03/25] improve aiModel --- web/src/core/adapters/ai/mock.ts | 6 +++++- web/src/core/adapters/ai/openWebUi.ts | 7 +++++-- web/src/core/ports/Ai.ts | 2 +- web/src/core/usecases/ai/state.ts | 14 ++++++++------ web/src/core/usecases/ai/thunks.ts | 13 ++++++++----- web/src/ui/pages/account/AccountAiTab.tsx | 12 ++++++------ 6 files changed, 33 insertions(+), 21 deletions(-) diff --git a/web/src/core/adapters/ai/mock.ts b/web/src/core/adapters/ai/mock.ts index 9b629904d..63dc35a4f 100644 --- a/web/src/core/adapters/ai/mock.ts +++ b/web/src/core/adapters/ai/mock.ts @@ -7,6 +7,10 @@ export function createAi(params: { webUiUrl: string }): Ai { webUiUrl, apiBase: `${webUiUrl}/api`, getToken: async () => ({ status: "success" as const, token: "mock-ai-token" }), - listModels: async () => ["llama3.2", "mistral-7b", "codestral"] + listModels: async () => [ + { id: "llama3.2", name: "Llama 3.2" }, + { id: "mistral-7b", name: "Mistral 7B" }, + { id: "codestral", name: "Codestral" } + ] }; } diff --git a/web/src/core/adapters/ai/openWebUi.ts b/web/src/core/adapters/ai/openWebUi.ts index a2b07e9c4..38f7902a5 100644 --- a/web/src/core/adapters/ai/openWebUi.ts +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -1,5 +1,6 @@ import type { Ai, GetTokenResult } from "core/ports/Ai"; import { oidcTokenExchange, OidcTokenExchangeError } from "core/tools/oidcTokenExchange"; +import { z } from "zod"; export function createAi(params: { webUiUrl: string; @@ -37,9 +38,11 @@ export function createAi(params: { throw new Error(`Failed to list models (${response.status})`); } - const data = await response.json(); + const { data } = z + .object({ data: z.array(z.object({ id: z.string(), name: z.string() })) }) + .parse(await response.json()); - return (data.data as { id: string }[]).map(m => m.id); + return data.map(({ id, name }) => ({ id, name })); } }; } diff --git a/web/src/core/ports/Ai.ts b/web/src/core/ports/Ai.ts index 1a4dbde8f..c0d046221 100644 --- a/web/src/core/ports/Ai.ts +++ b/web/src/core/ports/Ai.ts @@ -2,7 +2,7 @@ export type Ai = { webUiUrl: string; apiBase: string; getToken: () => Promise; - listModels: (token: string) => Promise; + listModels: (token: string) => Promise<{ id: string; name: string }[]>; }; export type GetTokenResult = diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts index 8bc99a013..2361846a4 100644 --- a/web/src/core/usecases/ai/state.ts +++ b/web/src/core/usecases/ai/state.ts @@ -3,12 +3,14 @@ import { id } from "tsafe/id"; export const name = "ai"; +export type AiModel = { id: string; name: string }; + export type CustomAiProvider = { id: string; label: string; apiBase: string; apiKey: string; - availableModels: string[]; + availableModels: AiModel[]; selectedModel: string | undefined; modelsFetchStatus: "fetching" | "success" | "error"; }; @@ -26,7 +28,7 @@ export declare namespace State { webUiUrl: string; apiBase: string; token: string | undefined; - availableModels: string[]; + availableModels: AiModel[]; selectedModel: string | undefined; customProviders: CustomAiProvider[]; }; @@ -59,7 +61,7 @@ export const { reducer, actions } = createUsecaseActions({ webUiUrl: string; apiBase: string; token: string; - availableModels: string[]; + availableModels: AiModel[]; selectedModel: string | undefined; customProviders: CustomAiProvider[]; }; @@ -80,7 +82,7 @@ export const { reducer, actions } = createUsecaseActions({ apiBase, token, availableModels, - selectedModel: selectedModel ?? availableModels[0], + selectedModel: selectedModel ?? availableModels[0]?.id, customProviders }); }, @@ -107,7 +109,7 @@ export const { reducer, actions } = createUsecaseActions({ }, customProviderModelsLoaded: ( state, - { payload }: { payload: { id: string; models: string[] } } + { payload }: { payload: { id: string; models: AiModel[] } } ) => { if (!state.isEnabled) return; const provider = state.customProviders.find(p => p.id === payload.id); @@ -115,7 +117,7 @@ export const { reducer, actions } = createUsecaseActions({ provider.availableModels = payload.models; provider.modelsFetchStatus = "success"; if (provider.selectedModel === undefined && payload.models.length > 0) { - provider.selectedModel = payload.models[0]; + provider.selectedModel = payload.models[0].id; } }, customProviderModelsFetchFailed: ( diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index c826ed6bb..36dc98758 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -1,6 +1,7 @@ import type { Thunks } from "core/bootstrap"; import { actions } from "./state"; -import type { CustomAiProvider } from "./state"; +import type { AiModel, CustomAiProvider } from "./state"; +import { z } from "zod"; import { getLocalStorage } from "core/tools/safeLocalStorage"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { assert } from "tsafe"; @@ -38,15 +39,17 @@ function writePersistedProviders( localStorage.setItem(CUSTOM_PROVIDERS_STORAGE_KEY, JSON.stringify(providers)); } -async function fetchModels(apiBase: string, apiKey: string): Promise { +async function fetchModels(apiBase: string, apiKey: string): Promise { const response = await fetch(`${apiBase}/models`, { headers: { Authorization: `Bearer ${apiKey}` } }); if (!response.ok) { throw new Error(`Failed to fetch models (${response.status})`); } - const data = await response.json(); - return (data.data as { id: string }[]).map(m => m.id); + const { data } = z + .object({ data: z.array(z.object({ id: z.string(), name: z.string() })) }) + .parse(await response.json()); + return data.map(({ id, name }) => ({ id, name })); } export const thunks = { @@ -144,7 +147,7 @@ export const thunks = { }, testCustomProvider: (params: { apiBase: string; apiKey: string }) => - async (..._args): Promise => { + async (..._args): Promise => { const { apiBase, apiKey } = params; return fetchModels(apiBase, apiKey); }, diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx index 16f3346c4..a7ecc78aa 100644 --- a/web/src/ui/pages/account/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -180,9 +180,9 @@ const AccountAiGatewayTab = memo((props: Props) => { onChange={onModelChange} size="small" > - {availableModels.map(model => ( - - {model} + {availableModels.map(({ id, name }) => ( + + {name} ))} @@ -343,9 +343,9 @@ const CustomProviderCard = memo((props: CustomProviderCardProps) => { onChange={onModelChange} size="small" > - {provider.availableModels.map(model => ( - - {model} + {provider.availableModels.map(({ id, name }) => ( + + {name} ))} From f9ab65441b77142dd8668433d656bbd6c6f270e1 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:15:14 +0200 Subject: [PATCH 04/25] rebase with correct index --- web/src/ui/i18n/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index f7200dd3e..f358689fc 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -55,6 +55,7 @@ export type ComponentKey = | import("ui/pages/account/AccountKubernetesTab").I18n | import("ui/pages/account/AccountUserInterfaceTab").I18n | import("ui/pages/account/AccountVaultTab").I18n + | import("ui/pages/account/AccountAiTab").I18n | import("ui/App/Footer").I18n | import("ui/pages/catalog/Page").I18n | import("ui/pages/catalog/CatalogChartCard").I18n From edcb15a8f3a82bc3840a27b2c922704270d59581 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:51:47 +0200 Subject: [PATCH 05/25] wip --- web/src/core/adapters/ai/mock.ts | 6 +- web/src/core/adapters/ai/openWebUi.ts | 6 +- web/src/core/adapters/onyxiaApi/ApiTypes.ts | 6 +- web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 32 +- web/src/core/bootstrap.ts | 57 +- web/src/core/ports/Ai.ts | 2 + .../core/ports/OnyxiaApi/DeploymentRegion.ts | 14 +- web/src/core/ports/OnyxiaApi/XOnyxia.ts | 6 +- .../ai/decoupledLogic/persistedAiConfig.ts | 88 ++++ web/src/core/usecases/ai/selectors.ts | 95 ++-- web/src/core/usecases/ai/state.ts | 266 ++++++---- web/src/core/usecases/ai/thunks.ts | 386 +++++++++----- web/src/core/usecases/launcher/thunks.ts | 15 +- web/src/core/usecases/userConfigs.ts | 4 +- web/src/ui/i18n/resources/de.tsx | 7 +- web/src/ui/i18n/resources/en.tsx | 7 +- web/src/ui/i18n/resources/es.tsx | 7 +- web/src/ui/i18n/resources/fi.tsx | 7 +- web/src/ui/i18n/resources/fr.tsx | 7 +- web/src/ui/i18n/resources/it.tsx | 12 +- web/src/ui/i18n/resources/nl.tsx | 7 +- web/src/ui/i18n/resources/no.tsx | 7 +- web/src/ui/i18n/resources/zh-CN.tsx | 7 +- web/src/ui/pages/account/AccountAiTab.tsx | 487 ++++++++++++------ 24 files changed, 1033 insertions(+), 505 deletions(-) create mode 100644 web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts diff --git a/web/src/core/adapters/ai/mock.ts b/web/src/core/adapters/ai/mock.ts index 63dc35a4f..ba53e9c0d 100644 --- a/web/src/core/adapters/ai/mock.ts +++ b/web/src/core/adapters/ai/mock.ts @@ -1,9 +1,11 @@ import type { Ai } from "core/ports/Ai"; -export function createAi(params: { webUiUrl: string }): Ai { - const { webUiUrl } = params; +export function createAi(params: { id: string; name: string; webUiUrl: string }): Ai { + const { id, name, webUiUrl } = params; return { + id, + name, webUiUrl, apiBase: `${webUiUrl}/api`, getToken: async () => ({ status: "success" as const, token: "mock-ai-token" }), diff --git a/web/src/core/adapters/ai/openWebUi.ts b/web/src/core/adapters/ai/openWebUi.ts index 38f7902a5..19fc7c814 100644 --- a/web/src/core/adapters/ai/openWebUi.ts +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -3,15 +3,19 @@ import { oidcTokenExchange, OidcTokenExchangeError } from "core/tools/oidcTokenE import { z } from "zod"; export function createAi(params: { + id: string; + name: string; webUiUrl: string; oauthProvider: string; getOidcAccessToken: () => Promise; }): Ai { - const { webUiUrl, oauthProvider, getOidcAccessToken } = params; + const { id, name, webUiUrl, oauthProvider, getOidcAccessToken } = params; const apiBase = `${webUiUrl}/api`; return { + id, + name, webUiUrl, apiBase, getToken: async (): Promise => { diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 1e571dd24..d7ee21217 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -82,11 +82,13 @@ export type ApiTypes = { }; }; data?: { - ai?: { + ai?: ArrayOrNot<{ + id?: string; URL: string; + name?: string; oauthProvider: string; oidcConfiguration?: Partial; - }; + }>; S3?: ArrayOrNot<{ URL: string; pathStyleAccess?: true; diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 878665426..9a28aff24 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -435,17 +435,27 @@ export function createOnyxiaApi(params: { apiRegion.vault.oidcConfiguration ) }, - ai: - apiRegion.data?.ai === undefined - ? undefined - : { - url: apiRegion.data.ai.URL, - oauthProvider: apiRegion.data.ai.oauthProvider, - oidcParams: - apiTypesOidcConfigurationToOidcParams_Partial( - apiRegion.data.ai.oidcConfiguration - ) - }, + ai: (() => { + const value = apiRegion.data?.ai; + + const aiConfigs_api = + value === undefined + ? [] + : value instanceof Array + ? value + : [value]; + + return aiConfigs_api.map(aiConfig_api => ({ + id: aiConfig_api.id ?? aiConfig_api.URL, + url: aiConfig_api.URL, + name: aiConfig_api.name, + oauthProvider: aiConfig_api.oauthProvider, + oidcParams: + apiTypesOidcConfigurationToOidcParams_Partial( + aiConfig_api.oidcConfiguration + ) + })); + })(), proxyInjection: apiRegion.proxyInjection === undefined ? undefined diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 712f16bd4..1a0b69fcb 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -39,7 +39,7 @@ export type Context = { onyxiaApi: OnyxiaApi; secretsManager: SecretsManager; sqlOlap: SqlOlap; - ai: Ai | undefined; + ai: Ai[]; }; export type Core = GenericCore; @@ -177,7 +177,7 @@ export async function bootstrapCore( }; } }), - ai: undefined + ai: [] }; const { core, dispatch, getState } = createCore({ @@ -285,7 +285,7 @@ export async function bootstrapCore( getState() ); - if (deploymentRegion.ai === undefined) { + if (deploymentRegion.ai.length === 0) { break init_ai; } @@ -298,22 +298,45 @@ export async function bootstrapCore( assert(oidcParams !== undefined); - const oidc_ai = await createOidc({ - ...mergeOidcParams({ + // One Ai adapter per region-provided provider, but providers may share the + // same OIDC client: oidc-spa identifies a client by issuerUri + clientId, so + // creating it twice would collide. Create each distinct client only once. + const getOidcAccessTokenByOidcKey = new Map Promise>(); + + for (const aiConfig of deploymentRegion.ai) { + const oidcParams_ai = mergeOidcParams({ oidcParams, - oidcParams_partial: deploymentRegion.ai.oidcParams - }), - transformBeforeRedirectForKeycloakTheme, - getCurrentLang, - autoLogin: true, - enableDebugLogs: enableOidcDebugLogs - }); + oidcParams_partial: aiConfig.oidcParams + }); - context.ai = createAi({ - webUiUrl: deploymentRegion.ai.url, - oauthProvider: deploymentRegion.ai.oauthProvider, - getOidcAccessToken: async () => (await oidc_ai.getTokens()).accessToken - }); + const oidcKey = `${oidcParams_ai.issuerUri}${oidcParams_ai.clientId}`; + + let getOidcAccessToken = getOidcAccessTokenByOidcKey.get(oidcKey); + + if (getOidcAccessToken === undefined) { + const oidc_ai = await createOidc({ + ...oidcParams_ai, + transformBeforeRedirectForKeycloakTheme, + getCurrentLang, + autoLogin: true, + enableDebugLogs: enableOidcDebugLogs + }); + + getOidcAccessToken = async () => (await oidc_ai.getTokens()).accessToken; + + getOidcAccessTokenByOidcKey.set(oidcKey, getOidcAccessToken); + } + + context.ai.push( + createAi({ + id: aiConfig.id, + name: aiConfig.name ?? new URL(aiConfig.url).hostname, + webUiUrl: aiConfig.url, + oauthProvider: aiConfig.oauthProvider, + getOidcAccessToken + }) + ); + } await dispatch(usecases.ai.protectedThunks.initialize()); } diff --git a/web/src/core/ports/Ai.ts b/web/src/core/ports/Ai.ts index c0d046221..267f9e369 100644 --- a/web/src/core/ports/Ai.ts +++ b/web/src/core/ports/Ai.ts @@ -1,4 +1,6 @@ export type Ai = { + id: string; + name: string; webUiUrl: string; apiBase: string; getToken: () => Promise; diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 5138850a8..7120e98bd 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -44,13 +44,13 @@ export type DeploymentRegion = { oidcParams: OidcParams_Partial; } | undefined; - ai: - | { - url: string; - oauthProvider: string; - oidcParams: OidcParams_Partial; - } - | undefined; + ai: { + id: string; + url: string; + name: string | undefined; + oauthProvider: string; + oidcParams: OidcParams_Partial; + }[]; proxyInjection: | { enabled: boolean | undefined; diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index b21bd5d64..9ee1a815a 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -184,10 +184,12 @@ export type XOnyxiaContext = { }; ai: | { - enabled: true; - token: string; + enabled: boolean; + apiKey: string; apiBase: string; model: string; + provider: string; + embeddingsModel: string; } | undefined; proxyInjection: diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts new file mode 100644 index 000000000..f593527b4 --- /dev/null +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import { assert, is } from "tsafe"; + +// undefined isn't representable in JSON, so absent selections are stored as null. +export type PersistedModelSelection = { + modelId: string | null; + embeddingsModelId: string | null; +}; + +export type PersistedCustomProvider = { + id: string; + label: string; + apiBase: string; + apiKey: string; +}; + +export type PersistedActiveProvider = + | { kind: "none" } + | { kind: "provider"; providerId: string }; + +/** + * The whole AI configuration, serialized into the single `aiConfigStr` user config + * entry (persisted in the secret manager — i.e. Vault). Only durable data lives here: + * runtime-only fields (model catalog, OIDC token) are recomputed on init. + */ +export type PersistedAiConfig = { + customProviders: PersistedCustomProvider[]; + /** Model selection per provider id (region providers included). */ + selections: Record; + activeProvider: PersistedActiveProvider; +}; + +const zPersistedAiConfig: z.ZodType = z.object({ + customProviders: z.array( + z.object({ + id: z.string(), + label: z.string(), + apiBase: z.string(), + apiKey: z.string() + }) + ), + selections: z.record( + z.string(), + z.object({ + modelId: z.string().nullable(), + embeddingsModelId: z.string().nullable() + }) + ), + activeProvider: z.union([ + z.object({ kind: z.literal("none") }), + z.object({ kind: z.literal("provider"), providerId: z.string() }) + ]) +}); + +/** Returns null when nothing usable is stored (never saved or corrupted). */ +export function parseAiConfigStr(params: { + aiConfigStr: string | null; +}): PersistedAiConfig | null { + const { aiConfigStr } = params; + + if (aiConfigStr === null) { + return null; + } + + let aiConfig: unknown; + + try { + aiConfig = JSON.parse(aiConfigStr); + } catch { + console.warn("Stored AI config is not valid JSON, ignoring it."); + return null; + } + + try { + zPersistedAiConfig.parse(aiConfig); + } catch { + console.warn("Stored AI config does not match the expected shape, ignoring it."); + return null; + } + + assert(is(aiConfig)); + + return aiConfig; +} + +export function serializeAiConfig(params: { aiConfig: PersistedAiConfig }): string { + return JSON.stringify(params.aiConfig); +} diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index ab254f608..03bc364a3 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -1,53 +1,62 @@ import { createSelector } from "clean-architecture"; import type { State as RootState } from "core/bootstrap"; +import type { XOnyxiaContext } from "core/ports/OnyxiaApi"; import { name } from "./state"; -import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; -import { assert } from "tsafe"; +import type { Provider } from "./state"; const state = (rootState: RootState) => rootState[name]; -const main = createSelector( - state, - deploymentRegionManagement.selectors.currentDeploymentRegion, - (state, region) => { - if (!state.isEnabled) { - const { initializationStatus } = state; - - if (initializationStatus === "no-account") { - assert( - region.ai !== undefined, - "region.ai should exists in case of no-account status" - ); - - return { - isEnabled: false as const, - initializationStatus, - webUiUrl: region.ai.url - }; - } - - return { isEnabled: false as const, initializationStatus }; - } - - const { - webUiUrl, - apiBase, - token, - availableModels, - selectedModel, - customProviders - } = state; - +const main = createSelector(state, state => { + if (!state.isInitialized) { return { - isEnabled: true as const, - webUiUrl, - apiBase, - token, - availableModels, - selectedModel, - customProviders + isInitialized: false as const, + isInitializing: state.isInitializing }; } -); -export const selectors = { main }; + return { + isInitialized: true as const, + providers: state.providers, + activeProvider: state.activeProvider + }; +}); + +/** Display name of a provider, whatever its kind. */ +function getProviderName(provider: Provider): string { + return provider.kind === "region" ? provider.name : provider.label; +} + +/** Credentials usable to call a provider, or undefined when it isn't ready. */ +function getProviderApiKey(provider: Provider): string | undefined { + if (provider.kind === "custom") return provider.apiKey; + if (provider.auth.stateDescription !== "authenticated") return undefined; + return provider.auth.token; +} + +const activeProvider = createSelector(state, (state): XOnyxiaContext["ai"] => { + if (!state.isInitialized) return undefined; + + const { providers, activeProvider } = state; + + if (activeProvider.kind === "none") return undefined; + + const provider = providers.find(p => p.id === activeProvider.providerId); + + if (provider === undefined) return undefined; + if (provider.modelCatalog.stateDescription !== "loaded") return undefined; + + const apiKey = getProviderApiKey(provider); + + if (apiKey === undefined) return undefined; + + return { + enabled: true, + apiKey, + apiBase: provider.apiBase, + model: provider.selection.modelId ?? "", + embeddingsModel: provider.selection.embeddingsModelId ?? "", + provider: getProviderName(provider) + }; +}); + +export const selectors = { main, activeProvider }; diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts index 2361846a4..ccb7e68b8 100644 --- a/web/src/core/usecases/ai/state.ts +++ b/web/src/core/usecases/ai/state.ts @@ -1,142 +1,212 @@ import { createUsecaseActions } from "clean-architecture"; +import { assert } from "tsafe"; import { id } from "tsafe/id"; export const name = "ai"; export type AiModel = { id: string; name: string }; -export type CustomAiProvider = { - id: string; - label: string; - apiBase: string; - apiKey: string; - availableModels: AiModel[]; - selectedModel: string | undefined; - modelsFetchStatus: "fetching" | "success" | "error"; +/** + * The chat/embeddings models the user picked on a provider. + * Kept independently of the catalog so a selection survives a refetch. + */ +export type ModelSelection = { + modelId: string | undefined; + embeddingsModelId: string | undefined; }; -type State = State.Disabled | State.Enabled; +/** Lifecycle of fetching the provider's `/models` list. */ +export type ModelCatalog = + | { stateDescription: "not fetched" } + | { stateDescription: "fetching" } + | { stateDescription: "error" } + | { stateDescription: "loaded"; availableModels: AiModel[] }; -export declare namespace State { - export type Disabled = { - isEnabled: false; - initializationStatus: "not-started" | "pending" | "error" | "no-account"; - }; +export type Provider = Provider.Region | Provider.Custom; - export type Enabled = { - isEnabled: true; +export declare namespace Provider { + /** Provisioned by the deployment region, authenticated via the OIDC token. */ + export type Region = { + kind: "region"; + id: string; + name: string; webUiUrl: string; apiBase: string; - token: string | undefined; - availableModels: AiModel[]; - selectedModel: string | undefined; - customProviders: CustomAiProvider[]; + auth: + | { stateDescription: "no account" } + | { stateDescription: "error" } + | { + stateDescription: "authenticated"; + /** undefined only while a refresh is in flight. */ + token: string | undefined; + }; + modelCatalog: ModelCatalog; + selection: ModelSelection; + }; + + /** Added by the user, authenticated via a static API key. */ + export type Custom = { + kind: "custom"; + id: string; + label: string; + apiBase: string; + apiKey: string; + modelCatalog: ModelCatalog; + selection: ModelSelection; + }; +} + +/** Which provider, if any, is wired into the user's services. */ +export type ActiveProvider = { kind: "none" } | { kind: "provider"; providerId: string }; + +type State = State.NotInitialized | State.Initialized; + +export declare namespace State { + export type NotInitialized = { + isInitialized: false; + isInitializing: boolean; + }; + + export type Initialized = { + isInitialized: true; + providers: Provider[]; + activeProvider: ActiveProvider; }; } export const { reducer, actions } = createUsecaseActions({ name, initialState: id( - id({ isEnabled: false, initializationStatus: "not-started" }) + id({ isInitialized: false, isInitializing: false }) ), reducers: { - initializeStart: state => { - if (state.isEnabled) return; - state.initializationStatus = "pending"; - }, - initializeFailed: ( - _, - { payload }: { payload: { cause: "error" | "no-account" } } - ) => - id({ - isEnabled: false, - initializationStatus: payload.cause - }), - initializeSucceed: ( + initializeStarted: () => + id({ isInitialized: false, isInitializing: true }), + initialized: ( _, { payload }: { - payload: { - webUiUrl: string; - apiBase: string; - token: string; - availableModels: AiModel[]; - selectedModel: string | undefined; - customProviders: CustomAiProvider[]; - }; + payload: { providers: Provider[]; activeProvider: ActiveProvider }; } + ) => + id({ + isInitialized: true, + providers: payload.providers, + activeProvider: payload.activeProvider + }), + activeProviderChanged: ( + state, + { payload }: { payload: { activeProvider: ActiveProvider } } ) => { - const { - webUiUrl, - apiBase, - token, - availableModels, - selectedModel, - customProviders - } = payload; - - return id({ - isEnabled: true, - webUiUrl, - apiBase, - token, - availableModels, - selectedModel: selectedModel ?? availableModels[0]?.id, - customProviders - }); + if (!state.isInitialized) return; + state.activeProvider = payload.activeProvider; }, - tokenRefreshed: (state, { payload }: { payload: { token: string } }) => { - if (!state.isEnabled) return; - state.token = payload.token; + regionTokenRefreshed: ( + state, + { payload }: { payload: { providerId: string; token: string | undefined } } + ) => { + if (!state.isInitialized) return; + const provider = state.providers.find(p => p.id === payload.providerId); + if (provider?.kind !== "region") return; + if (provider.auth.stateDescription !== "authenticated") return; + provider.auth.token = payload.token; }, - tokenRefreshFailed: state => { - if (!state.isEnabled) return; - state.token = undefined; + modelCatalogFetchStarted: ( + state, + { payload }: { payload: { providerId: string } } + ) => { + if (!state.isInitialized) return; + const provider = state.providers.find(p => p.id === payload.providerId); + if (provider === undefined) return; + provider.modelCatalog = { stateDescription: "fetching" }; }, - selectedModelSet: (state, { payload }: { payload: { model: string } }) => { - if (!state.isEnabled) return; - state.selectedModel = payload.model; + modelCatalogLoaded: ( + state, + { payload }: { payload: { providerId: string; models: AiModel[] } } + ) => { + if (!state.isInitialized) return; + const provider = state.providers.find(p => p.id === payload.providerId); + if (provider === undefined) return; + provider.modelCatalog = { + stateDescription: "loaded", + availableModels: payload.models + }; + // Default the chat model to the first available one if none is set. + if (provider.selection.modelId === undefined && payload.models.length > 0) { + provider.selection.modelId = payload.models[0].id; + } }, - customProviderAdded: (state, { payload }: { payload: CustomAiProvider }) => { - if (!state.isEnabled) return; - state.customProviders.push(payload); + modelCatalogFetchFailed: ( + state, + { payload }: { payload: { providerId: string } } + ) => { + if (!state.isInitialized) return; + const provider = state.providers.find(p => p.id === payload.providerId); + assert(provider !== undefined, "Provider should not be undefined"); + provider.modelCatalog = { stateDescription: "error" }; }, - customProviderDeleted: (state, { payload }: { payload: { id: string } }) => { - if (!state.isEnabled) return; - const i = state.customProviders.findIndex(p => p.id === payload.id); - if (i !== -1) state.customProviders.splice(i, 1); + modelSelected: ( + state, + { payload }: { payload: { providerId: string; modelId: string } } + ) => { + if (!state.isInitialized) return; + const provider = state.providers.find(p => p.id === payload.providerId); + assert(provider !== undefined, "Provider should not be undefined"); + provider.selection.modelId = payload.modelId; }, - customProviderModelsLoaded: ( + embeddingsModelSelected: ( state, - { payload }: { payload: { id: string; models: AiModel[] } } + { payload }: { payload: { providerId: string; modelId: string } } ) => { - if (!state.isEnabled) return; - const provider = state.customProviders.find(p => p.id === payload.id); - if (provider === undefined) return; - provider.availableModels = payload.models; - provider.modelsFetchStatus = "success"; - if (provider.selectedModel === undefined && payload.models.length > 0) { - provider.selectedModel = payload.models[0].id; - } + if (!state.isInitialized) return; + const provider = state.providers.find(p => p.id === payload.providerId); + assert(provider !== undefined, "Provider should not be undefined"); + provider.selection.embeddingsModelId = payload.modelId; }, - customProviderModelsFetchFailed: ( + customProviderAdded: ( state, - { payload }: { payload: { id: string } } + { payload }: { payload: { provider: Provider.Custom } } ) => { - if (!state.isEnabled) return; - const provider = state.customProviders.find(p => p.id === payload.id); - if (provider === undefined) return; - provider.modelsFetchStatus = "error"; + if (!state.isInitialized) return; + state.providers.push(payload.provider); }, - customProviderSelectedModelSet: ( + customProviderEdited: ( state, - { payload }: { payload: { id: string; model: string } } + { + payload + }: { + payload: { + providerId: string; + label: string; + apiBase: string; + apiKey: string; + }; + } ) => { - if (!state.isEnabled) return; - const provider = state.customProviders.find(p => p.id === payload.id); - if (provider === undefined) return; - provider.selectedModel = payload.model; + if (!state.isInitialized) return; + const provider = state.providers.find(p => p.id === payload.providerId); + assert(provider !== undefined, "Provider should not be undefined"); + assert(provider.kind === "custom", "Provider should be custom"); + provider.label = payload.label; + provider.apiBase = payload.apiBase; + provider.apiKey = payload.apiKey; + // Credentials changed → the previous catalog no longer applies. + provider.modelCatalog = { stateDescription: "fetching" }; + }, + customProviderDeleted: ( + state, + { payload }: { payload: { providerId: string } } + ) => { + assert(state.isInitialized, "state should be initialized"); + + state.providers = state.providers.filter(p => p.id !== payload.providerId); + if ( + state.activeProvider.kind === "provider" && + state.activeProvider.providerId === payload.providerId + ) { + state.activeProvider = { kind: "none" }; + } } } }); diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index 36dc98758..398b25943 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -1,42 +1,31 @@ import type { Thunks } from "core/bootstrap"; -import { actions } from "./state"; -import type { AiModel, CustomAiProvider } from "./state"; +import { actions, name } from "./state"; +import type { AiModel, ActiveProvider, ModelSelection, Provider } from "./state"; +import { + parseAiConfigStr, + serializeAiConfig, + type PersistedAiConfig, + type PersistedModelSelection +} from "./decoupledLogic/persistedAiConfig"; import { z } from "zod"; -import { getLocalStorage } from "core/tools/safeLocalStorage"; +import * as userConfigs from "core/usecases/userConfigs"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { assert } from "tsafe"; -import { id } from "tsafe/id"; -const SELECTED_MODEL_STORAGE_KEY = "onyxia:ai:selectedModel"; -const CUSTOM_PROVIDERS_STORAGE_KEY = "onyxia:ai:customProviders"; - -type PersistedCustomProvider = { - id: string; - label: string; - apiBase: string; - apiKey: string; - selectedModel: string | undefined; -}; - -type LocalStorageLike = Pick; - -function readPersistedProviders( - localStorage: LocalStorageLike -): PersistedCustomProvider[] { - const raw = localStorage.getItem(CUSTOM_PROVIDERS_STORAGE_KEY); - if (raw === null) return []; - try { - return JSON.parse(raw) as PersistedCustomProvider[]; - } catch { - return []; - } +function toPersistedSelection(selection: ModelSelection): PersistedModelSelection { + return { + modelId: selection.modelId ?? null, + embeddingsModelId: selection.embeddingsModelId ?? null + }; } -function writePersistedProviders( - localStorage: LocalStorageLike, - providers: PersistedCustomProvider[] -): void { - localStorage.setItem(CUSTOM_PROVIDERS_STORAGE_KEY, JSON.stringify(providers)); +function fromPersistedSelection( + selection: PersistedModelSelection | undefined +): ModelSelection { + return { + modelId: selection?.modelId ?? undefined, + embeddingsModelId: selection?.embeddingsModelId ?? undefined + }; } async function fetchModels(apiBase: string, apiKey: string): Promise { @@ -59,35 +48,53 @@ export const thunks = { const [, getState] = args; const region = deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); - return region.ai !== undefined; + return region.ai.length > 0; }, refreshToken: - () => + (params: { providerId: string }) => async (...args) => { + const { providerId } = params; const [dispatch, , { ai }] = args; - assert(ai !== undefined); + const aiProvider = ai.find(aiProvider => aiProvider.id === providerId); - const result = await ai.getToken(); + assert(aiProvider !== undefined); - if (result.status !== "success") { - dispatch(actions.tokenRefreshFailed()); - return; - } + const result = await aiProvider.getToken(); - dispatch(actions.tokenRefreshed({ token: result.token })); + dispatch( + actions.regionTokenRefreshed({ + providerId, + token: result.status === "success" ? result.token : undefined + }) + ); }, - setSelectedModel: - (params: { model: string }) => - (...args) => { - const { model } = params; + setActiveProvider: + (params: { activeProvider: ActiveProvider }) => + async (...args) => { + const { activeProvider } = params; const [dispatch] = args; - const { localStorage } = getLocalStorage(); + dispatch(actions.activeProviderChanged({ activeProvider })); + await dispatch(privateThunks.persistConfig()); + }, + setSelectedModel: + (params: { providerId: string; modelId: string }) => + async (...args) => { + const { providerId, modelId } = params; + const [dispatch] = args; - localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, model); + dispatch(actions.modelSelected({ providerId, modelId })); + await dispatch(privateThunks.persistConfig()); + }, + setSelectedEmbeddingsModel: + (params: { providerId: string; modelId: string }) => + async (...args) => { + const { providerId, modelId } = params; + const [dispatch] = args; - dispatch(actions.selectedModelSet({ model })); + dispatch(actions.embeddingsModelSelected({ providerId, modelId })); + await dispatch(privateThunks.persistConfig()); }, addCustomProvider: (params: { label: string; apiBase: string; apiKey: string }) => @@ -95,78 +102,96 @@ export const thunks = { const { label, apiBase, apiKey } = params; const [dispatch] = args; - const { localStorage } = getLocalStorage(); - const providerId = crypto.randomUUID(); dispatch( - actions.customProviderAdded( - id({ + actions.customProviderAdded({ + provider: { + kind: "custom", id: providerId, label, apiBase, apiKey, - availableModels: [], - selectedModel: undefined, - modelsFetchStatus: "fetching" - }) - ) + modelCatalog: { stateDescription: "fetching" }, + selection: { modelId: undefined, embeddingsModelId: undefined } + } + }) ); + dispatch( + actions.activeProviderChanged({ + activeProvider: { kind: "provider", providerId } + }) + ); + await dispatch(privateThunks.persistConfig()); - const persisted = readPersistedProviders(localStorage); - persisted.push({ - id: providerId, - label, - apiBase, - apiKey, - selectedModel: undefined - }); - writePersistedProviders(localStorage, persisted); - - try { - const models = await fetchModels(apiBase, apiKey); - dispatch(actions.customProviderModelsLoaded({ id: providerId, models })); - } catch { - dispatch(actions.customProviderModelsFetchFailed({ id: providerId })); - } + await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); }, - deleteCustomProvider: - (params: { id: string }) => - (...args) => { - const { id } = params; + editCustomProvider: + (params: { + providerId: string; + label: string; + apiBase: string; + apiKey: string; + }) => + async (...args) => { + const { providerId, label, apiBase, apiKey } = params; const [dispatch] = args; - const { localStorage } = getLocalStorage(); + dispatch( + actions.customProviderEdited({ providerId, label, apiBase, apiKey }) + ); + await dispatch(privateThunks.persistConfig()); - dispatch(actions.customProviderDeleted({ id })); + await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); + }, + deleteCustomProvider: + (params: { providerId: string }) => + async (...args) => { + const { providerId } = params; + const [dispatch] = args; - const persisted = readPersistedProviders(localStorage).filter( - p => p.id !== id - ); - writePersistedProviders(localStorage, persisted); + dispatch(actions.customProviderDeleted({ providerId })); + await dispatch(privateThunks.persistConfig()); }, testCustomProvider: (params: { apiBase: string; apiKey: string }) => async (..._args): Promise => { const { apiBase, apiKey } = params; return fetchModels(apiBase, apiKey); - }, - setCustomProviderSelectedModel: - (params: { id: string; model: string }) => - (...args) => { - const { id, model } = params; - const [dispatch] = args; + } +} satisfies Thunks; - const { localStorage } = getLocalStorage(); +const privateThunks = { + persistConfig: + () => + async (...args) => { + const [dispatch, getState] = args; - dispatch(actions.customProviderSelectedModelSet({ id, model })); + const state = getState()[name]; - const persisted = readPersistedProviders(localStorage); - const entry = persisted.find(p => p.id === id); - if (entry !== undefined) { - entry.selectedModel = model; - writePersistedProviders(localStorage, persisted); - } + if (!state.isInitialized) return; + + const aiConfig: PersistedAiConfig = { + customProviders: state.providers + .filter((p): p is Provider.Custom => p.kind === "custom") + .map(({ id, label, apiBase, apiKey }) => ({ + id, + label, + apiBase, + apiKey + })), + selections: Object.fromEntries( + state.providers.map(p => [p.id, toPersistedSelection(p.selection)]) + ), + activeProvider: state.activeProvider + }; + + await dispatch( + userConfigs.thunks.changeValue({ + key: "aiConfigStr", + value: serializeAiConfig({ aiConfig }) + }) + ); } } satisfies Thunks; @@ -174,63 +199,142 @@ export const protectedThunks = { initialize: () => async (...args) => { - const [dispatch, , { ai }] = args; - - if (ai === undefined) { - return; - } - - const { localStorage } = getLocalStorage(); + const [dispatch, getState, { ai }] = args; - dispatch(actions.initializeStart()); - - const tokenResult = await ai.getToken(); - - if (tokenResult.status !== "success") { - dispatch(actions.initializeFailed({ cause: tokenResult.status })); + if (ai.length === 0) { return; } - const { token } = tokenResult; - const availableModels = await ai.listModels(token); - - const persisted = readPersistedProviders(localStorage); + dispatch(actions.initializeStarted()); - const customProviders: CustomAiProvider[] = persisted.map(p => - id({ - id: p.id, - label: p.label, - apiBase: p.apiBase, - apiKey: p.apiKey, - availableModels: [], - selectedModel: p.selectedModel, - modelsFetchStatus: "fetching" - }) - ); + const persisted = parseAiConfigStr({ + aiConfigStr: userConfigs.selectors.userConfigs(getState()).aiConfigStr + }); - dispatch( - actions.initializeSucceed({ - webUiUrl: ai.webUiUrl, - apiBase: ai.apiBase, - token, - availableModels, - selectedModel: - localStorage.getItem(SELECTED_MODEL_STORAGE_KEY) ?? undefined, - customProviders + // Build one region provider per region-provided endpoint, keeping a handle + // on its adapter + token result for the post-init model fetch. + const regionEntries = await Promise.all( + ai.map(async aiProvider => { + const tokenResult = await aiProvider.getToken(); + + const provider: Provider.Region = { + kind: "region", + id: aiProvider.id, + name: aiProvider.name, + webUiUrl: aiProvider.webUiUrl, + apiBase: aiProvider.apiBase, + auth: (() => { + switch (tokenResult.status) { + case "no-account": + return { stateDescription: "no account" }; + case "error": + return { stateDescription: "error" }; + case "success": + return { + stateDescription: "authenticated", + token: tokenResult.token + }; + } + })(), + modelCatalog: { + stateDescription: + tokenResult.status === "success" + ? "fetching" + : "not fetched" + }, + selection: fromPersistedSelection( + persisted?.selections[aiProvider.id] + ) + }; + + return { provider, aiProvider, tokenResult }; }) ); - await Promise.all( - persisted.map(async p => { + const regionProviders = regionEntries.map(({ provider }) => provider); + + const customProviders: Provider.Custom[] = ( + persisted?.customProviders ?? [] + ).map(p => ({ + kind: "custom", + id: p.id, + label: p.label, + apiBase: p.apiBase, + apiKey: p.apiKey, + modelCatalog: { stateDescription: "fetching" }, + selection: fromPersistedSelection(persisted?.selections[p.id]) + })); + + const providers = [...regionProviders, ...customProviders]; + + const activeProvider = ((): ActiveProvider => { + const stored = persisted?.activeProvider; + + // Never saved a preference → default to the first region provider. + if (stored === undefined) { + const [firstRegionProvider] = regionProviders; + return firstRegionProvider === undefined + ? { kind: "none" } + : { kind: "provider", providerId: firstRegionProvider.id }; + } + + // Stored selection points at a provider that no longer exists. + if ( + stored.kind === "provider" && + !providers.some(p => p.id === stored.providerId) + ) { + return { kind: "none" }; + } + + return stored; + })(); + + dispatch(actions.initialized({ providers, activeProvider })); + + await Promise.all([ + ...regionEntries.map(async ({ provider, aiProvider, tokenResult }) => { + if (tokenResult.status !== "success") return; try { - const models = await fetchModels(p.apiBase, p.apiKey); + const models = await aiProvider.listModels(tokenResult.token); dispatch( - actions.customProviderModelsLoaded({ id: p.id, models }) + actions.modelCatalogLoaded({ + providerId: provider.id, + models + }) ); } catch { - dispatch(actions.customProviderModelsFetchFailed({ id: p.id })); + dispatch( + actions.modelCatalogFetchFailed({ providerId: provider.id }) + ); } - }) - ); + }), + ...customProviders.map(p => + dispatchFetchedModels({ + dispatch, + providerId: p.id, + apiBase: p.apiBase, + apiKey: p.apiKey + }) + ) + ]); } } satisfies Thunks; + +async function dispatchFetchedModels(params: { + dispatch: ( + action: + | ReturnType + | ReturnType + ) => void; + providerId: string; + apiBase: string; + apiKey: string; +}): Promise { + const { dispatch, providerId, apiBase, apiKey } = params; + try { + const models = await fetchModels(apiBase, apiKey); + dispatch(actions.modelCatalogLoaded({ providerId, models })); + } catch { + dispatch(actions.modelCatalogFetchFailed({ providerId })); + } +} diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index 742daa19b..0da62d68b 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -769,20 +769,7 @@ export const protectedThunks = { useCertManager: region.certManager?.useCertManager, certManagerClusterIssuer: region.certManager?.certManagerClusterIssuer }, - ai: (() => { - const aiState = aiUsecase.selectors.main(getState()); - - if (!aiState.isEnabled || aiState.token === undefined) { - return undefined; - } - - return { - enabled: true as const, - token: aiState.token, - apiBase: aiState.apiBase, - model: aiState.selectedModel ?? "" - }; - })(), + ai: aiUsecase.selectors.activeProvider(getState()), proxyInjection: region.proxyInjection, packageRepositoryInjection: region.packageRepositoryInjection, certificateAuthorityInjection: region.certificateAuthorityInjection diff --git a/web/src/core/usecases/userConfigs.ts b/web/src/core/usecases/userConfigs.ts index b3a3f7463..bfb5ce08f 100644 --- a/web/src/core/usecases/userConfigs.ts +++ b/web/src/core/usecases/userConfigs.ts @@ -33,6 +33,7 @@ export type UserConfigs = Id< isCommandBarEnabled: boolean; userProfileStr: string | null; s3BookmarksStr: string | null; + aiConfigStr: string | null; } >; @@ -155,7 +156,8 @@ export const protectedThunks = { selectedProjectId: null, isCommandBarEnabled: paramsOfBootstrapCore.isCommandBarEnabledByDefault, userProfileStr: null, - s3BookmarksStr: null + s3BookmarksStr: null, + aiConfigStr: null }; const dirPath = await dispatch(privateThunks.getDirPath()); diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 2fb18827b..f8ec874f7 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -111,9 +111,13 @@ export const translations: Translations<"de"> = { "api base url": "API-Basis-URL", token: "Token", "model label": "Modell", + "embeddings model label": "Embedding-Modell", + "gateway error": "Das KI-Gateway konnte nicht initialisiert werden.", + "use in services": "In Ihren Diensten verwenden", "custom providers section title": "Benutzerdefinierte KI-Anbieter", "custom providers section helper": - "Fügen Sie Ihre eigenen KI-Anbieter hinzu (OpenAI, Anthropic oder jeden OpenAI-kompatiblen Endpunkt). Die Anmeldedaten werden in Ihrem Browser gespeichert.", + "Fügen Sie Ihre eigenen OpenAI-kompatiblen KI-Anbieter hinzu. Die Anmeldedaten werden in Ihrem Browser gespeichert.", + "edit custom provider title": "KI-Anbieter bearbeiten", "custom provider label field": "Name", "custom provider api base field": "API-Basis-URL", "custom provider api key field": "API-Schlüssel", @@ -122,6 +126,7 @@ export const translations: Translations<"de"> = { "provider test error": "Verbindung fehlgeschlagen — URL und API-Schlüssel prüfen.", "provider save": "Hinzufügen", + "provider update": "Speichern", "provider cancel": "Abbrechen", "models fetch error": "Modelle konnten nicht abgerufen werden — überprüfen Sie URL und API-Schlüssel.", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 58c63732e..86e222248 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -109,9 +109,13 @@ export const translations: Translations<"en"> = { "api base url": "API base URL", token: "Token", "model label": "Model", + "embeddings model label": "Embeddings model", + "gateway error": "Unable to initialize the AI gateway.", + "use in services": "Use in your services", "custom providers section title": "Custom AI providers", "custom providers section helper": - "Add your own AI providers (OpenAI, Anthropic, or any OpenAI-compatible endpoint). Credentials are stored in your browser.", + "Add your own OpenAI-compatible AI providers. Credentials are stored in your browser.", + "edit custom provider title": "Edit AI provider", "custom provider label field": "Label", "custom provider api base field": "API base URL", "custom provider api key field": "API key", @@ -119,6 +123,7 @@ export const translations: Translations<"en"> = { "provider test success": "Connection successful", "provider test error": "Unable to connect — check URL and API key.", "provider save": "Add", + "provider update": "Save changes", "provider cancel": "Cancel", "models fetch error": "Unable to fetch models — check your URL and API key.", "no account": ({ webUiUrl }) => ( diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 2b19a794a..1ba59f4c2 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -110,9 +110,13 @@ export const translations: Translations<"es"> = { "api base url": "URL base de la API", token: "Token", "model label": "Modelo", + "embeddings model label": "Modelo de embeddings", + "gateway error": "No se pudo inicializar la pasarela de IA.", + "use in services": "Usar en sus servicios", "custom providers section title": "Proveedores de IA personalizados", "custom providers section helper": - "Añade tus propios proveedores de IA con una URL base y clave API.", + "Añade tus propios proveedores de IA compatibles con OpenAI. Las credenciales se almacenan en tu navegador.", + "edit custom provider title": "Editar proveedor de IA", "custom provider label field": "Etiqueta", "custom provider api base field": "URL base de la API", "custom provider api key field": "Clave API", @@ -120,6 +124,7 @@ export const translations: Translations<"es"> = { "provider test success": "Conexión exitosa", "provider test error": "No se puede conectar — compruebe la URL y la clave API.", "provider save": "Añadir", + "provider update": "Guardar", "provider cancel": "Cancelar", "models fetch error": "No se pueden obtener los modelos — compruebe la URL y la clave API.", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index c867f1342..55c00b332 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -110,9 +110,13 @@ export const translations: Translations<"fi"> = { "api base url": "API-perus-URL", token: "Token", "model label": "Malli", + "embeddings model label": "Upotusmalli", + "gateway error": "Tekoäly-yhdyskäytävän alustus epäonnistui.", + "use in services": "Käytä palveluissasi", "custom providers section title": "Mukautetut tekoälyntarjoajat", "custom providers section helper": - "Lisää omat tekoälyntarjoajasi perus-URL:lla ja API-avaimella.", + "Lisää omia OpenAI-yhteensopivia tekoälypalveluntarjoajia. Tunnukset tallennetaan selaimeesi.", + "edit custom provider title": "Muokkaa tekoälyntarjoajaa", "custom provider label field": "Tunniste", "custom provider api base field": "API-perus-URL", "custom provider api key field": "API-avain", @@ -120,6 +124,7 @@ export const translations: Translations<"fi"> = { "provider test success": "Yhteys onnistui", "provider test error": "Yhteyttä ei voi muodostaa — tarkista URL ja API-avain.", "provider save": "Lisää", + "provider update": "Tallenna", "provider cancel": "Peruuta", "models fetch error": "Mallien haku epäonnistui — tarkista URL ja API-avain.", "no account": ({ webUiUrl }) => ( diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 451870f1b..68086d33f 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -111,9 +111,13 @@ export const translations: Translations<"fr"> = { "api base url": "URL de base de l'API", token: "Jeton", "model label": "Modèles", + "embeddings model label": "Modèle d'embeddings", + "gateway error": "Impossible d'initialiser la passerelle IA.", + "use in services": "Utiliser dans vos services", "custom providers section title": "Providers IA personnalisés", "custom providers section helper": - "Ajoutez vos propres providers IA (OpenAI, Anthropic, ou tout endpoint compatible OpenAI). Les identifiants sont stockés dans votre navigateur.", + "Ajoutez vos propres providers IA compatibles OpenAI. Les identifiants sont stockés dans votre navigateur.", + "edit custom provider title": "Modifier le provider IA", "custom provider label field": "Nom", "custom provider api base field": "URL de base de l'API", "custom provider api key field": "Clé API", @@ -122,6 +126,7 @@ export const translations: Translations<"fr"> = { "provider test error": "Impossible de se connecter — vérifiez l'URL et la clé API.", "provider save": "Ajouter", + "provider update": "Enregistrer", "provider cancel": "Annuler", "models fetch error": "Impossible de récupérer les modèles — vérifiez l'URL et la clé API.", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 8c19af490..aaa1b0d7c 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -109,9 +109,13 @@ export const translations: Translations<"it"> = { "api base url": "URL base dell'API", token: "Token", "model label": "Modello", + "embeddings model label": "Modello di embedding", + "gateway error": "Impossibile inizializzare il gateway IA.", + "use in services": "Usa nei tuoi servizi", "custom providers section title": "Provider IA personalizzati", "custom providers section helper": - "Aggiungi i tuoi provider IA con un URL base e una chiave API.", + "Aggiungi i tuoi provider IA compatibili con OpenAI. Le credenziali sono memorizzate nel tuo browser.", + "edit custom provider title": "Modifica provider IA", "custom provider label field": "Etichetta", "custom provider api base field": "URL base API", "custom provider api key field": "Chiave API", @@ -120,6 +124,7 @@ export const translations: Translations<"it"> = { "provider test error": "Impossibile connettersi — controlla l'URL e la chiave API.", "provider save": "Aggiungi", + "provider update": "Salva", "provider cancel": "Annulla", "models fetch error": "Impossibile recuperare i modelli — controlla l'URL e la chiave API.", @@ -548,9 +553,8 @@ export const translations: Translations<"it"> = { la nostra documentazione .   - - Configurare il tuo Vault CLI locale - . + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 6ae60a0b3..bc0de2b3c 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -110,9 +110,13 @@ export const translations: Translations<"nl"> = { "api base url": "API-basis-URL", token: "Token", "model label": "Model", + "embeddings model label": "Embeddings-model", + "gateway error": "Kan de AI-gateway niet initialiseren.", + "use in services": "Gebruiken in uw services", "custom providers section title": "Aangepaste AI-providers", "custom providers section helper": - "Voeg uw eigen AI-providers toe met een basis-URL en API-sleutel.", + "Voeg uw eigen OpenAI-compatibele AI-providers toe. Inloggegevens worden in uw browser opgeslagen.", + "edit custom provider title": "AI-provider bewerken", "custom provider label field": "Label", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-sleutel", @@ -121,6 +125,7 @@ export const translations: Translations<"nl"> = { "provider test error": "Kan geen verbinding maken — controleer URL en API-sleutel.", "provider save": "Toevoegen", + "provider update": "Opslaan", "provider cancel": "Annuleren", "models fetch error": "Kan modellen niet ophalen — controleer uw URL en API-sleutel.", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index cdba25800..9b115f659 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -110,9 +110,13 @@ export const translations: Translations<"no"> = { "api base url": "API-basis-URL", token: "Token", "model label": "Modell", + "embeddings model label": "Embeddings-modell", + "gateway error": "Kunne ikke initialisere AI-gatewayen.", + "use in services": "Bruk i tjenestene dine", "custom providers section title": "Tilpassede AI-leverandører", "custom providers section helper": - "Legg til dine egne AI-leverandører med en basis-URL og API-nøkkel.", + "Legg til dine egne OpenAI-kompatible AI-leverandører. Påloggingsinformasjonen lagres i nettleseren din.", + "edit custom provider title": "Rediger AI-leverandør", "custom provider label field": "Etikett", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-nøkkel", @@ -120,6 +124,7 @@ export const translations: Translations<"no"> = { "provider test success": "Tilkobling vellykket", "provider test error": "Kan ikke koble til — sjekk URL og API-nøkkel.", "provider save": "Legg til", + "provider update": "Lagre", "provider cancel": "Avbryt", "models fetch error": "Kan ikke hente modeller — sjekk URL-en og API-nøkkelen.", "no account": ({ webUiUrl }) => ( diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 36f046019..330ce809f 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -101,9 +101,13 @@ export const translations: Translations<"zh-CN"> = { "api base url": "API 基础 URL", token: "令牌", "model label": "模型", + "embeddings model label": "嵌入模型", + "gateway error": "无法初始化 AI 网关。", + "use in services": "在服务中使用", "custom providers section title": "自定义 AI 提供商", "custom providers section helper": - "使用基础 URL 和 API 密钥添加您自己的 AI 提供商。", + "添加您自己的兼容 OpenAI 的 AI 提供商。凭据存储在您的浏览器中。", + "edit custom provider title": "编辑 AI 提供商", "custom provider label field": "标签", "custom provider api base field": "API 基础 URL", "custom provider api key field": "API 密钥", @@ -111,6 +115,7 @@ export const translations: Translations<"zh-CN"> = { "provider test success": "连接成功", "provider test error": "无法连接 — 请检查 URL 和 API 密钥。", "provider save": "添加", + "provider update": "保存", "provider cancel": "取消", "models fetch error": "无法获取模型 — 请检查您的 URL 和 API 密钥。", "no account": ({ webUiUrl }) => ( diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx index a7ecc78aa..ad2337b6b 100644 --- a/web/src/ui/pages/account/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -14,12 +14,18 @@ import { Button } from "onyxia-ui/Button"; import { useCoreState, getCoreSync } from "core"; import { smartTrim } from "ui/tools/smartTrim"; import { getIconUrlByName } from "lazy-icons"; -import Divider from "@mui/material/Divider"; import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; +import Switch from "@mui/material/Switch"; +import FormControlLabel from "@mui/material/FormControlLabel"; import { Text } from "onyxia-ui/Text"; -import type { CustomAiProvider } from "core/usecases/ai/state"; +import type { + AiModel, + ModelCatalog, + ModelSelection, + Provider +} from "core/usecases/ai/state"; export type Props = { className?: string; @@ -37,9 +43,17 @@ const AccountAiGatewayTab = memo((props: Props) => { const uiState = useCoreState("ai", "main"); useEffect(() => { - if (uiState.isEnabled && uiState.token === undefined) { - ai.refreshToken(); - } + if (!uiState.isInitialized) return; + + uiState.providers.forEach(provider => { + if ( + provider.kind === "region" && + provider.auth.stateDescription === "authenticated" && + provider.auth.token === undefined + ) { + ai.refreshToken({ providerId: provider.id }); + } + }); }, []); const { t } = useTranslation({ AccountAiGatewayTab }); @@ -48,22 +62,27 @@ const AccountAiGatewayTab = memo((props: Props) => { copyToClipboard(text) ); - const onRefreshClick = useConstCallback(() => ai.refreshToken()); - - const onModelChange = useConstCallback((event: { target: { value: string } }) => - ai.setSelectedModel({ model: event.target.value }) + const onRefreshClickFactory = useCallbackFactory(([providerId]: [string]) => + ai.refreshToken({ providerId }) ); - const onCustomProviderModelChangeFactory = useCallbackFactory( - ([id]: [string], [event]: [{ target: { value: string } }]) => - ai.setCustomProviderSelectedModel({ id, model: event.target.value }) + const onToggleProviderFactory = useCallbackFactory( + ([providerId]: [string], [, checked]: [unknown, boolean]) => + ai.setActiveProvider({ + activeProvider: checked + ? { kind: "provider", providerId } + : { kind: "none" } + }) ); - const onDeleteCustomProviderFactory = useCallbackFactory(([id]: [string]) => - ai.deleteCustomProvider({ id }) + const onDeleteCustomProviderFactory = useCallbackFactory(([providerId]: [string]) => + ai.deleteCustomProvider({ providerId }) ); const [addFormOpen, setAddFormOpen] = useState(false); + const [editingProviderId, setEditingProviderId] = useState( + undefined + ); const [pendingLabel, setPendingLabel] = useState(""); const [pendingApiBase, setPendingApiBase] = useState(""); const [pendingApiKey, setPendingApiKey] = useState(""); @@ -72,10 +91,36 @@ const AccountAiGatewayTab = memo((props: Props) => { >("idle"); const [testModelCount, setTestModelCount] = useState(0); - const onAddClick = useConstCallback(() => setAddFormOpen(true)); + const onAddClick = useConstCallback(() => { + setEditingProviderId(undefined); + setPendingLabel(""); + setPendingApiBase(""); + setPendingApiKey(""); + setTestStatus("idle"); + setTestModelCount(0); + setAddFormOpen(true); + }); + + const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => { + if (!uiState.isInitialized) return; + + const provider = uiState.providers.find( + (p): p is Provider.Custom => p.kind === "custom" && p.id === providerId + ); + if (provider === undefined) return; + + setEditingProviderId(providerId); + setPendingLabel(provider.label); + setPendingApiBase(provider.apiBase); + setPendingApiKey(provider.apiKey); + setTestStatus("idle"); + setTestModelCount(0); + setAddFormOpen(true); + }); const onCancelAdd = useConstCallback(() => { setAddFormOpen(false); + setEditingProviderId(undefined); setPendingLabel(""); setPendingApiBase(""); setPendingApiKey(""); @@ -98,12 +143,22 @@ const AccountAiGatewayTab = memo((props: Props) => { }); const onSaveProvider = useConstCallback(async () => { - await ai.addCustomProvider({ - label: pendingLabel, - apiBase: pendingApiBase, - apiKey: pendingApiKey - }); + if (editingProviderId === undefined) { + await ai.addCustomProvider({ + label: pendingLabel, + apiBase: pendingApiBase, + apiKey: pendingApiKey + }); + } else { + await ai.editCustomProvider({ + providerId: editingProviderId, + label: pendingLabel, + apiBase: pendingApiBase, + apiKey: pendingApiKey + }); + } setAddFormOpen(false); + setEditingProviderId(undefined); setPendingLabel(""); setPendingApiBase(""); setPendingApiKey(""); @@ -111,85 +166,117 @@ const AccountAiGatewayTab = memo((props: Props) => { setTestModelCount(0); }); - if (!uiState.isEnabled) { - const { initializationStatus } = uiState; - - if (initializationStatus === "pending") { - return ; - } - - if ( - initializationStatus === "no-account" && - "webUiUrl" in uiState && - uiState.webUiUrl !== undefined - ) { - return ( - - {t("no account", { webUiUrl: uiState.webUiUrl })} - - ); - } - - return null; + if (!uiState.isInitialized) { + return uiState.isInitializing ? : null; } - if (uiState.token === undefined) { - return ; - } + const { providers, activeProvider } = uiState; - const { token, apiBase, webUiUrl, availableModels, selectedModel, customProviders } = - uiState; + const regionProviders = providers.filter( + (p): p is Provider.Region => p.kind === "region" + ); + const customProviders = providers.filter( + (p): p is Provider.Custom => p.kind === "custom" + ); + + const isActive = (providerId: string) => + activeProvider.kind === "provider" && activeProvider.providerId === providerId; return (
- - {t("credentials section helper", { webUiUrl })} -   - ( +
+
+ {regionProvider.name} + + } + label={{t("use in services")}} /> - - } - /> - - -
-
- {t("model label")} -
-
- -
-
+
+ + {regionProvider.auth.stateDescription === "no account" && ( + + {t("no account", { webUiUrl: regionProvider.webUiUrl })} + + )} - + {regionProvider.auth.stateDescription === "error" && ( + + {t("gateway error")} + + )} + + {regionProvider.auth.stateDescription === "authenticated" && ( + <> + + {t("credentials section helper", { + webUiUrl: regionProvider.webUiUrl + })} +   + + + } + /> + {regionProvider.auth.token === undefined ? ( + + ) : ( + <> + + + + )} + + + )} +
+ ))}
{
{customProviders.map(provider => ( - +
+
+ {provider.label} +
+ + } + label={{t("use in services")}} + /> + + +
+
+ + + +
))} { )} {testStatus === "error" && ( - + {t("provider test error")} )} @@ -291,7 +432,11 @@ const AccountAiGatewayTab = memo((props: Props) => { pendingApiKey === "" } > - {t("provider save")} + {t( + editingProviderId === undefined + ? "provider save" + : "provider update" + )} } @@ -300,57 +445,86 @@ const AccountAiGatewayTab = memo((props: Props) => { ); }); -type CustomProviderCardProps = { - provider: CustomAiProvider; - onModelChange: (event: { target: { value: string } }) => void; - onDelete: () => void; - modelLabel: string; - modelsErrorLabel: string; +type ModelCatalogSectionProps = { + providerId: string; + modelCatalog: ModelCatalog; + selection: ModelSelection; }; -const CustomProviderCard = memo((props: CustomProviderCardProps) => { - const { provider, onModelChange, onDelete, modelLabel, modelsErrorLabel } = props; +const ModelCatalogSection = memo((props: ModelCatalogSectionProps) => { + const { providerId, modelCatalog, selection } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ AccountAiGatewayTab }); + const { + functions: { ai } + } = getCoreSync(); + + const onModelChange = useConstCallback((event: { target: { value: string } }) => + ai.setSelectedModel({ providerId, modelId: event.target.value }) + ); + + const onEmbeddingsModelChange = useConstCallback( + (event: { target: { value: string } }) => + ai.setSelectedEmbeddingsModel({ providerId, modelId: event.target.value }) + ); + + switch (modelCatalog.stateDescription) { + case "not fetched": + return null; + case "fetching": + return ; + case "error": + return ( + + {t("models fetch error")} + + ); + case "loaded": + return ( + <> + + + + ); + } +}); + +type ModelSelectRowProps = { + label: string; + models: AiModel[]; + selectedModel: string | undefined; + onChange: (event: { target: { value: string } }) => void; +}; + +const ModelSelectRow = memo((props: ModelSelectRowProps) => { + const { label, models, selectedModel, onChange } = props; const { classes } = useStyles(); return ( -
-
- {provider.label} - +
+
+ {label}
-
-
- {modelLabel} -
-
- {provider.modelsFetchStatus === "fetching" && ( - - )} - {provider.modelsFetchStatus === "error" && ( - - {modelsErrorLabel} - - )} - {provider.modelsFetchStatus === "success" && - provider.availableModels.length > 0 && ( - - )} -
+
+
); @@ -359,13 +533,17 @@ const CustomProviderCard = memo((props: CustomProviderCardProps) => { export default AccountAiGatewayTab; const { i18n } = declareComponentKeys< + | "use in services" | "credentials section title" | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } | "api base url" | "token" | "model label" + | "embeddings model label" + | "gateway error" | "custom providers section title" | "custom providers section helper" + | "edit custom provider title" | "custom provider label field" | "custom provider api base field" | "custom provider api key field" @@ -373,6 +551,7 @@ const { i18n } = declareComponentKeys< | "provider test success" | "provider test error" | "provider save" + | "provider update" | "provider cancel" | "models fetch error" | { K: "no account"; P: { webUiUrl: string }; R: JSX.Element } @@ -380,9 +559,6 @@ const { i18n } = declareComponentKeys< export type I18n = typeof i18n; const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ - divider: { - ...theme.spacing.topBottom("margin", 4) - }, modelRow: { display: "flex", alignItems: "center", @@ -401,7 +577,8 @@ const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ customProvidersSectionHeader: { display: "flex", alignItems: "flex-start", - gap: theme.spacing(1) + gap: theme.spacing(1), + marginTop: theme.spacing(4) }, providerCard: { border: `1px solid ${theme.colors.useCases.typography.textDisabled}`, @@ -415,7 +592,12 @@ const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ alignItems: "center", marginBottom: theme.spacing(2) }, - modelsError: { + providerCardActions: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2) + }, + errorText: { color: theme.colors.useCases.alertSeverity.error.main }, addFormFields: { @@ -430,8 +612,5 @@ const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ }, testSuccess: { color: theme.colors.useCases.alertSeverity.success.main - }, - testError: { - color: theme.colors.useCases.alertSeverity.error.main } })); From 100e7266879075c226e45dbe18879681bfb30e43 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:35:51 +0200 Subject: [PATCH 06/25] fix(ai): disable DPoP for the OpenWebUI token exchange OIDC client The access token is handed over to OpenWebUI's token exchange endpoint, which validates it server-side and cannot present a DPoP proof. It must therefore be a plain bearer token, never sender-constrained, even when DPoP is globally enabled. Forward oidc-spa's `disableDPoP` flag through the createOidc wrapper and set it on the dedicated AI OIDC client instance. Co-Authored-By: Claude Opus 4.8 --- web/src/core/adapters/oidc/oidc.ts | 13 +++++++++++-- web/src/core/bootstrap.ts | 7 ++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/web/src/core/adapters/oidc/oidc.ts b/web/src/core/adapters/oidc/oidc.ts index d959bc4ed..cfd4dc564 100644 --- a/web/src/core/adapters/oidc/oidc.ts +++ b/web/src/core/adapters/oidc/oidc.ts @@ -18,6 +18,13 @@ export async function createOidc( getCurrentLang: () => Language; autoLogin: AutoLogin; enableDebugLogs: boolean; + /** + * Opt this specific OIDC client instance out of DPoP. + * Use it when the access token has to be handed over to a third party that + * will use it on the user's behalf (e.g. an OpenWebUI token exchange): such + * a party cannot present a DPoP proof, so the token must not be sender-constrained. + */ + disableDPoP?: true; } ): Promise { const { @@ -29,7 +36,8 @@ export async function createOidc( extraQueryParams_raw, idleSessionLifetimeInSeconds, autoLogin, - enableDebugLogs + enableDebugLogs, + disableDPoP } = params; const extraQueryParams_raw_normalized = extraQueryParams_raw @@ -99,7 +107,8 @@ export async function createOidc( extraTokenParams, idleSessionLifetimeInSeconds, debugLogs: enableDebugLogs, - autoLogin + autoLogin, + ...(disableDPoP ? { disableDPoP } : {}) }); return oidc; diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 1a0b69fcb..f65bd4182 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -319,7 +319,12 @@ export async function bootstrapCore( transformBeforeRedirectForKeycloakTheme, getCurrentLang, autoLogin: true, - enableDebugLogs: enableOidcDebugLogs + enableDebugLogs: enableOidcDebugLogs, + // The access token is handed over to OpenWebUI's token exchange + // endpoint, which validates it server-side and cannot present a + // DPoP proof. It must therefore be a plain bearer token, never + // sender-constrained, even when DPoP is globally enabled. + disableDPoP: true }); getOidcAccessToken = async () => (await oidc_ai.getTokens()).accessToken; From bbb2529176e10e3c2f3c8b317ac17f0f8c2c259f Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:36:00 +0200 Subject: [PATCH 07/25] feat(ai): remove the embeddings model selection Drop the embeddings model picker across all layers: UI select row and i18n key, usecase state/thunk/selector, persisted config schema, and the `embeddingsModel` field of the XOnyxia templating context. Co-Authored-By: Claude Opus 4.8 --- web/src/core/ports/OnyxiaApi/XOnyxia.ts | 1 - .../ai/decoupledLogic/persistedAiConfig.ts | 4 +--- web/src/core/usecases/ai/selectors.ts | 1 - web/src/core/usecases/ai/state.ts | 12 +----------- web/src/core/usecases/ai/thunks.ts | 17 +++-------------- web/src/ui/i18n/resources/de.tsx | 1 - web/src/ui/i18n/resources/en.tsx | 1 - web/src/ui/i18n/resources/es.tsx | 1 - web/src/ui/i18n/resources/fi.tsx | 1 - web/src/ui/i18n/resources/fr.tsx | 1 - web/src/ui/i18n/resources/it.tsx | 6 +++--- web/src/ui/i18n/resources/nl.tsx | 1 - web/src/ui/i18n/resources/no.tsx | 1 - web/src/ui/i18n/resources/zh-CN.tsx | 1 - web/src/ui/pages/account/AccountAiTab.tsx | 12 ------------ 15 files changed, 8 insertions(+), 53 deletions(-) diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index 9ee1a815a..5513ea91c 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -189,7 +189,6 @@ export type XOnyxiaContext = { apiBase: string; model: string; provider: string; - embeddingsModel: string; } | undefined; proxyInjection: diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts index f593527b4..72e03888c 100644 --- a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts @@ -4,7 +4,6 @@ import { assert, is } from "tsafe"; // undefined isn't representable in JSON, so absent selections are stored as null. export type PersistedModelSelection = { modelId: string | null; - embeddingsModelId: string | null; }; export type PersistedCustomProvider = { @@ -42,8 +41,7 @@ const zPersistedAiConfig: z.ZodType = z.object({ selections: z.record( z.string(), z.object({ - modelId: z.string().nullable(), - embeddingsModelId: z.string().nullable() + modelId: z.string().nullable() }) ), activeProvider: z.union([ diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index 03bc364a3..87235f4c5 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -54,7 +54,6 @@ const activeProvider = createSelector(state, (state): XOnyxiaContext["ai"] => { apiKey, apiBase: provider.apiBase, model: provider.selection.modelId ?? "", - embeddingsModel: provider.selection.embeddingsModelId ?? "", provider: getProviderName(provider) }; }); diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts index ccb7e68b8..733198475 100644 --- a/web/src/core/usecases/ai/state.ts +++ b/web/src/core/usecases/ai/state.ts @@ -7,12 +7,11 @@ export const name = "ai"; export type AiModel = { id: string; name: string }; /** - * The chat/embeddings models the user picked on a provider. + * The chat model the user picked on a provider. * Kept independently of the catalog so a selection survives a refetch. */ export type ModelSelection = { modelId: string | undefined; - embeddingsModelId: string | undefined; }; /** Lifecycle of fetching the provider's `/models` list. */ @@ -155,15 +154,6 @@ export const { reducer, actions } = createUsecaseActions({ assert(provider !== undefined, "Provider should not be undefined"); provider.selection.modelId = payload.modelId; }, - embeddingsModelSelected: ( - state, - { payload }: { payload: { providerId: string; modelId: string } } - ) => { - if (!state.isInitialized) return; - const provider = state.providers.find(p => p.id === payload.providerId); - assert(provider !== undefined, "Provider should not be undefined"); - provider.selection.embeddingsModelId = payload.modelId; - }, customProviderAdded: ( state, { payload }: { payload: { provider: Provider.Custom } } diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index 398b25943..d48c116db 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -14,8 +14,7 @@ import { assert } from "tsafe"; function toPersistedSelection(selection: ModelSelection): PersistedModelSelection { return { - modelId: selection.modelId ?? null, - embeddingsModelId: selection.embeddingsModelId ?? null + modelId: selection.modelId ?? null }; } @@ -23,8 +22,7 @@ function fromPersistedSelection( selection: PersistedModelSelection | undefined ): ModelSelection { return { - modelId: selection?.modelId ?? undefined, - embeddingsModelId: selection?.embeddingsModelId ?? undefined + modelId: selection?.modelId ?? undefined }; } @@ -87,15 +85,6 @@ export const thunks = { dispatch(actions.modelSelected({ providerId, modelId })); await dispatch(privateThunks.persistConfig()); }, - setSelectedEmbeddingsModel: - (params: { providerId: string; modelId: string }) => - async (...args) => { - const { providerId, modelId } = params; - const [dispatch] = args; - - dispatch(actions.embeddingsModelSelected({ providerId, modelId })); - await dispatch(privateThunks.persistConfig()); - }, addCustomProvider: (params: { label: string; apiBase: string; apiKey: string }) => async (...args) => { @@ -113,7 +102,7 @@ export const thunks = { apiBase, apiKey, modelCatalog: { stateDescription: "fetching" }, - selection: { modelId: undefined, embeddingsModelId: undefined } + selection: { modelId: undefined } } }) ); diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index f8ec874f7..3e8d01819 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -111,7 +111,6 @@ export const translations: Translations<"de"> = { "api base url": "API-Basis-URL", token: "Token", "model label": "Modell", - "embeddings model label": "Embedding-Modell", "gateway error": "Das KI-Gateway konnte nicht initialisiert werden.", "use in services": "In Ihren Diensten verwenden", "custom providers section title": "Benutzerdefinierte KI-Anbieter", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 86e222248..6e91437ca 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -109,7 +109,6 @@ export const translations: Translations<"en"> = { "api base url": "API base URL", token: "Token", "model label": "Model", - "embeddings model label": "Embeddings model", "gateway error": "Unable to initialize the AI gateway.", "use in services": "Use in your services", "custom providers section title": "Custom AI providers", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 1ba59f4c2..4f96bf581 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -110,7 +110,6 @@ export const translations: Translations<"es"> = { "api base url": "URL base de la API", token: "Token", "model label": "Modelo", - "embeddings model label": "Modelo de embeddings", "gateway error": "No se pudo inicializar la pasarela de IA.", "use in services": "Usar en sus servicios", "custom providers section title": "Proveedores de IA personalizados", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 55c00b332..3323e453e 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -110,7 +110,6 @@ export const translations: Translations<"fi"> = { "api base url": "API-perus-URL", token: "Token", "model label": "Malli", - "embeddings model label": "Upotusmalli", "gateway error": "Tekoäly-yhdyskäytävän alustus epäonnistui.", "use in services": "Käytä palveluissasi", "custom providers section title": "Mukautetut tekoälyntarjoajat", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 68086d33f..7ce25cdd9 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -111,7 +111,6 @@ export const translations: Translations<"fr"> = { "api base url": "URL de base de l'API", token: "Jeton", "model label": "Modèles", - "embeddings model label": "Modèle d'embeddings", "gateway error": "Impossible d'initialiser la passerelle IA.", "use in services": "Utiliser dans vos services", "custom providers section title": "Providers IA personnalisés", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index aaa1b0d7c..76ac931d5 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -109,7 +109,6 @@ export const translations: Translations<"it"> = { "api base url": "URL base dell'API", token: "Token", "model label": "Modello", - "embeddings model label": "Modello di embedding", "gateway error": "Impossibile inizializzare il gateway IA.", "use in services": "Usa nei tuoi servizi", "custom providers section title": "Provider IA personalizzati", @@ -553,8 +552,9 @@ export const translations: Translations<"it"> = { la nostra documentazione .   - Configurare il tuo Vault CLI locale - . + + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index bc0de2b3c..9a4511219 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -110,7 +110,6 @@ export const translations: Translations<"nl"> = { "api base url": "API-basis-URL", token: "Token", "model label": "Model", - "embeddings model label": "Embeddings-model", "gateway error": "Kan de AI-gateway niet initialiseren.", "use in services": "Gebruiken in uw services", "custom providers section title": "Aangepaste AI-providers", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 9b115f659..b28d7056a 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -110,7 +110,6 @@ export const translations: Translations<"no"> = { "api base url": "API-basis-URL", token: "Token", "model label": "Modell", - "embeddings model label": "Embeddings-modell", "gateway error": "Kunne ikke initialisere AI-gatewayen.", "use in services": "Bruk i tjenestene dine", "custom providers section title": "Tilpassede AI-leverandører", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 330ce809f..b22c8d9ff 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -101,7 +101,6 @@ export const translations: Translations<"zh-CN"> = { "api base url": "API 基础 URL", token: "令牌", "model label": "模型", - "embeddings model label": "嵌入模型", "gateway error": "无法初始化 AI 网关。", "use in services": "在服务中使用", "custom providers section title": "自定义 AI 提供商", diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx index ad2337b6b..98bd1ab8e 100644 --- a/web/src/ui/pages/account/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -464,11 +464,6 @@ const ModelCatalogSection = memo((props: ModelCatalogSectionProps) => { ai.setSelectedModel({ providerId, modelId: event.target.value }) ); - const onEmbeddingsModelChange = useConstCallback( - (event: { target: { value: string } }) => - ai.setSelectedEmbeddingsModel({ providerId, modelId: event.target.value }) - ); - switch (modelCatalog.stateDescription) { case "not fetched": return null; @@ -489,12 +484,6 @@ const ModelCatalogSection = memo((props: ModelCatalogSectionProps) => { selectedModel={selection.modelId} onChange={onModelChange} /> - ); } @@ -539,7 +528,6 @@ const { i18n } = declareComponentKeys< | "api base url" | "token" | "model label" - | "embeddings model label" | "gateway error" | "custom providers section title" | "custom providers section helper" From f8d3ef4614a05319b303a2500c2e73c261cded29 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:25:37 +0200 Subject: [PATCH 08/25] review codes --- web/src/core/bootstrap.ts | 2 +- web/src/core/tools/fetchAiModels.ts | 43 ++ web/src/core/tools/oidcTokenExchange.ts | 21 +- .../decoupledLogic/persistedAiConfig.test.ts | 73 ++++ .../ai/decoupledLogic/persistedAiConfig.ts | 14 +- web/src/core/usecases/ai/selectors.ts | 94 +++-- web/src/core/usecases/ai/state.ts | 203 +++++----- web/src/core/usecases/ai/thunks.ts | 272 ++++++------- web/src/ui/pages/account/AccountAiTab.tsx | 367 ++++++++---------- 9 files changed, 577 insertions(+), 512 deletions(-) create mode 100644 web/src/core/tools/fetchAiModels.ts create mode 100644 web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index f65bd4182..515e79ec3 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -343,7 +343,7 @@ export async function bootstrapCore( ); } - await dispatch(usecases.ai.protectedThunks.initialize()); + dispatch(usecases.ai.protectedThunks.initialize()); } pluginSystemInitCore({ core, context }); diff --git a/web/src/core/tools/fetchAiModels.ts b/web/src/core/tools/fetchAiModels.ts new file mode 100644 index 000000000..77ed7e038 --- /dev/null +++ b/web/src/core/tools/fetchAiModels.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export type AiModel = { id: string; name: string }; + +/** + * Lists the models exposed by a generic OpenAI-compatible `/models` endpoint, + * authenticated with a bearer API key. Used by user-added custom providers. + * + * `name` is optional on purpose: plain OpenAI-compatible APIs (incl. OpenAI + * itself) only return `id`. We fall back to `id` so those providers aren't + * rejected. (The OpenWebUI region adapter has its own listing, since OpenWebUI + * always returns `name`.) + */ +export async function fetchAiModels(params: { + apiBase: string; + token: string; +}): Promise { + const { apiBase, token } = params; + + const response = await fetch(`${apiBase}/models`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error(`Failed to list models (${response.status})`); + } + + const json = await response.json(); + + let data; + + try { + ({ data } = z + .object({ + data: z.array(z.object({ id: z.string(), name: z.string().optional() })) + }) + .parse(json)); + } catch { + throw new Error("Unexpected /models response shape"); + } + + return data.map(({ id, name }) => ({ id, name: name ?? id })); +} diff --git a/web/src/core/tools/oidcTokenExchange.ts b/web/src/core/tools/oidcTokenExchange.ts index fdb320251..8d903d281 100644 --- a/web/src/core/tools/oidcTokenExchange.ts +++ b/web/src/core/tools/oidcTokenExchange.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export class OidcTokenExchangeError extends Error { constructor( public readonly status: number, @@ -26,11 +28,24 @@ export async function oidcTokenExchange(params: { ); } - const data = await response.json(); + const json = await response.json(); + + let token: string | undefined; - const token: string = data.token ?? data.access_token; + try { + const data = z + .object({ + token: z.string().optional(), + access_token: z.string().optional() + }) + .parse(json); + + token = data.token ?? data.access_token; + } catch { + throw new Error("Unexpected token exchange response shape"); + } - if (!token) { + if (token === undefined || token === "") { throw new Error("Token exchange response contained no token"); } diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts new file mode 100644 index 000000000..60422aa76 --- /dev/null +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { symToStr } from "tsafe/symToStr"; +import { + parseAiConfigStr, + serializeAiConfig, + type PersistedAiConfig +} from "./persistedAiConfig"; + +const sampleConfig: PersistedAiConfig = { + customProviders: [ + { + id: "p1", + label: "My provider", + apiBase: "https://api.openai.com/v1", + apiKey: "sk-secret" + } + ], + selections: { + p1: { modelId: "gpt-4" }, + region1: { modelId: null } + }, + activeProviderId: "p1" +}; + +describe(symToStr({ parseAiConfigStr }), () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null when nothing is stored", () => { + expect(parseAiConfigStr({ aiConfigStr: null })).toBeNull(); + }); + + it("returns null on invalid JSON", () => { + expect(parseAiConfigStr({ aiConfigStr: "{not json" })).toBeNull(); + }); + + it("returns null when the shape doesn't match", () => { + expect( + parseAiConfigStr({ aiConfigStr: JSON.stringify({ customProviders: 42 }) }) + ).toBeNull(); + }); + + it("parses a valid config", () => { + expect( + parseAiConfigStr({ aiConfigStr: JSON.stringify(sampleConfig) }) + ).toStrictEqual(sampleConfig); + }); + + it("preserves a null modelId selection", () => { + const parsed = parseAiConfigStr({ aiConfigStr: JSON.stringify(sampleConfig) }); + expect(parsed?.selections.region1).toStrictEqual({ modelId: null }); + }); +}); + +describe(symToStr({ serializeAiConfig }), () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("round-trips through parseAiConfigStr", () => { + const aiConfigStr = serializeAiConfig({ aiConfig: sampleConfig }); + expect(parseAiConfigStr({ aiConfigStr })).toStrictEqual(sampleConfig); + }); +}); diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts index 72e03888c..b892a632c 100644 --- a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts @@ -13,20 +13,17 @@ export type PersistedCustomProvider = { apiKey: string; }; -export type PersistedActiveProvider = - | { kind: "none" } - | { kind: "provider"; providerId: string }; - /** * The whole AI configuration, serialized into the single `aiConfigStr` user config * entry (persisted in the secret manager — i.e. Vault). Only durable data lives here: - * runtime-only fields (model catalog, OIDC token) are recomputed on init. + * runtime-only fields (model list, OIDC token) are recomputed on init. */ export type PersistedAiConfig = { customProviders: PersistedCustomProvider[]; /** Model selection per provider id (region providers included). */ selections: Record; - activeProvider: PersistedActiveProvider; + // null (not undefined) because absent selections must round-trip through JSON. + activeProviderId: string | null; }; const zPersistedAiConfig: z.ZodType = z.object({ @@ -44,10 +41,7 @@ const zPersistedAiConfig: z.ZodType = z.object({ modelId: z.string().nullable() }) ), - activeProvider: z.union([ - z.object({ kind: z.literal("none") }), - z.object({ kind: z.literal("provider"), providerId: z.string() }) - ]) + activeProviderId: z.string().nullable() }); /** Returns null when nothing usable is stored (never saved or corrupted). */ diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index 87235f4c5..1a603f48a 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -2,60 +2,96 @@ import { createSelector } from "clean-architecture"; import type { State as RootState } from "core/bootstrap"; import type { XOnyxiaContext } from "core/ports/OnyxiaApi"; import { name } from "./state"; -import type { Provider } from "./state"; +import type { State } from "./state"; const state = (rootState: RootState) => rootState[name]; const main = createSelector(state, state => { - if (!state.isInitialized) { - return { - isInitialized: false as const, - isInitializing: state.isInitializing - }; - } + const providers = state.stateDescription === "initialized" ? state.providers : []; + const activeProviderId = + state.stateDescription === "initialized" ? state.activeProviderId : undefined; + + const toCommonView = (provider: State.Provider) => ({ + id: provider.id, + apiBase: provider.apiBase, + isActive: provider.id === activeProviderId, + // A provider can only be wired into services once its models are listed. + canBeActivated: provider.models?.stateDescription === "loaded", + models: provider.models, + selectedModelId: provider.selectedModelId + }); return { - isInitialized: true as const, - providers: state.providers, - activeProvider: state.activeProvider + stateDescription: state.stateDescription, + regionProviders: providers + .filter((p): p is State.Provider.Region => p.kind === "region") + .map(p => ({ + ...toCommonView(p), + name: p.name, + webUiUrl: p.webUiUrl, + auth: p.auth + })), + customProviders: providers + .filter((p): p is State.Provider.Custom => p.kind === "custom") + .map(p => ({ + ...toCommonView(p), + label: p.label, + apiKey: p.apiKey + })) }; }); /** Display name of a provider, whatever its kind. */ -function getProviderName(provider: Provider): string { +function getProviderName(provider: State.Provider): string { return provider.kind === "region" ? provider.name : provider.label; } /** Credentials usable to call a provider, or undefined when it isn't ready. */ -function getProviderApiKey(provider: Provider): string | undefined { +function getProviderApiKey(provider: State.Provider): string | undefined { if (provider.kind === "custom") return provider.apiKey; if (provider.auth.stateDescription !== "authenticated") return undefined; return provider.auth.token; } -const activeProvider = createSelector(state, (state): XOnyxiaContext["ai"] => { - if (!state.isInitialized) return undefined; +const providers = createSelector(state, state => + state.stateDescription === "initialized" ? state.providers : undefined +); - const { providers, activeProvider } = state; +const resolvedActiveProvider = createSelector( + createSelector(state, state => + state.stateDescription === "initialized" ? state.activeProviderId : undefined + ), + providers, + (activeProviderId, providers) => { + if (activeProviderId === undefined || providers === undefined) return undefined; + return providers.find(p => p.id === activeProviderId); + } +); - if (activeProvider.kind === "none") return undefined; +const activeProvider = createSelector( + resolvedActiveProvider, + (provider): XOnyxiaContext["ai"] => { + if (provider === undefined) return undefined; + if (provider.models?.stateDescription !== "loaded") return undefined; - const provider = providers.find(p => p.id === activeProvider.providerId); + const apiKey = getProviderApiKey(provider); - if (provider === undefined) return undefined; - if (provider.modelCatalog.stateDescription !== "loaded") return undefined; + if (apiKey === undefined) return undefined; - const apiKey = getProviderApiKey(provider); + const { selectedModelId: selectedModel } = provider; - if (apiKey === undefined) return undefined; + // No usable model (e.g. the models list loaded empty) → the provider isn't ready + // to be wired into services; don't inject an empty model name. + if (selectedModel === undefined || selectedModel === "") return undefined; - return { - enabled: true, - apiKey, - apiBase: provider.apiBase, - model: provider.selection.modelId ?? "", - provider: getProviderName(provider) - }; -}); + return { + enabled: true, + apiKey, + apiBase: provider.apiBase, + model: selectedModel, + provider: getProviderName(provider) + }; + } +); export const selectors = { main, activeProvider }; diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts index 733198475..9d1c1ada1 100644 --- a/web/src/core/usecases/ai/state.ts +++ b/web/src/core/usecases/ai/state.ts @@ -4,164 +4,140 @@ import { id } from "tsafe/id"; export const name = "ai"; -export type AiModel = { id: string; name: string }; +type State = State.NotInitialized | State.Error | State.Initialized; -/** - * The chat model the user picked on a provider. - * Kept independently of the catalog so a selection survives a refetch. - */ -export type ModelSelection = { - modelId: string | undefined; -}; - -/** Lifecycle of fetching the provider's `/models` list. */ -export type ModelCatalog = - | { stateDescription: "not fetched" } - | { stateDescription: "fetching" } - | { stateDescription: "error" } - | { stateDescription: "loaded"; availableModels: AiModel[] }; +export declare namespace State { + export type NotInitialized = { stateDescription: "not initialized" }; -export type Provider = Provider.Region | Provider.Custom; + export type Error = { stateDescription: "error" }; -export declare namespace Provider { - /** Provisioned by the deployment region, authenticated via the OIDC token. */ - export type Region = { - kind: "region"; - id: string; - name: string; - webUiUrl: string; - apiBase: string; - auth: - | { stateDescription: "no account" } - | { stateDescription: "error" } - | { - stateDescription: "authenticated"; - /** undefined only while a refresh is in flight. */ - token: string | undefined; - }; - modelCatalog: ModelCatalog; - selection: ModelSelection; + export type Initialized = { + stateDescription: "initialized"; + providers: Provider[]; + activeProviderId: string | undefined; }; - /** Added by the user, authenticated via a static API key. */ - export type Custom = { - kind: "custom"; - id: string; - label: string; - apiBase: string; - apiKey: string; - modelCatalog: ModelCatalog; - selection: ModelSelection; - }; -} + // --- Providers --- -/** Which provider, if any, is wired into the user's services. */ -export type ActiveProvider = { kind: "none" } | { kind: "provider"; providerId: string }; + export type Provider = Provider.Region | Provider.Custom; -type State = State.NotInitialized | State.Initialized; + export namespace Provider { + export type Common = { + id: string; + apiBase: string; + models: Models | undefined; + selectedModelId: string | undefined; + }; -export declare namespace State { - export type NotInitialized = { - isInitialized: false; - isInitializing: boolean; - }; + /** Provisioned by the deployment region, authenticated via the OIDC token. */ + export type Region = Common & { + kind: "region"; + name: string; + webUiUrl: string; + auth: + | { stateDescription: "no account" } + | { stateDescription: "error" } + | { stateDescription: "authenticated"; token: string }; + }; - export type Initialized = { - isInitialized: true; - providers: Provider[]; - activeProvider: ActiveProvider; - }; + /** Added by the user, authenticated via a static API key. */ + export type Custom = Common & { + kind: "custom"; + label: string; + apiKey: string; + }; + } + + /** Lifecycle of fetching a provider's `/models` list (undefined = not fetched). */ + export type Models = + | { stateDescription: "fetching" } + | { stateDescription: "error" } + | { stateDescription: "loaded"; availableModels: AiModel[] }; + + export type AiModel = { id: string; name: string }; } export const { reducer, actions } = createUsecaseActions({ name, initialState: id( - id({ isInitialized: false, isInitializing: false }) + id({ stateDescription: "not initialized" }) ), reducers: { - initializeStarted: () => - id({ isInitialized: false, isInitializing: true }), + initializationFailed: () => id({ stateDescription: "error" }), initialized: ( _, { payload }: { - payload: { providers: Provider[]; activeProvider: ActiveProvider }; + payload: { + providers: State.Provider[]; + activeProviderId: string | undefined; + }; } ) => id({ - isInitialized: true, + stateDescription: "initialized", providers: payload.providers, - activeProvider: payload.activeProvider + activeProviderId: payload.activeProviderId }), activeProviderChanged: ( state, - { payload }: { payload: { activeProvider: ActiveProvider } } + { payload }: { payload: { activeProviderId: string | undefined } } ) => { - if (!state.isInitialized) return; - state.activeProvider = payload.activeProvider; + if (state.stateDescription !== "initialized") return; + state.activeProviderId = payload.activeProviderId; }, - regionTokenRefreshed: ( + regionAuthRefreshed: ( state, - { payload }: { payload: { providerId: string; token: string | undefined } } - ) => { - if (!state.isInitialized) return; - const provider = state.providers.find(p => p.id === payload.providerId); - if (provider?.kind !== "region") return; - if (provider.auth.stateDescription !== "authenticated") return; - provider.auth.token = payload.token; - }, - modelCatalogFetchStarted: ( - state, - { payload }: { payload: { providerId: string } } + { + payload + }: { payload: { providerId: string; auth: State.Provider.Region["auth"] } } ) => { - if (!state.isInitialized) return; + assert(state.stateDescription === "initialized"); const provider = state.providers.find(p => p.id === payload.providerId); - if (provider === undefined) return; - provider.modelCatalog = { stateDescription: "fetching" }; + if (provider === undefined || provider.kind !== "region") return; + provider.auth = payload.auth; }, - modelCatalogLoaded: ( + modelsLoaded: ( state, - { payload }: { payload: { providerId: string; models: AiModel[] } } + { payload }: { payload: { providerId: string; models: State.AiModel[] } } ) => { - if (!state.isInitialized) return; + assert(state.stateDescription === "initialized"); const provider = state.providers.find(p => p.id === payload.providerId); if (provider === undefined) return; - provider.modelCatalog = { + provider.models = { stateDescription: "loaded", availableModels: payload.models }; // Default the chat model to the first available one if none is set. - if (provider.selection.modelId === undefined && payload.models.length > 0) { - provider.selection.modelId = payload.models[0].id; + if (provider.selectedModelId === undefined && payload.models.length > 0) { + provider.selectedModelId = payload.models[0].id; } }, - modelCatalogFetchFailed: ( - state, - { payload }: { payload: { providerId: string } } - ) => { - if (!state.isInitialized) return; + modelsFetchFailed: (state, { payload }: { payload: { providerId: string } }) => { + assert(state.stateDescription === "initialized"); const provider = state.providers.find(p => p.id === payload.providerId); - assert(provider !== undefined, "Provider should not be undefined"); - provider.modelCatalog = { stateDescription: "error" }; + if (provider === undefined) return; + provider.models = { stateDescription: "error" }; }, modelSelected: ( state, { payload }: { payload: { providerId: string; modelId: string } } ) => { - if (!state.isInitialized) return; + assert(state.stateDescription === "initialized"); const provider = state.providers.find(p => p.id === payload.providerId); - assert(provider !== undefined, "Provider should not be undefined"); - provider.selection.modelId = payload.modelId; + // Synchronous user action on a displayed provider: it must exist. + assert(provider !== undefined); + provider.selectedModelId = payload.modelId; }, - customProviderAdded: ( + addCustomProvider: ( state, - { payload }: { payload: { provider: Provider.Custom } } + { payload }: { payload: { provider: State.Provider.Custom } } ) => { - if (!state.isInitialized) return; + assert(state.stateDescription === "initialized"); state.providers.push(payload.provider); }, - customProviderEdited: ( + editCustomProvider: ( state, { payload @@ -174,28 +150,27 @@ export const { reducer, actions } = createUsecaseActions({ }; } ) => { - if (!state.isInitialized) return; + assert(state.stateDescription === "initialized"); const provider = state.providers.find(p => p.id === payload.providerId); - assert(provider !== undefined, "Provider should not be undefined"); - assert(provider.kind === "custom", "Provider should be custom"); + // Editing an existing custom provider from its dialog: it must exist. + assert(provider !== undefined); + assert(provider.kind === "custom"); provider.label = payload.label; provider.apiBase = payload.apiBase; provider.apiKey = payload.apiKey; - // Credentials changed → the previous catalog no longer applies. - provider.modelCatalog = { stateDescription: "fetching" }; + // Credentials changed → the previous models list no longer applies. + provider.models = { stateDescription: "fetching" }; }, - customProviderDeleted: ( + deleteCustomProvider: ( state, { payload }: { payload: { providerId: string } } ) => { - assert(state.isInitialized, "state should be initialized"); + // Deleting is only reachable from the initialized UI. + assert(state.stateDescription === "initialized"); state.providers = state.providers.filter(p => p.id !== payload.providerId); - if ( - state.activeProvider.kind === "provider" && - state.activeProvider.providerId === payload.providerId - ) { - state.activeProvider = { kind: "none" }; + if (state.activeProviderId === payload.providerId) { + state.activeProviderId = undefined; } } } diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index d48c116db..551d34d0e 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -1,42 +1,41 @@ import type { Thunks } from "core/bootstrap"; import { actions, name } from "./state"; -import type { AiModel, ActiveProvider, ModelSelection, Provider } from "./state"; +import type { State } from "./state"; import { parseAiConfigStr, serializeAiConfig, type PersistedAiConfig, type PersistedModelSelection } from "./decoupledLogic/persistedAiConfig"; -import { z } from "zod"; +import { fetchAiModels } from "core/tools/fetchAiModels"; +import type { GetTokenResult } from "core/ports/Ai"; import * as userConfigs from "core/usecases/userConfigs"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { assert } from "tsafe"; -function toPersistedSelection(selection: ModelSelection): PersistedModelSelection { - return { - modelId: selection.modelId ?? null - }; +function getTokenResultToAuth(result: GetTokenResult): State.Provider.Region["auth"] { + switch (result.status) { + case "no-account": + return { stateDescription: "no account" }; + case "error": + return { stateDescription: "error" }; + case "success": + return { stateDescription: "authenticated", token: result.token }; + } } -function fromPersistedSelection( - selection: PersistedModelSelection | undefined -): ModelSelection { +function toPersistedSelection( + selectedModel: string | undefined +): PersistedModelSelection { return { - modelId: selection?.modelId ?? undefined + modelId: selectedModel ?? null }; } -async function fetchModels(apiBase: string, apiKey: string): Promise { - const response = await fetch(`${apiBase}/models`, { - headers: { Authorization: `Bearer ${apiKey}` } - }); - if (!response.ok) { - throw new Error(`Failed to fetch models (${response.status})`); - } - const { data } = z - .object({ data: z.array(z.object({ id: z.string(), name: z.string() })) }) - .parse(await response.json()); - return data.map(({ id, name }) => ({ id, name })); +function fromPersistedSelection( + selection: PersistedModelSelection | undefined +): string | undefined { + return selection?.modelId ?? undefined; } export const thunks = { @@ -61,19 +60,19 @@ export const thunks = { const result = await aiProvider.getToken(); dispatch( - actions.regionTokenRefreshed({ + actions.regionAuthRefreshed({ providerId, - token: result.status === "success" ? result.token : undefined + auth: getTokenResultToAuth(result) }) ); }, setActiveProvider: - (params: { activeProvider: ActiveProvider }) => + (params: { activeProviderId: string | undefined }) => async (...args) => { - const { activeProvider } = params; + const { activeProviderId } = params; const [dispatch] = args; - dispatch(actions.activeProviderChanged({ activeProvider })); + dispatch(actions.activeProviderChanged({ activeProviderId })); await dispatch(privateThunks.persistConfig()); }, setSelectedModel: @@ -85,6 +84,17 @@ export const thunks = { dispatch(actions.modelSelected({ providerId, modelId })); await dispatch(privateThunks.persistConfig()); }, + deleteCustomProvider: + (params: { providerId: string }) => + async (...args) => { + const { providerId } = params; + const [dispatch] = args; + + dispatch(actions.deleteCustomProvider({ providerId })); + await dispatch(privateThunks.persistConfig()); + }, + // The add/edit form (values, validation, connection-test result, open state) is + // owned by the UI. The core only exposes the resulting operations on the state. addCustomProvider: (params: { label: string; apiBase: string; apiKey: string }) => async (...args) => { @@ -94,25 +104,20 @@ export const thunks = { const providerId = crypto.randomUUID(); dispatch( - actions.customProviderAdded({ + actions.addCustomProvider({ provider: { kind: "custom", id: providerId, label, apiBase, apiKey, - modelCatalog: { stateDescription: "fetching" }, - selection: { modelId: undefined } + models: { stateDescription: "fetching" }, + selectedModelId: undefined } }) ); - dispatch( - actions.activeProviderChanged({ - activeProvider: { kind: "provider", providerId } - }) - ); - await dispatch(privateThunks.persistConfig()); + await dispatch(privateThunks.persistConfig()); await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); }, editCustomProvider: @@ -126,27 +131,19 @@ export const thunks = { const { providerId, label, apiBase, apiKey } = params; const [dispatch] = args; - dispatch( - actions.customProviderEdited({ providerId, label, apiBase, apiKey }) - ); - await dispatch(privateThunks.persistConfig()); + dispatch(actions.editCustomProvider({ providerId, label, apiBase, apiKey })); - await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); - }, - deleteCustomProvider: - (params: { providerId: string }) => - async (...args) => { - const { providerId } = params; - const [dispatch] = args; - - dispatch(actions.customProviderDeleted({ providerId })); await dispatch(privateThunks.persistConfig()); + await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); }, - testCustomProvider: + // Command-query thunk: the connection-test result is purely UI-local (it never + // touches the persisted state), so returning it here is intentional. + testCustomProviderConnection: (params: { apiBase: string; apiKey: string }) => - async (..._args): Promise => { + async (): Promise<{ modelCount: number }> => { const { apiBase, apiKey } = params; - return fetchModels(apiBase, apiKey); + const models = await fetchAiModels({ apiBase, token: apiKey }); + return { modelCount: models.length }; } } satisfies Thunks; @@ -158,11 +155,11 @@ const privateThunks = { const state = getState()[name]; - if (!state.isInitialized) return; + if (state.stateDescription !== "initialized") return; const aiConfig: PersistedAiConfig = { customProviders: state.providers - .filter((p): p is Provider.Custom => p.kind === "custom") + .filter((p): p is State.Provider.Custom => p.kind === "custom") .map(({ id, label, apiBase, apiKey }) => ({ id, label, @@ -170,9 +167,12 @@ const privateThunks = { apiKey })), selections: Object.fromEntries( - state.providers.map(p => [p.id, toPersistedSelection(p.selection)]) + state.providers.map(p => [ + p.id, + toPersistedSelection(p.selectedModelId) + ]) ), - activeProvider: state.activeProvider + activeProviderId: state.activeProviderId ?? null }; await dispatch( @@ -194,91 +194,75 @@ export const protectedThunks = { return; } - dispatch(actions.initializeStarted()); - - const persisted = parseAiConfigStr({ - aiConfigStr: userConfigs.selectors.userConfigs(getState()).aiConfigStr - }); - // Build one region provider per region-provided endpoint, keeping a handle // on its adapter + token result for the post-init model fetch. - const regionEntries = await Promise.all( - ai.map(async aiProvider => { - const tokenResult = await aiProvider.getToken(); - - const provider: Provider.Region = { - kind: "region", - id: aiProvider.id, - name: aiProvider.name, - webUiUrl: aiProvider.webUiUrl, - apiBase: aiProvider.apiBase, - auth: (() => { - switch (tokenResult.status) { - case "no-account": - return { stateDescription: "no account" }; - case "error": - return { stateDescription: "error" }; - case "success": - return { - stateDescription: "authenticated", - token: tokenResult.token - }; - } - })(), - modelCatalog: { - stateDescription: + let regionEntries; + let customProviders: State.Provider.Custom[]; + + try { + const persisted = parseAiConfigStr({ + aiConfigStr: userConfigs.selectors.userConfigs(getState()).aiConfigStr + }); + + regionEntries = await Promise.all( + ai.map(async aiProvider => { + const tokenResult = await aiProvider.getToken(); + + const provider: State.Provider.Region = { + kind: "region", + id: aiProvider.id, + name: aiProvider.name, + webUiUrl: aiProvider.webUiUrl, + apiBase: aiProvider.apiBase, + auth: getTokenResultToAuth(tokenResult), + models: tokenResult.status === "success" - ? "fetching" - : "not fetched" - }, - selection: fromPersistedSelection( - persisted?.selections[aiProvider.id] - ) - }; - - return { provider, aiProvider, tokenResult }; - }) - ); + ? { stateDescription: "fetching" } + : undefined, + selectedModelId: fromPersistedSelection( + persisted?.selections[aiProvider.id] + ) + }; + + return { provider, aiProvider, tokenResult }; + }) + ); + + const regionProviders = regionEntries.map(({ provider }) => provider); + + customProviders = (persisted?.customProviders ?? []).map(p => ({ + kind: "custom", + id: p.id, + label: p.label, + apiBase: p.apiBase, + apiKey: p.apiKey, + models: { stateDescription: "fetching" }, + selectedModelId: fromPersistedSelection(persisted?.selections[p.id]) + })); + + const providers = [...regionProviders, ...customProviders]; + + const activeProviderId = ((): string | undefined => { + // Never saved a preference → default to the first region provider. + if (persisted === null) { + return regionProviders[0]?.id; + } + + const stored = persisted.activeProviderId ?? undefined; + + // Stored selection points at a provider that no longer exists. + if (stored !== undefined && !providers.some(p => p.id === stored)) { + return undefined; + } - const regionProviders = regionEntries.map(({ provider }) => provider); - - const customProviders: Provider.Custom[] = ( - persisted?.customProviders ?? [] - ).map(p => ({ - kind: "custom", - id: p.id, - label: p.label, - apiBase: p.apiBase, - apiKey: p.apiKey, - modelCatalog: { stateDescription: "fetching" }, - selection: fromPersistedSelection(persisted?.selections[p.id]) - })); - - const providers = [...regionProviders, ...customProviders]; - - const activeProvider = ((): ActiveProvider => { - const stored = persisted?.activeProvider; - - // Never saved a preference → default to the first region provider. - if (stored === undefined) { - const [firstRegionProvider] = regionProviders; - return firstRegionProvider === undefined - ? { kind: "none" } - : { kind: "provider", providerId: firstRegionProvider.id }; - } - - // Stored selection points at a provider that no longer exists. - if ( - stored.kind === "provider" && - !providers.some(p => p.id === stored.providerId) - ) { - return { kind: "none" }; - } - - return stored; - })(); - - dispatch(actions.initialized({ providers, activeProvider })); + return stored; + })(); + + dispatch(actions.initialized({ providers, activeProviderId })); + } catch { + dispatch(actions.initializationFailed()); + return; + } await Promise.all([ ...regionEntries.map(async ({ provider, aiProvider, tokenResult }) => { @@ -286,15 +270,13 @@ export const protectedThunks = { try { const models = await aiProvider.listModels(tokenResult.token); dispatch( - actions.modelCatalogLoaded({ + actions.modelsLoaded({ providerId: provider.id, models }) ); } catch { - dispatch( - actions.modelCatalogFetchFailed({ providerId: provider.id }) - ); + dispatch(actions.modelsFetchFailed({ providerId: provider.id })); } }), ...customProviders.map(p => @@ -312,8 +294,8 @@ export const protectedThunks = { async function dispatchFetchedModels(params: { dispatch: ( action: - | ReturnType - | ReturnType + | ReturnType + | ReturnType ) => void; providerId: string; apiBase: string; @@ -321,9 +303,9 @@ async function dispatchFetchedModels(params: { }): Promise { const { dispatch, providerId, apiBase, apiKey } = params; try { - const models = await fetchModels(apiBase, apiKey); - dispatch(actions.modelCatalogLoaded({ providerId, models })); + const models = await fetchAiModels({ apiBase, token: apiKey }); + dispatch(actions.modelsLoaded({ providerId, models })); } catch { - dispatch(actions.modelCatalogFetchFailed({ providerId })); + dispatch(actions.modelsFetchFailed({ providerId })); } } diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx index 98bd1ab8e..b8ffb5636 100644 --- a/web/src/ui/pages/account/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, memo, useState } from "react"; +import { memo, useState } from "react"; import { useTranslation } from "ui/i18n"; import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; import { SettingField } from "ui/shared/SettingField"; @@ -20,17 +20,27 @@ import TextField from "@mui/material/TextField"; import Switch from "@mui/material/Switch"; import FormControlLabel from "@mui/material/FormControlLabel"; import { Text } from "onyxia-ui/Text"; -import type { - AiModel, - ModelCatalog, - ModelSelection, - Provider -} from "core/usecases/ai/state"; export type Props = { className?: string; }; +type AiModel = { id: string; name: string }; + +type Models = + | { stateDescription: "fetching" } + | { stateDescription: "error" } + | { stateDescription: "loaded"; availableModels: AiModel[] } + | undefined; + +type FormValues = { label: string; apiBase: string; apiKey: string }; + +type FormTest = + | { stateDescription: "idle" } + | { stateDescription: "testing" } + | { stateDescription: "success"; modelCount: number } + | { stateDescription: "error" }; + const AccountAiGatewayTab = memo((props: Props) => { const { className } = props; @@ -40,21 +50,10 @@ const AccountAiGatewayTab = memo((props: Props) => { functions: { ai } } = getCoreSync(); - const uiState = useCoreState("ai", "main"); - - useEffect(() => { - if (!uiState.isInitialized) return; - - uiState.providers.forEach(provider => { - if ( - provider.kind === "region" && - provider.auth.stateDescription === "authenticated" && - provider.auth.token === undefined - ) { - ai.refreshToken({ providerId: provider.id }); - } - }); - }, []); + const { stateDescription, regionProviders, customProviders } = useCoreState( + "ai", + "main" + ); const { t } = useTranslation({ AccountAiGatewayTab }); @@ -69,9 +68,7 @@ const AccountAiGatewayTab = memo((props: Props) => { const onToggleProviderFactory = useCallbackFactory( ([providerId]: [string], [, checked]: [unknown, boolean]) => ai.setActiveProvider({ - activeProvider: checked - ? { kind: "provider", providerId } - : { kind: "none" } + activeProviderId: checked ? providerId : undefined }) ); @@ -79,109 +76,92 @@ const AccountAiGatewayTab = memo((props: Props) => { ai.deleteCustomProvider({ providerId }) ); - const [addFormOpen, setAddFormOpen] = useState(false); - const [editingProviderId, setEditingProviderId] = useState( + // The add/edit custom-provider form is entirely UI-owned: its open state, edited + // values and connection-test result never go through the core. The core only + // exposes the resulting operations (add/edit/test). + const [isFormOpen, setIsFormOpen] = useState(false); + const [editedProviderId, setEditedProviderId] = useState( undefined ); - const [pendingLabel, setPendingLabel] = useState(""); - const [pendingApiBase, setPendingApiBase] = useState(""); - const [pendingApiKey, setPendingApiKey] = useState(""); - const [testStatus, setTestStatus] = useState< - "idle" | "testing" | "success" | "error" - >("idle"); - const [testModelCount, setTestModelCount] = useState(0); + const [values, setValues] = useState({ + label: "", + apiBase: "", + apiKey: "" + }); + const [test, setTest] = useState({ stateDescription: "idle" }); + + const isEditing = editedProviderId !== undefined; + const canSave = values.label !== "" && values.apiBase !== "" && values.apiKey !== ""; + const canTest = + values.apiBase !== "" && + values.apiKey !== "" && + test.stateDescription !== "testing"; const onAddClick = useConstCallback(() => { - setEditingProviderId(undefined); - setPendingLabel(""); - setPendingApiBase(""); - setPendingApiKey(""); - setTestStatus("idle"); - setTestModelCount(0); - setAddFormOpen(true); + setEditedProviderId(undefined); + setValues({ label: "", apiBase: "", apiKey: "" }); + setTest({ stateDescription: "idle" }); + setIsFormOpen(true); }); const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => { - if (!uiState.isInitialized) return; - - const provider = uiState.providers.find( - (p): p is Provider.Custom => p.kind === "custom" && p.id === providerId - ); + const provider = customProviders.find(p => p.id === providerId); if (provider === undefined) return; - - setEditingProviderId(providerId); - setPendingLabel(provider.label); - setPendingApiBase(provider.apiBase); - setPendingApiKey(provider.apiKey); - setTestStatus("idle"); - setTestModelCount(0); - setAddFormOpen(true); + setEditedProviderId(providerId); + setValues({ + label: provider.label, + apiBase: provider.apiBase, + apiKey: provider.apiKey + }); + setTest({ stateDescription: "idle" }); + setIsFormOpen(true); }); - const onCancelAdd = useConstCallback(() => { - setAddFormOpen(false); - setEditingProviderId(undefined); - setPendingLabel(""); - setPendingApiBase(""); - setPendingApiKey(""); - setTestStatus("idle"); - setTestModelCount(0); - }); + const onClose = useConstCallback(() => setIsFormOpen(false)); - const onTestProvider = useConstCallback(async () => { - setTestStatus("testing"); + const onFieldChangeFactory = useCallbackFactory( + ([key]: [keyof FormValues], [event]: [{ target: { value: string } }]) => { + const { value } = event.target; + setValues(values => ({ ...values, [key]: value })); + // Credentials changed → a previous test result no longer applies. + if (key !== "label") { + setTest({ stateDescription: "idle" }); + } + } + ); + + const onTest = useConstCallback(async () => { + setTest({ stateDescription: "testing" }); try { - const models = await ai.testCustomProvider({ - apiBase: pendingApiBase, - apiKey: pendingApiKey + const { modelCount } = await ai.testCustomProviderConnection({ + apiBase: values.apiBase, + apiKey: values.apiKey }); - setTestModelCount(models.length); - setTestStatus("success"); + setTest({ stateDescription: "success", modelCount }); } catch { - setTestStatus("error"); + setTest({ stateDescription: "error" }); } }); - const onSaveProvider = useConstCallback(async () => { - if (editingProviderId === undefined) { - await ai.addCustomProvider({ - label: pendingLabel, - apiBase: pendingApiBase, - apiKey: pendingApiKey - }); + const onSave = useConstCallback(async () => { + setIsFormOpen(false); + if (editedProviderId === undefined) { + await ai.addCustomProvider(values); } else { - await ai.editCustomProvider({ - providerId: editingProviderId, - label: pendingLabel, - apiBase: pendingApiBase, - apiKey: pendingApiKey - }); + await ai.editCustomProvider({ providerId: editedProviderId, ...values }); } - setAddFormOpen(false); - setEditingProviderId(undefined); - setPendingLabel(""); - setPendingApiBase(""); - setPendingApiKey(""); - setTestStatus("idle"); - setTestModelCount(0); }); - if (!uiState.isInitialized) { - return uiState.isInitializing ? : null; + if (stateDescription !== "initialized") { + return stateDescription === "error" ? ( + + {t("gateway error")} + + ) : ( + + ); } - const { providers, activeProvider } = uiState; - - const regionProviders = providers.filter( - (p): p is Provider.Region => p.kind === "region" - ); - const customProviders = providers.filter( - (p): p is Provider.Custom => p.kind === "custom" - ); - - const isActive = (providerId: string) => - activeProvider.kind === "provider" && activeProvider.providerId === providerId; - return (
{regionProviders.map(regionProvider => ( @@ -191,12 +171,9 @@ const AccountAiGatewayTab = memo((props: Props) => { } @@ -236,42 +213,36 @@ const AccountAiGatewayTab = memo((props: Props) => { } /> - {regionProvider.auth.token === undefined ? ( - - ) : ( - <> - - - - )} - + + )} @@ -298,12 +269,9 @@ const AccountAiGatewayTab = memo((props: Props) => { } @@ -343,49 +311,43 @@ const AccountAiGatewayTab = memo((props: Props) => { onRequestCopy={onFieldRequestCopyFactory(provider.apiKey)} isSensitiveInformation={true} /> -
))} setPendingLabel(e.target.value)} + value={values.label} + onChange={onFieldChangeFactory("label")} size="small" fullWidth /> { - setPendingApiBase(e.target.value); - setTestStatus("idle"); - }} + value={values.apiBase} + onChange={onFieldChangeFactory("apiBase")} size="small" fullWidth placeholder="https://api.openai.com/v1" /> { - setPendingApiKey(e.target.value); - setTestStatus("idle"); - }} + value={values.apiKey} + onChange={onFieldChangeFactory("apiKey")} size="small" fullWidth type="password" @@ -393,25 +355,21 @@ const AccountAiGatewayTab = memo((props: Props) => {
- {testStatus === "success" && ( + {test.stateDescription === "success" && ( - {t("provider test success")} ({testModelCount}) + {t("provider test success")} ({test.modelCount}) )} - {testStatus === "error" && ( + {test.stateDescription === "error" && ( {t("provider test error")} @@ -421,22 +379,11 @@ const AccountAiGatewayTab = memo((props: Props) => { } buttons={ <> - - } @@ -445,14 +392,14 @@ const AccountAiGatewayTab = memo((props: Props) => { ); }); -type ModelCatalogSectionProps = { +type ModelsSectionProps = { providerId: string; - modelCatalog: ModelCatalog; - selection: ModelSelection; + models: Models; + selectedModel: string | undefined; }; -const ModelCatalogSection = memo((props: ModelCatalogSectionProps) => { - const { providerId, modelCatalog, selection } = props; +const ModelsSection = memo((props: ModelsSectionProps) => { + const { providerId, models, selectedModel } = props; const { classes } = useStyles(); const { t } = useTranslation({ AccountAiGatewayTab }); @@ -464,9 +411,11 @@ const ModelCatalogSection = memo((props: ModelCatalogSectionProps) => { ai.setSelectedModel({ providerId, modelId: event.target.value }) ); - switch (modelCatalog.stateDescription) { - case "not fetched": - return null; + if (models === undefined) { + return null; + } + + switch (models.stateDescription) { case "fetching": return ; case "error": @@ -477,14 +426,12 @@ const ModelCatalogSection = memo((props: ModelCatalogSectionProps) => { ); case "loaded": return ( - <> - - + ); } }); From 2555228ab013dd3b6688f27a9af056ee64475346 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:51:54 +0200 Subject: [PATCH 09/25] add env var to enable ai feature (disabled by default) --- web/.env | 9 +++++++++ web/src/core/bootstrap.ts | 9 ++++++++- web/src/core/usecases/ai/thunks.ts | 14 +++++--------- web/src/env.ts | 22 ++++++++++++++++++++++ web/src/ui/App/App.tsx | 1 + web/src/vite-env.d.ts | 1 + 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/web/.env b/web/.env index 88aacc4f5..1a15d3d27 100644 --- a/web/.env +++ b/web/.env @@ -861,6 +861,15 @@ ONYXIA_API_URL=/api DISABLE_DISPLAY_ALL_CATALOG=false +# Master switch for the AI feature (Account > AI tab: region-provided AI gateways +# and user-added custom OpenAI-compatible providers). +# +# When set to "false" (the default), the AI feature is entirely hidden, even if a +# deployment region declares AI gateways. Set to "true" to enable it; region +# gateways are then listed when present, and users can always add custom providers. +ENABLED_AI=false + + # ================================================================================== # Private parameters - Not expected to be configured by the instance administrator # ================================================================================== diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 515e79ec3..a9ab11ae0 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -30,6 +30,7 @@ export type ParamsOfBootstrapCore = { isAuthGloballyRequired: boolean; enableOidcDebugLogs: boolean; disableDisplayAllCatalog: boolean; + isAiEnabled: boolean; getIsDarkModeEnabled: () => boolean; }; @@ -52,7 +53,8 @@ export async function bootstrapCore( transformBeforeRedirectForKeycloakTheme, getCurrentLang, isAuthGloballyRequired, - enableOidcDebugLogs + enableOidcDebugLogs, + isAiEnabled } = params; let isCoreCreated = false; @@ -276,6 +278,10 @@ export async function bootstrapCore( } init_ai: { + if (!isAiEnabled) { + break init_ai; + } + if (!oidc.isUserLoggedIn) { break init_ai; } @@ -286,6 +292,7 @@ export async function bootstrapCore( ); if (deploymentRegion.ai.length === 0) { + dispatch(usecases.ai.protectedThunks.initialize()); break init_ai; } diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index 551d34d0e..11334f0f0 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -10,7 +10,6 @@ import { import { fetchAiModels } from "core/tools/fetchAiModels"; import type { GetTokenResult } from "core/ports/Ai"; import * as userConfigs from "core/usecases/userConfigs"; -import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { assert } from "tsafe"; function getTokenResultToAuth(result: GetTokenResult): State.Provider.Region["auth"] { @@ -42,10 +41,9 @@ export const thunks = { isAvailable: () => (...args): boolean => { - const [, getState] = args; - const region = - deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); - return region.ai.length > 0; + const [, , { paramsOfBootstrapCore }] = args; + + return paramsOfBootstrapCore.isAiEnabled; }, refreshToken: (params: { providerId: string }) => @@ -190,10 +188,8 @@ export const protectedThunks = { async (...args) => { const [dispatch, getState, { ai }] = args; - if (ai.length === 0) { - return; - } - + // `ai` (region-provided adapters) may be empty: the feature can be enabled + // with no region gateway, in which case only custom providers are loaded. // Build one region provider per region-provided endpoint, keeping a handle // on its adapter + token result for the post-init model fetch. let regionEntries; diff --git a/web/src/env.ts b/web/src/env.ts index 9ed568897..334cbecfa 100644 --- a/web/src/env.ts +++ b/web/src/env.ts @@ -1277,6 +1277,28 @@ export const { env, injectEnvsTransferableToKeycloakTheme } = createParsedEnvs([ return envValue === "true"; } }, + { + envName: "ENABLED_AI", + isUsedInKeycloakTheme: false, + validateAndParseOrGetDefault: ({ envValue, envName }) => { + const possibleValues = ["true", "false"]; + + assert( + possibleValues.indexOf(envValue) >= 0, + `${envName} should either be ${possibleValues.join(" or ")}` + ); + + return envValue === "true"; + } + }, + { + envName: "S3_DOCUMENTATION_LINK", + isUsedInKeycloakTheme: false, + validateAndParseOrGetDefault: ({ envValue }) => { + assert(envValue !== "", "Should have default in .env"); + return envValue; + } + }, { envName: "VAULT_DOCUMENTATION_LINK", isUsedInKeycloakTheme: false, diff --git a/web/src/ui/App/App.tsx b/web/src/ui/App/App.tsx index 3451434bf..67f843f22 100644 --- a/web/src/ui/App/App.tsx +++ b/web/src/ui/App/App.tsx @@ -37,6 +37,7 @@ triggerCoreBootstrap({ isAuthGloballyRequired: env.AUTHENTICATION_GLOBALLY_REQUIRED, enableOidcDebugLogs: env.OIDC_DEBUG_LOGS, disableDisplayAllCatalog: env.DISABLE_DISPLAY_ALL_CATALOG, + isAiEnabled: env.ENABLED_AI, getIsDarkModeEnabled: () => evtTheme.state.isDarkModeEnabled }); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 53efc020e..f8db13f28 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -60,6 +60,7 @@ type ImportMetaEnv = { VAULT_DOCUMENTATION_LINK: string ONYXIA_API_URL: string DISABLE_DISPLAY_ALL_CATALOG: string + ENABLED_AI: string ONYXIA_VERSION: string ONYXIA_VERSION_URL: string SCREEN_SCALER: string From e49c080d4de1ca788a5948a8dfbd980b4cca4241 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:38:43 +0200 Subject: [PATCH 10/25] add provider with openai as default --- web/src/core/adapters/ai/mock.ts | 10 ++++-- web/src/core/adapters/ai/openWebUi.ts | 4 ++- web/src/core/adapters/onyxiaApi/ApiTypes.ts | 1 + web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 1 + web/src/core/bootstrap.ts | 1 + web/src/core/ports/Ai.ts | 6 ++++ .../core/ports/OnyxiaApi/DeploymentRegion.ts | 5 +++ .../decoupledLogic/persistedAiConfig.test.ts | 1 + .../ai/decoupledLogic/persistedAiConfig.ts | 4 +++ web/src/core/usecases/ai/selectors.ts | 8 ++--- web/src/core/usecases/ai/state.ts | 8 +++++ web/src/core/usecases/ai/thunks.ts | 24 +++++++++++--- web/src/ui/i18n/resources/de.tsx | 1 + web/src/ui/i18n/resources/en.tsx | 1 + web/src/ui/i18n/resources/es.tsx | 1 + web/src/ui/i18n/resources/fi.tsx | 1 + web/src/ui/i18n/resources/fr.tsx | 1 + web/src/ui/i18n/resources/it.tsx | 6 ++-- web/src/ui/i18n/resources/nl.tsx | 1 + web/src/ui/i18n/resources/no.tsx | 1 + web/src/ui/i18n/resources/zh-CN.tsx | 1 + web/src/ui/pages/account/AccountAiTab.tsx | 31 ++++++++++++++++--- 22 files changed, 96 insertions(+), 22 deletions(-) diff --git a/web/src/core/adapters/ai/mock.ts b/web/src/core/adapters/ai/mock.ts index ba53e9c0d..4a97ffa4b 100644 --- a/web/src/core/adapters/ai/mock.ts +++ b/web/src/core/adapters/ai/mock.ts @@ -1,11 +1,17 @@ import type { Ai } from "core/ports/Ai"; -export function createAi(params: { id: string; name: string; webUiUrl: string }): Ai { - const { id, name, webUiUrl } = params; +export function createAi(params: { + id: string; + name: string; + provider: string; + webUiUrl: string; +}): Ai { + const { id, name, provider, webUiUrl } = params; return { id, name, + provider, webUiUrl, apiBase: `${webUiUrl}/api`, getToken: async () => ({ status: "success" as const, token: "mock-ai-token" }), diff --git a/web/src/core/adapters/ai/openWebUi.ts b/web/src/core/adapters/ai/openWebUi.ts index 19fc7c814..0e25fbd3f 100644 --- a/web/src/core/adapters/ai/openWebUi.ts +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -5,17 +5,19 @@ import { z } from "zod"; export function createAi(params: { id: string; name: string; + provider: string; webUiUrl: string; oauthProvider: string; getOidcAccessToken: () => Promise; }): Ai { - const { id, name, webUiUrl, oauthProvider, getOidcAccessToken } = params; + const { id, name, provider, webUiUrl, oauthProvider, getOidcAccessToken } = params; const apiBase = `${webUiUrl}/api`; return { id, name, + provider, webUiUrl, apiBase, getToken: async (): Promise => { diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index d7ee21217..1ec34f5fa 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -86,6 +86,7 @@ export type ApiTypes = { id?: string; URL: string; name?: string; + provider?: string; oauthProvider: string; oidcConfiguration?: Partial; }>; diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 9a28aff24..9e5b1dab9 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -449,6 +449,7 @@ export function createOnyxiaApi(params: { id: aiConfig_api.id ?? aiConfig_api.URL, url: aiConfig_api.URL, name: aiConfig_api.name, + provider: aiConfig_api.provider ?? "openai", oauthProvider: aiConfig_api.oauthProvider, oidcParams: apiTypesOidcConfigurationToOidcParams_Partial( diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index a9ab11ae0..49c4a1659 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -343,6 +343,7 @@ export async function bootstrapCore( createAi({ id: aiConfig.id, name: aiConfig.name ?? new URL(aiConfig.url).hostname, + provider: aiConfig.provider, webUiUrl: aiConfig.url, oauthProvider: aiConfig.oauthProvider, getOidcAccessToken diff --git a/web/src/core/ports/Ai.ts b/web/src/core/ports/Ai.ts index 267f9e369..47b0c98c3 100644 --- a/web/src/core/ports/Ai.ts +++ b/web/src/core/ports/Ai.ts @@ -1,6 +1,12 @@ export type Ai = { id: string; name: string; + /** + * LLM provider family the endpoint speaks (e.g. "openai", "anthropic", "gemini"). + * Injected as `ai.provider` in the service launch context so charts know which + * API dialect to use. Defaults to "openai" (OpenAI-compatible) for region gateways. + */ + provider: string; webUiUrl: string; apiBase: string; getToken: () => Promise; diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 7120e98bd..5ed5c9690 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -48,6 +48,11 @@ export type DeploymentRegion = { id: string; url: string; name: string | undefined; + /** + * LLM provider family the gateway speaks (e.g. "openai", "anthropic", + * "gemini"). Defaults to "openai" since the gateway is OpenAI-compatible. + */ + provider: string; oauthProvider: string; oidcParams: OidcParams_Partial; }[]; diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts index 60422aa76..b6330d5f7 100644 --- a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts @@ -11,6 +11,7 @@ const sampleConfig: PersistedAiConfig = { { id: "p1", label: "My provider", + provider: "openai", apiBase: "https://api.openai.com/v1", apiKey: "sk-secret" } diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts index b892a632c..193c59aea 100644 --- a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts @@ -9,6 +9,9 @@ export type PersistedModelSelection = { export type PersistedCustomProvider = { id: string; label: string; + // Optional for backward compatibility with configs persisted before the field + // existed; defaulted to "openai" when read back. + provider?: string; apiBase: string; apiKey: string; }; @@ -31,6 +34,7 @@ const zPersistedAiConfig: z.ZodType = z.object({ z.object({ id: z.string(), label: z.string(), + provider: z.string().optional(), apiBase: z.string(), apiKey: z.string() }) diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index 1a603f48a..be67c2787 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -13,6 +13,7 @@ const main = createSelector(state, state => { const toCommonView = (provider: State.Provider) => ({ id: provider.id, + provider: provider.provider, apiBase: provider.apiBase, isActive: provider.id === activeProviderId, // A provider can only be wired into services once its models are listed. @@ -41,11 +42,6 @@ const main = createSelector(state, state => { }; }); -/** Display name of a provider, whatever its kind. */ -function getProviderName(provider: State.Provider): string { - return provider.kind === "region" ? provider.name : provider.label; -} - /** Credentials usable to call a provider, or undefined when it isn't ready. */ function getProviderApiKey(provider: State.Provider): string | undefined { if (provider.kind === "custom") return provider.apiKey; @@ -89,7 +85,7 @@ const activeProvider = createSelector( apiKey, apiBase: provider.apiBase, model: selectedModel, - provider: getProviderName(provider) + provider: provider.provider }; } ); diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts index 9d1c1ada1..5f671bda8 100644 --- a/web/src/core/usecases/ai/state.ts +++ b/web/src/core/usecases/ai/state.ts @@ -25,6 +25,12 @@ export declare namespace State { export type Common = { id: string; apiBase: string; + /** + * LLM provider family (e.g. "openai", "anthropic", "gemini"), injected as + * `ai.provider` in the service launch context. For region providers it + * comes from the deployment region config; for custom ones the user sets it. + */ + provider: string; models: Models | undefined; selectedModelId: string | undefined; }; @@ -145,6 +151,7 @@ export const { reducer, actions } = createUsecaseActions({ payload: { providerId: string; label: string; + provider: string; apiBase: string; apiKey: string; }; @@ -156,6 +163,7 @@ export const { reducer, actions } = createUsecaseActions({ assert(provider !== undefined); assert(provider.kind === "custom"); provider.label = payload.label; + provider.provider = payload.provider; provider.apiBase = payload.apiBase; provider.apiKey = payload.apiKey; // Credentials changed → the previous models list no longer applies. diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index 11334f0f0..d9659c64a 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -94,9 +94,9 @@ export const thunks = { // The add/edit form (values, validation, connection-test result, open state) is // owned by the UI. The core only exposes the resulting operations on the state. addCustomProvider: - (params: { label: string; apiBase: string; apiKey: string }) => + (params: { label: string; provider: string; apiBase: string; apiKey: string }) => async (...args) => { - const { label, apiBase, apiKey } = params; + const { label, provider, apiBase, apiKey } = params; const [dispatch] = args; const providerId = crypto.randomUUID(); @@ -107,6 +107,7 @@ export const thunks = { kind: "custom", id: providerId, label, + provider, apiBase, apiKey, models: { stateDescription: "fetching" }, @@ -122,14 +123,23 @@ export const thunks = { (params: { providerId: string; label: string; + provider: string; apiBase: string; apiKey: string; }) => async (...args) => { - const { providerId, label, apiBase, apiKey } = params; + const { providerId, label, provider, apiBase, apiKey } = params; const [dispatch] = args; - dispatch(actions.editCustomProvider({ providerId, label, apiBase, apiKey })); + dispatch( + actions.editCustomProvider({ + providerId, + label, + provider, + apiBase, + apiKey + }) + ); await dispatch(privateThunks.persistConfig()); await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); @@ -158,9 +168,10 @@ const privateThunks = { const aiConfig: PersistedAiConfig = { customProviders: state.providers .filter((p): p is State.Provider.Custom => p.kind === "custom") - .map(({ id, label, apiBase, apiKey }) => ({ + .map(({ id, label, provider, apiBase, apiKey }) => ({ id, label, + provider, apiBase, apiKey })), @@ -208,6 +219,7 @@ export const protectedThunks = { kind: "region", id: aiProvider.id, name: aiProvider.name, + provider: aiProvider.provider, webUiUrl: aiProvider.webUiUrl, apiBase: aiProvider.apiBase, auth: getTokenResultToAuth(tokenResult), @@ -230,6 +242,8 @@ export const protectedThunks = { kind: "custom", id: p.id, label: p.label, + // Configs persisted before the field existed default to "openai". + provider: p.provider ?? "openai", apiBase: p.apiBase, apiKey: p.apiKey, models: { stateDescription: "fetching" }, diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 3e8d01819..02f456bd9 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -118,6 +118,7 @@ export const translations: Translations<"de"> = { "Fügen Sie Ihre eigenen OpenAI-kompatiblen KI-Anbieter hinzu. Die Anmeldedaten werden in Ihrem Browser gespeichert.", "edit custom provider title": "KI-Anbieter bearbeiten", "custom provider label field": "Name", + "custom provider type field": "Provider-Typ", "custom provider api base field": "API-Basis-URL", "custom provider api key field": "API-Schlüssel", "provider test": "Verbindung testen", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 6e91437ca..d76bdb0b5 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -116,6 +116,7 @@ export const translations: Translations<"en"> = { "Add your own OpenAI-compatible AI providers. Credentials are stored in your browser.", "edit custom provider title": "Edit AI provider", "custom provider label field": "Label", + "custom provider type field": "Provider type", "custom provider api base field": "API base URL", "custom provider api key field": "API key", "provider test": "Test connection", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 4f96bf581..aee4b6119 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -117,6 +117,7 @@ export const translations: Translations<"es"> = { "Añade tus propios proveedores de IA compatibles con OpenAI. Las credenciales se almacenan en tu navegador.", "edit custom provider title": "Editar proveedor de IA", "custom provider label field": "Etiqueta", + "custom provider type field": "Tipo de proveedor", "custom provider api base field": "URL base de la API", "custom provider api key field": "Clave API", "provider test": "Probar conexión", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 3323e453e..4a31f8d02 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -117,6 +117,7 @@ export const translations: Translations<"fi"> = { "Lisää omia OpenAI-yhteensopivia tekoälypalveluntarjoajia. Tunnukset tallennetaan selaimeesi.", "edit custom provider title": "Muokkaa tekoälyntarjoajaa", "custom provider label field": "Tunniste", + "custom provider type field": "Palveluntarjoajan tyyppi", "custom provider api base field": "API-perus-URL", "custom provider api key field": "API-avain", "provider test": "Testaa yhteys", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 7ce25cdd9..69c17b731 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -118,6 +118,7 @@ export const translations: Translations<"fr"> = { "Ajoutez vos propres providers IA compatibles OpenAI. Les identifiants sont stockés dans votre navigateur.", "edit custom provider title": "Modifier le provider IA", "custom provider label field": "Nom", + "custom provider type field": "Type de provider", "custom provider api base field": "URL de base de l'API", "custom provider api key field": "Clé API", "provider test": "Tester la connexion", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 76ac931d5..30ec8c301 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -116,6 +116,7 @@ export const translations: Translations<"it"> = { "Aggiungi i tuoi provider IA compatibili con OpenAI. Le credenziali sono memorizzate nel tuo browser.", "edit custom provider title": "Modifica provider IA", "custom provider label field": "Etichetta", + "custom provider type field": "Tipo di provider", "custom provider api base field": "URL base API", "custom provider api key field": "Chiave API", "provider test": "Testa connessione", @@ -552,9 +553,8 @@ export const translations: Translations<"it"> = { la nostra documentazione .   - - Configurare il tuo Vault CLI locale - . + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 9a4511219..3fd6d1d5a 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -117,6 +117,7 @@ export const translations: Translations<"nl"> = { "Voeg uw eigen OpenAI-compatibele AI-providers toe. Inloggegevens worden in uw browser opgeslagen.", "edit custom provider title": "AI-provider bewerken", "custom provider label field": "Label", + "custom provider type field": "Providertype", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-sleutel", "provider test": "Verbinding testen", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index b28d7056a..58e816ff5 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -117,6 +117,7 @@ export const translations: Translations<"no"> = { "Legg til dine egne OpenAI-kompatible AI-leverandører. Påloggingsinformasjonen lagres i nettleseren din.", "edit custom provider title": "Rediger AI-leverandør", "custom provider label field": "Etikett", + "custom provider type field": "Leverandørtype", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-nøkkel", "provider test": "Test tilkobling", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index b22c8d9ff..1cc2f11fb 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -108,6 +108,7 @@ export const translations: Translations<"zh-CN"> = { "添加您自己的兼容 OpenAI 的 AI 提供商。凭据存储在您的浏览器中。", "edit custom provider title": "编辑 AI 提供商", "custom provider label field": "标签", + "custom provider type field": "提供商类型", "custom provider api base field": "API 基础 URL", "custom provider api key field": "API 密钥", "provider test": "测试连接", diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx index b8ffb5636..4dae63d97 100644 --- a/web/src/ui/pages/account/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -33,7 +33,12 @@ type Models = | { stateDescription: "loaded"; availableModels: AiModel[] } | undefined; -type FormValues = { label: string; apiBase: string; apiKey: string }; +type FormValues = { + label: string; + provider: string; + apiBase: string; + apiKey: string; +}; type FormTest = | { stateDescription: "idle" } @@ -85,13 +90,18 @@ const AccountAiGatewayTab = memo((props: Props) => { ); const [values, setValues] = useState({ label: "", + provider: "openai", apiBase: "", apiKey: "" }); const [test, setTest] = useState({ stateDescription: "idle" }); const isEditing = editedProviderId !== undefined; - const canSave = values.label !== "" && values.apiBase !== "" && values.apiKey !== ""; + const canSave = + values.label !== "" && + values.provider !== "" && + values.apiBase !== "" && + values.apiKey !== ""; const canTest = values.apiBase !== "" && values.apiKey !== "" && @@ -99,7 +109,7 @@ const AccountAiGatewayTab = memo((props: Props) => { const onAddClick = useConstCallback(() => { setEditedProviderId(undefined); - setValues({ label: "", apiBase: "", apiKey: "" }); + setValues({ label: "", provider: "openai", apiBase: "", apiKey: "" }); setTest({ stateDescription: "idle" }); setIsFormOpen(true); }); @@ -110,6 +120,7 @@ const AccountAiGatewayTab = memo((props: Props) => { setEditedProviderId(providerId); setValues({ label: provider.label, + provider: provider.provider, apiBase: provider.apiBase, apiKey: provider.apiKey }); @@ -123,8 +134,9 @@ const AccountAiGatewayTab = memo((props: Props) => { ([key]: [keyof FormValues], [event]: [{ target: { value: string } }]) => { const { value } = event.target; setValues(values => ({ ...values, [key]: value })); - // Credentials changed → a previous test result no longer applies. - if (key !== "label") { + // Only credential changes invalidate a previous connection-test result; + // the display label and provider type don't affect connectivity. + if (key !== "label" && key !== "provider") { setTest({ stateDescription: "idle" }); } } @@ -336,6 +348,14 @@ const AccountAiGatewayTab = memo((props: Props) => { size="small" fullWidth /> + Date: Mon, 29 Jun 2026 14:30:48 +0200 Subject: [PATCH 11/25] remove mock and handle provider correctly --- web/src/core/adapters/ai/mock.ts | 24 ------------------- web/src/core/adapters/ai/openWebUi.ts | 5 ++-- web/src/core/adapters/onyxiaApi/ApiTypes.ts | 1 - web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 1 - web/src/core/bootstrap.ts | 1 - .../core/ports/OnyxiaApi/DeploymentRegion.ts | 5 ---- 6 files changed, 2 insertions(+), 35 deletions(-) delete mode 100644 web/src/core/adapters/ai/mock.ts diff --git a/web/src/core/adapters/ai/mock.ts b/web/src/core/adapters/ai/mock.ts deleted file mode 100644 index 4a97ffa4b..000000000 --- a/web/src/core/adapters/ai/mock.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Ai } from "core/ports/Ai"; - -export function createAi(params: { - id: string; - name: string; - provider: string; - webUiUrl: string; -}): Ai { - const { id, name, provider, webUiUrl } = params; - - return { - id, - name, - provider, - webUiUrl, - apiBase: `${webUiUrl}/api`, - getToken: async () => ({ status: "success" as const, token: "mock-ai-token" }), - listModels: async () => [ - { id: "llama3.2", name: "Llama 3.2" }, - { id: "mistral-7b", name: "Mistral 7B" }, - { id: "codestral", name: "Codestral" } - ] - }; -} diff --git a/web/src/core/adapters/ai/openWebUi.ts b/web/src/core/adapters/ai/openWebUi.ts index 0e25fbd3f..51dbe9a11 100644 --- a/web/src/core/adapters/ai/openWebUi.ts +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -5,19 +5,18 @@ import { z } from "zod"; export function createAi(params: { id: string; name: string; - provider: string; webUiUrl: string; oauthProvider: string; getOidcAccessToken: () => Promise; }): Ai { - const { id, name, provider, webUiUrl, oauthProvider, getOidcAccessToken } = params; + const { id, name, webUiUrl, oauthProvider, getOidcAccessToken } = params; const apiBase = `${webUiUrl}/api`; return { id, name, - provider, + provider: "openai", webUiUrl, apiBase, getToken: async (): Promise => { diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 1ec34f5fa..d7ee21217 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -86,7 +86,6 @@ export type ApiTypes = { id?: string; URL: string; name?: string; - provider?: string; oauthProvider: string; oidcConfiguration?: Partial; }>; diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 9e5b1dab9..9a28aff24 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -449,7 +449,6 @@ export function createOnyxiaApi(params: { id: aiConfig_api.id ?? aiConfig_api.URL, url: aiConfig_api.URL, name: aiConfig_api.name, - provider: aiConfig_api.provider ?? "openai", oauthProvider: aiConfig_api.oauthProvider, oidcParams: apiTypesOidcConfigurationToOidcParams_Partial( diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 49c4a1659..a9ab11ae0 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -343,7 +343,6 @@ export async function bootstrapCore( createAi({ id: aiConfig.id, name: aiConfig.name ?? new URL(aiConfig.url).hostname, - provider: aiConfig.provider, webUiUrl: aiConfig.url, oauthProvider: aiConfig.oauthProvider, getOidcAccessToken diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 5ed5c9690..7120e98bd 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -48,11 +48,6 @@ export type DeploymentRegion = { id: string; url: string; name: string | undefined; - /** - * LLM provider family the gateway speaks (e.g. "openai", "anthropic", - * "gemini"). Defaults to "openai" since the gateway is OpenAI-compatible. - */ - provider: string; oauthProvider: string; oidcParams: OidcParams_Partial; }[]; From 59137ff1654877c6c6e37145468beb5bf6b6869a Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:20:45 +0200 Subject: [PATCH 12/25] improve ai x onyxia context et selectors --- web/src/core/ports/OnyxiaApi/XOnyxia.ts | 5 +- web/src/core/usecases/ai/selectors.ts | 131 +++++++++++------------ web/src/core/usecases/launcher/thunks.ts | 2 +- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index 5513ea91c..582583627 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -185,10 +185,13 @@ export type XOnyxiaContext = { ai: | { enabled: boolean; + id: string; apiKey: string; apiBase: string; - model: string; provider: string; + name: string; + selectedModel: string | undefined; + models: string[]; } | undefined; proxyInjection: diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index be67c2787..712a8c8fc 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -1,62 +1,57 @@ import { createSelector } from "clean-architecture"; import type { State as RootState } from "core/bootstrap"; -import type { XOnyxiaContext } from "core/ports/OnyxiaApi"; import { name } from "./state"; import type { State } from "./state"; const state = (rootState: RootState) => rootState[name]; -const main = createSelector(state, state => { - const providers = state.stateDescription === "initialized" ? state.providers : []; - const activeProviderId = - state.stateDescription === "initialized" ? state.activeProviderId : undefined; - - const toCommonView = (provider: State.Provider) => ({ - id: provider.id, - provider: provider.provider, - apiBase: provider.apiBase, - isActive: provider.id === activeProviderId, - // A provider can only be wired into services once its models are listed. - canBeActivated: provider.models?.stateDescription === "loaded", - models: provider.models, - selectedModelId: provider.selectedModelId - }); +const providers = createSelector(state, state => + state.stateDescription === "initialized" ? state.providers : undefined +); - return { - stateDescription: state.stateDescription, - regionProviders: providers - .filter((p): p is State.Provider.Region => p.kind === "region") - .map(p => ({ - ...toCommonView(p), - name: p.name, - webUiUrl: p.webUiUrl, - auth: p.auth - })), - customProviders: providers - .filter((p): p is State.Provider.Custom => p.kind === "custom") - .map(p => ({ - ...toCommonView(p), - label: p.label, - apiKey: p.apiKey - })) - }; -}); +const activeProviderId = createSelector(state, state => + state.stateDescription === "initialized" ? state.activeProviderId : undefined +); -/** Credentials usable to call a provider, or undefined when it isn't ready. */ -function getProviderApiKey(provider: State.Provider): string | undefined { - if (provider.kind === "custom") return provider.apiKey; - if (provider.auth.stateDescription !== "authenticated") return undefined; - return provider.auth.token; -} +const main = createSelector( + state, + providers, + activeProviderId, + (state, providers, activeProviderId) => { + const toCommonView = (provider: State.Provider) => ({ + id: provider.id, + provider: provider.provider, + apiBase: provider.apiBase, + isActive: provider.id === activeProviderId, + // A provider can only be wired into services once its models are listed. + canBeActivated: provider.models?.stateDescription === "loaded", + models: provider.models, + selectedModelId: provider.selectedModelId + }); -const providers = createSelector(state, state => - state.stateDescription === "initialized" ? state.providers : undefined + return { + stateDescription: state.stateDescription, + regionProviders: (providers ?? []) + .filter((p): p is State.Provider.Region => p.kind === "region") + .map(p => ({ + ...toCommonView(p), + name: p.name, + webUiUrl: p.webUiUrl, + auth: p.auth + })), + customProviders: (providers ?? []) + .filter((p): p is State.Provider.Custom => p.kind === "custom") + .map(p => ({ + ...toCommonView(p), + label: p.label, + apiKey: p.apiKey + })) + }; + } ); const resolvedActiveProvider = createSelector( - createSelector(state, state => - state.stateDescription === "initialized" ? state.activeProviderId : undefined - ), + activeProviderId, providers, (activeProviderId, providers) => { if (activeProviderId === undefined || providers === undefined) return undefined; @@ -64,30 +59,32 @@ const resolvedActiveProvider = createSelector( } ); -const activeProvider = createSelector( - resolvedActiveProvider, - (provider): XOnyxiaContext["ai"] => { - if (provider === undefined) return undefined; - if (provider.models?.stateDescription !== "loaded") return undefined; +const aiOnyxiaContext = createSelector(resolvedActiveProvider, provider => { + if (provider === undefined) return undefined; + if (provider.models?.stateDescription !== "loaded") return undefined; - const apiKey = getProviderApiKey(provider); + const apiKey = (() => { + if (provider.kind === "custom") return provider.apiKey; + if (provider.auth.stateDescription !== "authenticated") return undefined; + return provider.auth.token; + })(); - if (apiKey === undefined) return undefined; + if (apiKey === undefined) return undefined; - const { selectedModelId: selectedModel } = provider; + const models = provider.models.availableModels.map(m => m.id); - // No usable model (e.g. the models list loaded empty) → the provider isn't ready - // to be wired into services; don't inject an empty model name. - if (selectedModel === undefined || selectedModel === "") return undefined; + const selectedModel = provider.selectedModelId; - return { - enabled: true, - apiKey, - apiBase: provider.apiBase, - model: selectedModel, - provider: provider.provider - }; - } -); + return { + enabled: true, + id: provider.id, + apiKey, + apiBase: provider.apiBase, + provider: provider.provider, + name: provider.kind === "region" ? provider.name : provider.label, + selectedModel, + models + }; +}); -export const selectors = { main, activeProvider }; +export const selectors = { main, aiOnyxiaContext }; diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index 0da62d68b..821a08a55 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -769,7 +769,7 @@ export const protectedThunks = { useCertManager: region.certManager?.useCertManager, certManagerClusterIssuer: region.certManager?.certManagerClusterIssuer }, - ai: aiUsecase.selectors.activeProvider(getState()), + ai: aiUsecase.selectors.aiOnyxiaContext(getState()), proxyInjection: region.proxyInjection, packageRepositoryInjection: region.packageRepositoryInjection, certificateAuthorityInjection: region.certificateAuthorityInjection From 505c3d44ec2ccd86151e3cabcf9a05d9a99f6bb3 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:49:27 +0200 Subject: [PATCH 13/25] Update onyxiaApi.ts --- web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 9a28aff24..04128b32f 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -445,8 +445,8 @@ export function createOnyxiaApi(params: { ? value : [value]; - return aiConfigs_api.map(aiConfig_api => ({ - id: aiConfig_api.id ?? aiConfig_api.URL, + return aiConfigs_api.map((aiConfig_api, i) => ({ + id: aiConfig_api.id ?? `onyxia-${i}`, url: aiConfig_api.URL, name: aiConfig_api.name, oauthProvider: aiConfig_api.oauthProvider, From dd2c32eae0291560a630a9be7184b81d4fcd103b Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:30:58 +0200 Subject: [PATCH 14/25] Update env.ts --- web/src/env.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/web/src/env.ts b/web/src/env.ts index 334cbecfa..e06110a4f 100644 --- a/web/src/env.ts +++ b/web/src/env.ts @@ -1291,14 +1291,6 @@ export const { env, injectEnvsTransferableToKeycloakTheme } = createParsedEnvs([ return envValue === "true"; } }, - { - envName: "S3_DOCUMENTATION_LINK", - isUsedInKeycloakTheme: false, - validateAndParseOrGetDefault: ({ envValue }) => { - assert(envValue !== "", "Should have default in .env"); - return envValue; - } - }, { envName: "VAULT_DOCUMENTATION_LINK", isUsedInKeycloakTheme: false, From 0fdabf84a626f1502e68d52ec5cb51ef16176a19 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:32:44 +0200 Subject: [PATCH 15/25] improve initialize --- web/src/core/bootstrap.ts | 105 ++++++++++++----------- web/src/core/ports/OnyxiaApi/XOnyxia.ts | 29 ++++--- web/src/core/usecases/ai/selectors.ts | 69 ++++++++------- web/src/core/usecases/ai/thunks.ts | 55 +++++++++++- web/src/core/usecases/launcher/thunks.ts | 8 ++ 5 files changed, 171 insertions(+), 95 deletions(-) diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index a9ab11ae0..41a2e5b3a 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -291,65 +291,72 @@ export async function bootstrapCore( getState() ); - if (deploymentRegion.ai.length === 0) { - dispatch(usecases.ai.protectedThunks.initialize()); - break init_ai; - } + // Wire one Ai adapter per region-provided gateway into `context.ai` (none if + // the region exposes no AI: only custom providers will then be loaded). + region_ai: { + if (deploymentRegion.ai.length === 0) { + break region_ai; + } - const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] = - await Promise.all([ - import("core/adapters/ai"), - import("core/adapters/oidc"), - onyxiaApi.getAvailableRegionsAndOidcParams() - ]); + const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] = + await Promise.all([ + import("core/adapters/ai"), + import("core/adapters/oidc"), + onyxiaApi.getAvailableRegionsAndOidcParams() + ]); - assert(oidcParams !== undefined); + assert(oidcParams !== undefined); - // One Ai adapter per region-provided provider, but providers may share the - // same OIDC client: oidc-spa identifies a client by issuerUri + clientId, so - // creating it twice would collide. Create each distinct client only once. - const getOidcAccessTokenByOidcKey = new Map Promise>(); + // Providers may share the same OIDC client: oidc-spa identifies a client by + // issuerUri + clientId, so creating it twice would collide. Create each + // distinct client only once. + const getOidcAccessTokenByOidcKey = new Map Promise>(); - for (const aiConfig of deploymentRegion.ai) { - const oidcParams_ai = mergeOidcParams({ - oidcParams, - oidcParams_partial: aiConfig.oidcParams - }); - - const oidcKey = `${oidcParams_ai.issuerUri}${oidcParams_ai.clientId}`; - - let getOidcAccessToken = getOidcAccessTokenByOidcKey.get(oidcKey); - - if (getOidcAccessToken === undefined) { - const oidc_ai = await createOidc({ - ...oidcParams_ai, - transformBeforeRedirectForKeycloakTheme, - getCurrentLang, - autoLogin: true, - enableDebugLogs: enableOidcDebugLogs, - // The access token is handed over to OpenWebUI's token exchange - // endpoint, which validates it server-side and cannot present a - // DPoP proof. It must therefore be a plain bearer token, never - // sender-constrained, even when DPoP is globally enabled. - disableDPoP: true + for (const aiConfig of deploymentRegion.ai) { + const oidcParams_ai = mergeOidcParams({ + oidcParams, + oidcParams_partial: aiConfig.oidcParams }); - getOidcAccessToken = async () => (await oidc_ai.getTokens()).accessToken; + const oidcKey = `${oidcParams_ai.issuerUri}${oidcParams_ai.clientId}`; - getOidcAccessTokenByOidcKey.set(oidcKey, getOidcAccessToken); - } + let getOidcAccessToken = getOidcAccessTokenByOidcKey.get(oidcKey); - context.ai.push( - createAi({ - id: aiConfig.id, - name: aiConfig.name ?? new URL(aiConfig.url).hostname, - webUiUrl: aiConfig.url, - oauthProvider: aiConfig.oauthProvider, - getOidcAccessToken - }) - ); + if (getOidcAccessToken === undefined) { + const oidc_ai = await createOidc({ + ...oidcParams_ai, + transformBeforeRedirectForKeycloakTheme, + getCurrentLang, + autoLogin: true, + enableDebugLogs: enableOidcDebugLogs, + // The access token is handed over to OpenWebUI's token exchange + // endpoint, which validates it server-side and cannot present a + // DPoP proof. It must therefore be a plain bearer token, never + // sender-constrained, even when DPoP is globally enabled. + disableDPoP: true + }); + + getOidcAccessToken = async () => + (await oidc_ai.getTokens()).accessToken; + + getOidcAccessTokenByOidcKey.set(oidcKey, getOidcAccessToken); + } + + context.ai.push( + createAi({ + id: aiConfig.id, + name: aiConfig.name ?? new URL(aiConfig.url).hostname, + webUiUrl: aiConfig.url, + oauthProvider: aiConfig.oauthProvider, + getOidcAccessToken + }) + ); + } } + // Sole initiator of the AI use-case, dispatched only now that any region + // adapters are wired into `context.ai`. Fire-and-forget so app start isn't + // blocked; consumers await readiness via `ai...waitForInitialization`. dispatch(usecases.ai.protectedThunks.initialize()); } diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index 582583627..96bc97d19 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -182,18 +182,11 @@ export type XOnyxiaContext = { useCertManager: boolean; certManagerClusterIssuer: string | undefined; }; - ai: - | { - enabled: boolean; - id: string; - apiKey: string; - apiBase: string; - provider: string; - name: string; - selectedModel: string | undefined; - models: string[]; - } - | undefined; + ai: { + enabled: boolean; + activeProvider: AiProvider | undefined; + providers: AiProvider[]; + }; proxyInjection: | { enabled: boolean | undefined; @@ -219,3 +212,15 @@ export type XOnyxiaContext = { }; assert>(); + +type AiProvider = { + id: string; + apiKey: string; + apiBase: string; + name: string; + selectedModel: string | undefined; + // undefined while the provider's model list is still being fetched (or failed). + models: string[] | undefined; + // openai / anthropic / cohere / azure-openai / google-palm / ai21 ... + provider: string; +}; diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index 712a8c8fc..5bbac16b3 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -50,41 +50,50 @@ const main = createSelector( } ); -const resolvedActiveProvider = createSelector( - activeProviderId, +const aiOnyxiaContext = createSelector( providers, - (activeProviderId, providers) => { - if (activeProviderId === undefined || providers === undefined) return undefined; - return providers.find(p => p.id === activeProviderId); - } -); - -const aiOnyxiaContext = createSelector(resolvedActiveProvider, provider => { - if (provider === undefined) return undefined; - if (provider.models?.stateDescription !== "loaded") return undefined; + activeProviderId, + (providers, activeProviderId) => { + const toProviderView = (provider: State.Provider) => { + // Region providers authenticate via an OIDC-exchanged token; until that + // exchange succeeds there is no usable key, so the provider isn't ready. + const apiKey = (() => { + if (provider.kind === "custom") return provider.apiKey; + if (provider.auth.stateDescription !== "authenticated") return undefined; + return provider.auth.token; + })(); - const apiKey = (() => { - if (provider.kind === "custom") return provider.apiKey; - if (provider.auth.stateDescription !== "authenticated") return undefined; - return provider.auth.token; - })(); + // A provider is only usable once authenticated. Its models may not be + // listed yet (fetch still pending or failed) — that's fine, `models` is + // left undefined in that case. + if (apiKey === undefined) return undefined; - if (apiKey === undefined) return undefined; + const models = + provider.models?.stateDescription === "loaded" + ? provider.models.availableModels.map(({ id }) => id) + : undefined; - const models = provider.models.availableModels.map(m => m.id); + return { + id: provider.id, + name: provider.kind === "region" ? provider.name : provider.label, + provider: provider.provider, + apiBase: provider.apiBase, + apiKey, + models, + selectedModel: provider.selectedModelId + }; + }; - const selectedModel = provider.selectedModelId; + const providerViews = (providers ?? []) + .map(toProviderView) + .filter(view => view !== undefined); - return { - enabled: true, - id: provider.id, - apiKey, - apiBase: provider.apiBase, - provider: provider.provider, - name: provider.kind === "region" ? provider.name : provider.label, - selectedModel, - models - }; -}); + return { + enabled: providerViews.length > 0, + activeProvider: providerViews.find(p => p.id === activeProviderId), + providers: providerViews.filter(p => p.id !== activeProviderId) + }; + } +); export const selectors = { main, aiOnyxiaContext }; diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index d9659c64a..80e15b158 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -1,4 +1,5 @@ import type { Thunks } from "core/bootstrap"; +import { createUsecaseContextApi } from "clean-architecture"; import { actions, name } from "./state"; import type { State } from "./state"; import { @@ -190,10 +191,7 @@ const privateThunks = { value: serializeAiConfig({ aiConfig }) }) ); - } -} satisfies Thunks; - -export const protectedThunks = { + }, initialize: () => async (...args) => { @@ -274,6 +272,10 @@ export const protectedThunks = { return; } + // Awaited so the whole AI context (providers + their model lists) is + // ready before `initialize` resolves. Bootstrap awaits this thunk, and + // `getCoreSync` suspends until bootstrap resolves, so the launcher's + // one-shot read of `aiOnyxiaContext` always sees the loaded models. await Promise.all([ ...regionEntries.map(async ({ provider, aiProvider, tokenResult }) => { if (tokenResult.status !== "success") return; @@ -301,6 +303,51 @@ export const protectedThunks = { } } satisfies Thunks; +const { getContext, setContext, getIsContextSet } = createUsecaseContextApi<{ + prInitialized: Promise; +}>(); + +export const protectedThunks = { + // Initiates the AI use-case. Dispatched once by bootstrap, *after* the region AI + // adapters have been wired into `context.ai`. Idempotent: a second dispatch + // returns the same in-flight promise. This is the ONLY place that starts the + // work — consumers must use `waitForInitialization`, never call this, so they + // can't lock the context before `context.ai` is populated. + initialize: + () => + (...args): Promise => { + const [dispatch, , rootContext] = args; + + if (getIsContextSet(rootContext)) { + return getContext(rootContext).prInitialized; + } + + const prInitialized = dispatch(privateThunks.initialize()); + + setContext(rootContext, { prInitialized }); + + return prInitialized; + }, + // Awaits the in-flight initialization if it has started, otherwise resolves + // immediately. Crucially it never triggers the init itself: callers like the + // launcher's `getXOnyxiaContext` can run very early (restorable-config + // autocomplete, before bootstrap has wired up the region adapters), and a + // premature init would build the providers from an empty `context.ai` and + // freeze that wrong state. Early callers simply see the AI context as + // not-yet-available; the real init happens later in bootstrap. + waitForInitialization: + () => + async (...args): Promise => { + const [, , rootContext] = args; + + if (!getIsContextSet(rootContext)) { + return; + } + + await getContext(rootContext).prInitialized; + } +} satisfies Thunks; + async function dispatchFetchedModels(params: { dispatch: ( action: diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index 821a08a55..6eb9e950c 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -587,6 +587,14 @@ export const protectedThunks = { { paramsOfBootstrapCore, secretsManager, onyxiaApi } ] = args; + // `aiOnyxiaContext` is read synchronously below as a one-shot snapshot, so + // wait for the AI use-case's in-flight initialization (providers + their + // model lists) to finish first. This only awaits an init already started + // by bootstrap; it never triggers one (which would otherwise run before + // the region AI adapters are wired up, when called early for restorable- + // config autocomplete). + await dispatch(aiUsecase.protectedThunks.waitForInitialization()); + const { user } = await onyxiaApi.getUserAndProjects(); const userConfigs = userConfigsUsecase.selectors.userConfigs(getState()); From 44161f52c9c085eea95843e260c2ad78d749f1ff Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:31:05 +0200 Subject: [PATCH 16/25] use overrideDefaultWith earlier --- .../decoupledLogic/computeHelmValues.test.ts | 67 ++++++++++++++- .../decoupledLogic/computeHelmValues.ts | 86 +++++++++---------- 2 files changed, 109 insertions(+), 44 deletions(-) diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts index dcf0621e6..d30123c09 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { computeHelmValues } from "./computeHelmValues"; +import { computeHelmValues, type JSONSchemaLike } from "./computeHelmValues"; import YAML from "yaml"; import { symToStr } from "tsafe/symToStr"; @@ -79,6 +79,71 @@ describe(symToStr({ computeHelmValues }), () => { expect(got).toStrictEqual(expected); }); + it("Use x-onyxia on object with properties", () => { + const activeProvider = { + id: "openai", + name: "OpenAI", + provider: "openai", + apiBase: "https://api.openai.com/v1", + apiKey: "sk-test", + selectedModel: "gpt-4.1", + models: ["gpt-4.1", "gpt-4.1-mini"] + }; + + const xOnyxiaContext = { + ai: { + activeProvider, + providers: [activeProvider] + }, + s3: undefined + }; + + const providerProperties: Record = { + id: { type: "string", default: "" }, + name: { type: "string", default: "" }, + provider: { type: "string", default: "" }, + apiBase: { type: "string", default: "" }, + apiKey: { type: "string", default: "" }, + selectedModel: { type: "string", default: "" }, + models: { type: "array", default: [], items: { type: "string" } } + }; + + const got = computeHelmValues({ + helmValuesSchema: { + type: "object", + properties: { + activeProvider: { + type: "object", + default: {}, + properties: providerProperties, + "x-onyxia": { + overwriteDefaultWith: "{{ai.activeProvider}}" + } + }, + providers: { + type: "array", + default: [], + items: { + type: "object", + properties: providerProperties + }, + "x-onyxia": { + overwriteDefaultWith: "{{ai.providers}}" + } + } + } + }, + helmValuesYaml: YAML.stringify({}), + xOnyxiaContext, + infoAmountInHelmValues: "user provided" + }); + + expect(got.helmValues).toStrictEqual({ + activeProvider, + providers: [activeProvider] + }); + }); + it("Use default", () => { const xOnyxiaContext = { a: { diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.ts b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.ts index 97ee5596f..f99786f8f 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.ts @@ -190,49 +190,6 @@ export function computeHelmValues_rec(params: { return constValue; } - schema_is_object_with_known_properties: { - if (helmValuesSchemaType !== "object") { - break schema_is_object_with_known_properties; - } - - const { properties } = helmValuesSchema; - - if (properties === undefined) { - break schema_is_object_with_known_properties; - } - - return Object.fromEntries( - Object.entries(properties).map(([propertyName, propertySchema]) => [ - propertyName, - computeHelmValues_rec({ - helmValuesSchema: propertySchema, - helmValuesYaml_parsed: - helmValuesYaml_parsed instanceof Object && - !(helmValuesYaml_parsed instanceof Array) - ? helmValuesYaml_parsed[propertyName] - : undefined, - xOnyxiaContext, - helmValuesSchema_forDataTextEditor: (() => { - if (helmValuesSchema_forDataTextEditor === undefined) { - return undefined; - } - - const { properties: property_forDataTextEditor } = - helmValuesSchema_forDataTextEditor; - - assert(property_forDataTextEditor !== undefined); - - const out = property_forDataTextEditor[propertyName]; - - assert(out !== undefined, "crash"); - - return out; - })() - }) - ]) - ); - } - use_x_onyxia_overwriteDefaultWith: { const { overwriteDefaultWith } = helmValuesSchema["x-onyxia"] ?? {}; @@ -344,6 +301,49 @@ export function computeHelmValues_rec(params: { return resolvedValue; } + schema_is_object_with_known_properties: { + if (helmValuesSchemaType !== "object") { + break schema_is_object_with_known_properties; + } + + const { properties } = helmValuesSchema; + + if (properties === undefined) { + break schema_is_object_with_known_properties; + } + + return Object.fromEntries( + Object.entries(properties).map(([propertyName, propertySchema]) => [ + propertyName, + computeHelmValues_rec({ + helmValuesSchema: propertySchema, + helmValuesYaml_parsed: + helmValuesYaml_parsed instanceof Object && + !(helmValuesYaml_parsed instanceof Array) + ? helmValuesYaml_parsed[propertyName] + : undefined, + xOnyxiaContext, + helmValuesSchema_forDataTextEditor: (() => { + if (helmValuesSchema_forDataTextEditor === undefined) { + return undefined; + } + + const { properties: property_forDataTextEditor } = + helmValuesSchema_forDataTextEditor; + + assert(property_forDataTextEditor !== undefined); + + const out = property_forDataTextEditor[propertyName]; + + assert(out !== undefined, "crash"); + + return out; + })() + }) + ]) + ); + } + use_default: { const defaultValue = helmValuesSchema.default; From 6a18276f7af883d854c89b4a1d2deb262b806c80 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:31:39 +0200 Subject: [PATCH 17/25] improve design --- web/src/core/ports/OnyxiaApi/XOnyxia.ts | 1 - .../decoupledLogic/persistedAiConfig.test.ts | 2 +- .../ai/decoupledLogic/persistedAiConfig.ts | 4 +- web/src/core/usecases/ai/selectors.ts | 13 +- web/src/core/usecases/ai/state.ts | 14 +- web/src/core/usecases/ai/thunks.ts | 37 +- web/src/ui/i18n/resources/de.tsx | 8 +- web/src/ui/i18n/resources/en.tsx | 8 +- web/src/ui/i18n/resources/es.tsx | 8 +- web/src/ui/i18n/resources/fi.tsx | 8 +- web/src/ui/i18n/resources/fr.tsx | 8 +- web/src/ui/i18n/resources/it.tsx | 13 +- web/src/ui/i18n/resources/nl.tsx | 8 +- web/src/ui/i18n/resources/no.tsx | 8 +- web/src/ui/i18n/resources/zh-CN.tsx | 8 +- web/src/ui/pages/account/AccountAiTab.tsx | 396 +++++++++++------- 16 files changed, 355 insertions(+), 189 deletions(-) diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index 96bc97d19..f1307c4cb 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -219,7 +219,6 @@ type AiProvider = { apiBase: string; name: string; selectedModel: string | undefined; - // undefined while the provider's model list is still being fetched (or failed). models: string[] | undefined; // openai / anthropic / cohere / azure-openai / google-palm / ai21 ... provider: string; diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts index b6330d5f7..9d2495f68 100644 --- a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts @@ -10,7 +10,7 @@ const sampleConfig: PersistedAiConfig = { customProviders: [ { id: "p1", - label: "My provider", + name: "My provider", provider: "openai", apiBase: "https://api.openai.com/v1", apiKey: "sk-secret" diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts index 193c59aea..a4299bfce 100644 --- a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts @@ -8,7 +8,7 @@ export type PersistedModelSelection = { export type PersistedCustomProvider = { id: string; - label: string; + name: string; // Optional for backward compatibility with configs persisted before the field // existed; defaulted to "openai" when read back. provider?: string; @@ -33,7 +33,7 @@ const zPersistedAiConfig: z.ZodType = z.object({ customProviders: z.array( z.object({ id: z.string(), - label: z.string(), + name: z.string(), provider: z.string().optional(), apiBase: z.string(), apiKey: z.string() diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index 5bbac16b3..80654dc0f 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -22,11 +22,10 @@ const main = createSelector( id: provider.id, provider: provider.provider, apiBase: provider.apiBase, - isActive: provider.id === activeProviderId, - // A provider can only be wired into services once its models are listed. - canBeActivated: provider.models?.stateDescription === "loaded", + isDefault: provider.id === activeProviderId, models: provider.models, - selectedModelId: provider.selectedModelId + selectedModelId: provider.selectedModelId, + name: provider.name }); return { @@ -35,7 +34,6 @@ const main = createSelector( .filter((p): p is State.Provider.Region => p.kind === "region") .map(p => ({ ...toCommonView(p), - name: p.name, webUiUrl: p.webUiUrl, auth: p.auth })), @@ -43,7 +41,6 @@ const main = createSelector( .filter((p): p is State.Provider.Custom => p.kind === "custom") .map(p => ({ ...toCommonView(p), - label: p.label, apiKey: p.apiKey })) }; @@ -55,8 +52,6 @@ const aiOnyxiaContext = createSelector( activeProviderId, (providers, activeProviderId) => { const toProviderView = (provider: State.Provider) => { - // Region providers authenticate via an OIDC-exchanged token; until that - // exchange succeeds there is no usable key, so the provider isn't ready. const apiKey = (() => { if (provider.kind === "custom") return provider.apiKey; if (provider.auth.stateDescription !== "authenticated") return undefined; @@ -75,7 +70,7 @@ const aiOnyxiaContext = createSelector( return { id: provider.id, - name: provider.kind === "region" ? provider.name : provider.label, + name: provider.name, provider: provider.provider, apiBase: provider.apiBase, apiKey, diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts index 5f671bda8..dd1688fde 100644 --- a/web/src/core/usecases/ai/state.ts +++ b/web/src/core/usecases/ai/state.ts @@ -24,6 +24,7 @@ export declare namespace State { export namespace Provider { export type Common = { id: string; + name: string; apiBase: string; /** * LLM provider family (e.g. "openai", "anthropic", "gemini"), injected as @@ -38,7 +39,6 @@ export declare namespace State { /** Provisioned by the deployment region, authenticated via the OIDC token. */ export type Region = Common & { kind: "region"; - name: string; webUiUrl: string; auth: | { stateDescription: "no account" } @@ -49,7 +49,6 @@ export declare namespace State { /** Added by the user, authenticated via a static API key. */ export type Custom = Common & { kind: "custom"; - label: string; apiKey: string; }; } @@ -142,6 +141,7 @@ export const { reducer, actions } = createUsecaseActions({ ) => { assert(state.stateDescription === "initialized"); state.providers.push(payload.provider); + state.activeProviderId ??= payload.provider.id; }, editCustomProvider: ( state, @@ -150,7 +150,7 @@ export const { reducer, actions } = createUsecaseActions({ }: { payload: { providerId: string; - label: string; + name: string; provider: string; apiBase: string; apiKey: string; @@ -162,7 +162,7 @@ export const { reducer, actions } = createUsecaseActions({ // Editing an existing custom provider from its dialog: it must exist. assert(provider !== undefined); assert(provider.kind === "custom"); - provider.label = payload.label; + provider.name = payload.name; provider.provider = payload.provider; provider.apiBase = payload.apiBase; provider.apiKey = payload.apiKey; @@ -178,7 +178,11 @@ export const { reducer, actions } = createUsecaseActions({ state.providers = state.providers.filter(p => p.id !== payload.providerId); if (state.activeProviderId === payload.providerId) { - state.activeProviderId = undefined; + state.activeProviderId = state.providers.find( + provider => + provider.kind === "custom" || + provider.auth.stateDescription === "authenticated" + )?.id; } } } diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index 80e15b158..2267e89f7 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -66,7 +66,7 @@ export const thunks = { ); }, setActiveProvider: - (params: { activeProviderId: string | undefined }) => + (params: { activeProviderId: string }) => async (...args) => { const { activeProviderId } = params; const [dispatch] = args; @@ -95,9 +95,9 @@ export const thunks = { // The add/edit form (values, validation, connection-test result, open state) is // owned by the UI. The core only exposes the resulting operations on the state. addCustomProvider: - (params: { label: string; provider: string; apiBase: string; apiKey: string }) => + (params: { name: string; provider: string; apiBase: string; apiKey: string }) => async (...args) => { - const { label, provider, apiBase, apiKey } = params; + const { name, provider, apiBase, apiKey } = params; const [dispatch] = args; const providerId = crypto.randomUUID(); @@ -107,7 +107,7 @@ export const thunks = { provider: { kind: "custom", id: providerId, - label, + name, provider, apiBase, apiKey, @@ -123,19 +123,19 @@ export const thunks = { editCustomProvider: (params: { providerId: string; - label: string; + name: string; provider: string; apiBase: string; apiKey: string; }) => async (...args) => { - const { providerId, label, provider, apiBase, apiKey } = params; + const { providerId, name, provider, apiBase, apiKey } = params; const [dispatch] = args; dispatch( actions.editCustomProvider({ providerId, - label, + name: name, provider, apiBase, apiKey @@ -169,9 +169,9 @@ const privateThunks = { const aiConfig: PersistedAiConfig = { customProviders: state.providers .filter((p): p is State.Provider.Custom => p.kind === "custom") - .map(({ id, label, provider, apiBase, apiKey }) => ({ + .map(({ id, name, provider, apiBase, apiKey }) => ({ id, - label, + name, provider, apiBase, apiKey @@ -239,7 +239,7 @@ const privateThunks = { customProviders = (persisted?.customProviders ?? []).map(p => ({ kind: "custom", id: p.id, - label: p.label, + name: p.name, // Configs persisted before the field existed default to "openai". provider: p.provider ?? "openai", apiBase: p.apiBase, @@ -249,21 +249,26 @@ const privateThunks = { })); const providers = [...regionProviders, ...customProviders]; + const defaultableProviderIds = [ + ...regionEntries + .filter(({ tokenResult }) => tokenResult.status === "success") + .map(({ provider }) => provider.id), + ...customProviders.map(provider => provider.id) + ]; const activeProviderId = ((): string | undefined => { - // Never saved a preference → default to the first region provider. + // Never saved a preference → default to the first usable provider. if (persisted === null) { - return regionProviders[0]?.id; + return defaultableProviderIds[0]; } const stored = persisted.activeProviderId ?? undefined; - // Stored selection points at a provider that no longer exists. - if (stored !== undefined && !providers.some(p => p.id === stored)) { - return undefined; + if (stored !== undefined && defaultableProviderIds.includes(stored)) { + return stored; } - return stored; + return defaultableProviderIds[0]; })(); dispatch(actions.initialized({ providers, activeProviderId })); diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 02f456bd9..ba327dffd 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -112,7 +112,13 @@ export const translations: Translations<"de"> = { token: "Token", "model label": "Modell", "gateway error": "Das KI-Gateway konnte nicht initialisiert werden.", - "use in services": "In Ihren Diensten verwenden", + "default provider": "Standardanbieter", + "set default provider": "Als Standard festlegen", + "refresh credentials": "Anmeldedaten aktualisieren", + "delete provider": "Löschen", + "edit provider": "Bearbeiten", + copy: "Kopieren", + "not defined": "Nicht definiert", "custom providers section title": "Benutzerdefinierte KI-Anbieter", "custom providers section helper": "Fügen Sie Ihre eigenen OpenAI-kompatiblen KI-Anbieter hinzu. Die Anmeldedaten werden in Ihrem Browser gespeichert.", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index d76bdb0b5..bb2190090 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -110,7 +110,13 @@ export const translations: Translations<"en"> = { token: "Token", "model label": "Model", "gateway error": "Unable to initialize the AI gateway.", - "use in services": "Use in your services", + "default provider": "Default provider", + "set default provider": "Set default provider", + "refresh credentials": "Refresh credentials", + "delete provider": "Delete", + "edit provider": "Edit", + copy: "Copy", + "not defined": "Not defined", "custom providers section title": "Custom AI providers", "custom providers section helper": "Add your own OpenAI-compatible AI providers. Credentials are stored in your browser.", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index aee4b6119..6904220fa 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -111,7 +111,13 @@ export const translations: Translations<"es"> = { token: "Token", "model label": "Modelo", "gateway error": "No se pudo inicializar la pasarela de IA.", - "use in services": "Usar en sus servicios", + "default provider": "Proveedor predeterminado", + "set default provider": "Definir como predeterminado", + "refresh credentials": "Actualizar credenciales", + "delete provider": "Eliminar", + "edit provider": "Editar", + copy: "Copiar", + "not defined": "No definido", "custom providers section title": "Proveedores de IA personalizados", "custom providers section helper": "Añade tus propios proveedores de IA compatibles con OpenAI. Las credenciales se almacenan en tu navegador.", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 4a31f8d02..71291c3b8 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -111,7 +111,13 @@ export const translations: Translations<"fi"> = { token: "Token", "model label": "Malli", "gateway error": "Tekoäly-yhdyskäytävän alustus epäonnistui.", - "use in services": "Käytä palveluissasi", + "default provider": "Oletustarjoaja", + "set default provider": "Aseta oletukseksi", + "refresh credentials": "Päivitä tunnistetiedot", + "delete provider": "Poista", + "edit provider": "Muokkaa", + copy: "Kopioi", + "not defined": "Ei määritetty", "custom providers section title": "Mukautetut tekoälyntarjoajat", "custom providers section helper": "Lisää omia OpenAI-yhteensopivia tekoälypalveluntarjoajia. Tunnukset tallennetaan selaimeesi.", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 69c17b731..91e43e86c 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -112,7 +112,13 @@ export const translations: Translations<"fr"> = { token: "Jeton", "model label": "Modèles", "gateway error": "Impossible d'initialiser la passerelle IA.", - "use in services": "Utiliser dans vos services", + "default provider": "Provider par défaut", + "set default provider": "Définir par défaut", + "refresh credentials": "Rafraîchir les identifiants", + "delete provider": "Supprimer", + "edit provider": "Modifier", + copy: "Copier", + "not defined": "Non défini", "custom providers section title": "Providers IA personnalisés", "custom providers section helper": "Ajoutez vos propres providers IA compatibles OpenAI. Les identifiants sont stockés dans votre navigateur.", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 30ec8c301..d2f12f442 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -110,7 +110,13 @@ export const translations: Translations<"it"> = { token: "Token", "model label": "Modello", "gateway error": "Impossibile inizializzare il gateway IA.", - "use in services": "Usa nei tuoi servizi", + "default provider": "Provider predefinito", + "set default provider": "Imposta come predefinito", + "refresh credentials": "Aggiorna credenziali", + "delete provider": "Elimina", + "edit provider": "Modifica", + copy: "Copia", + "not defined": "Non definito", "custom providers section title": "Provider IA personalizzati", "custom providers section helper": "Aggiungi i tuoi provider IA compatibili con OpenAI. Le credenziali sono memorizzate nel tuo browser.", @@ -553,8 +559,9 @@ export const translations: Translations<"it"> = { la nostra documentazione .   - Configurare il tuo Vault CLI locale - . + + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 3fd6d1d5a..033121802 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -111,7 +111,13 @@ export const translations: Translations<"nl"> = { token: "Token", "model label": "Model", "gateway error": "Kan de AI-gateway niet initialiseren.", - "use in services": "Gebruiken in uw services", + "default provider": "Standaardprovider", + "set default provider": "Als standaard instellen", + "refresh credentials": "Referenties vernieuwen", + "delete provider": "Verwijderen", + "edit provider": "Bewerken", + copy: "Kopiëren", + "not defined": "Niet gedefinieerd", "custom providers section title": "Aangepaste AI-providers", "custom providers section helper": "Voeg uw eigen OpenAI-compatibele AI-providers toe. Inloggegevens worden in uw browser opgeslagen.", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 58e816ff5..0f0e2fa3a 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -111,7 +111,13 @@ export const translations: Translations<"no"> = { token: "Token", "model label": "Modell", "gateway error": "Kunne ikke initialisere AI-gatewayen.", - "use in services": "Bruk i tjenestene dine", + "default provider": "Standardleverandør", + "set default provider": "Angi som standard", + "refresh credentials": "Oppdater legitimasjon", + "delete provider": "Slett", + "edit provider": "Rediger", + copy: "Kopier", + "not defined": "Ikke definert", "custom providers section title": "Tilpassede AI-leverandører", "custom providers section helper": "Legg til dine egne OpenAI-kompatible AI-leverandører. Påloggingsinformasjonen lagres i nettleseren din.", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 1cc2f11fb..50a3ed2cc 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -102,7 +102,13 @@ export const translations: Translations<"zh-CN"> = { token: "令牌", "model label": "模型", "gateway error": "无法初始化 AI 网关。", - "use in services": "在服务中使用", + "default provider": "默认提供商", + "set default provider": "设为默认提供商", + "refresh credentials": "刷新凭据", + "delete provider": "删除", + "edit provider": "编辑", + copy: "复制", + "not defined": "未定义", "custom providers section title": "自定义 AI 提供商", "custom providers section helper": "添加您自己的兼容 OpenAI 的 AI 提供商。凭据存储在您的浏览器中。", diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx index 4dae63d97..89060f32c 100644 --- a/web/src/ui/pages/account/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -1,24 +1,21 @@ import { memo, useState } from "react"; import { useTranslation } from "ui/i18n"; import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; -import { SettingField } from "ui/shared/SettingField"; import { useCallbackFactory } from "powerhooks/useCallbackFactory"; import { copyToClipboard } from "ui/tools/copyToClipboard"; import { tss } from "tss"; import { declareComponentKeys } from "i18nifty"; import { useConstCallback } from "powerhooks/useConstCallback"; import { IconButton } from "onyxia-ui/IconButton"; +import { Icon } from "onyxia-ui/Icon"; import { CircularProgress } from "onyxia-ui/CircularProgress"; import { Dialog } from "onyxia-ui/Dialog"; import { Button } from "onyxia-ui/Button"; import { useCoreState, getCoreSync } from "core"; -import { smartTrim } from "ui/tools/smartTrim"; import { getIconUrlByName } from "lazy-icons"; import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; -import Switch from "@mui/material/Switch"; -import FormControlLabel from "@mui/material/FormControlLabel"; import { Text } from "onyxia-ui/Text"; export type Props = { @@ -34,7 +31,7 @@ type Models = | undefined; type FormValues = { - label: string; + name: string; provider: string; apiBase: string; apiKey: string; @@ -70,11 +67,10 @@ const AccountAiGatewayTab = memo((props: Props) => { ai.refreshToken({ providerId }) ); - const onToggleProviderFactory = useCallbackFactory( - ([providerId]: [string], [, checked]: [unknown, boolean]) => - ai.setActiveProvider({ - activeProviderId: checked ? providerId : undefined - }) + const onSetDefaultProviderFactory = useCallbackFactory(([providerId]: [string]) => + ai.setActiveProvider({ + activeProviderId: providerId + }) ); const onDeleteCustomProviderFactory = useCallbackFactory(([providerId]: [string]) => @@ -89,7 +85,7 @@ const AccountAiGatewayTab = memo((props: Props) => { undefined ); const [values, setValues] = useState({ - label: "", + name: "", provider: "openai", apiBase: "", apiKey: "" @@ -98,7 +94,7 @@ const AccountAiGatewayTab = memo((props: Props) => { const isEditing = editedProviderId !== undefined; const canSave = - values.label !== "" && + values.name !== "" && values.provider !== "" && values.apiBase !== "" && values.apiKey !== ""; @@ -109,7 +105,7 @@ const AccountAiGatewayTab = memo((props: Props) => { const onAddClick = useConstCallback(() => { setEditedProviderId(undefined); - setValues({ label: "", provider: "openai", apiBase: "", apiKey: "" }); + setValues({ name: "", provider: "openai", apiBase: "", apiKey: "" }); setTest({ stateDescription: "idle" }); setIsFormOpen(true); }); @@ -119,7 +115,7 @@ const AccountAiGatewayTab = memo((props: Props) => { if (provider === undefined) return; setEditedProviderId(providerId); setValues({ - label: provider.label, + name: provider.name, provider: provider.provider, apiBase: provider.apiBase, apiKey: provider.apiKey @@ -136,7 +132,7 @@ const AccountAiGatewayTab = memo((props: Props) => { setValues(values => ({ ...values, [key]: value })); // Only credential changes invalidate a previous connection-test result; // the display label and provider type don't affect connectivity. - if (key !== "label" && key !== "provider") { + if (key !== "name" && key !== "provider") { setTest({ stateDescription: "idle" }); } } @@ -174,23 +170,55 @@ const AccountAiGatewayTab = memo((props: Props) => { ); } + const renderDefaultProviderAction = (params: { + providerId: string; + isDefault: boolean; + }) => + params.isDefault ? ( +
+ + + {t("default provider")} + +
+ ) : ( + + ); + return (
{regionProviders.map(regionProvider => (
{regionProvider.name} - - } - label={{t("use in services")}} - /> +
+ {regionProvider.auth.stateDescription === "authenticated" && ( + <> + + {renderDefaultProviderAction({ + providerId: regionProvider.id, + isDefault: regionProvider.isDefault + })} + + )} +
{regionProvider.auth.stateDescription === "no account" && ( @@ -209,53 +237,32 @@ const AccountAiGatewayTab = memo((props: Props) => { <> - {t("credentials section helper", { - webUiUrl: regionProvider.webUiUrl - })} -   - - - } - /> - - - +
+ + + +
)}
@@ -276,58 +283,48 @@ const AccountAiGatewayTab = memo((props: Props) => { {customProviders.map(provider => (
- {provider.label} + {provider.name}
- - } - label={{t("use in services")}} - /> - - + className={classes.compactActionButton} + > + {t("delete provider")} + + + {renderDefaultProviderAction({ + providerId: provider.id, + isDefault: provider.isDefault + })}
- - - +
+ + + +
))} @@ -343,8 +340,8 @@ const AccountAiGatewayTab = memo((props: Props) => {
@@ -412,6 +409,49 @@ const AccountAiGatewayTab = memo((props: Props) => { ); }); +type ProviderValueFieldProps = { + label: string; + value: string; + onRequestCopy: () => void; + isSensitiveInformation?: boolean; +}; + +const ProviderValueField = memo((props: ProviderValueFieldProps) => { + const { label, value, onRequestCopy, isSensitiveInformation = false } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ AccountAiGatewayTab }); + const [isHidden, setIsHidden] = useState(isSensitiveInformation); + + const onToggleHidden = useConstCallback(() => setIsHidden(isHidden => !isHidden)); + + return ( +
+ {label} +
+ + {isHidden ? "•".repeat(Math.max(value.length, 30)) : value} + + {isSensitiveInformation && ( + + )} + +
+
+ ); +}); + type ModelsSectionProps = { providerId: string; models: Models; @@ -467,14 +507,22 @@ const ModelSelectRow = memo((props: ModelSelectRowProps) => { const { label, models, selectedModel, onChange } = props; const { classes } = useStyles(); + const { t } = useTranslation({ AccountAiGatewayTab }); return ( -
-
- {label} -
-
- + + {t("not defined")} + {models.map(({ id, name }) => ( {name} @@ -489,7 +537,13 @@ const ModelSelectRow = memo((props: ModelSelectRowProps) => { export default AccountAiGatewayTab; const { i18n } = declareComponentKeys< - | "use in services" + | "default provider" + | "set default provider" + | "refresh credentials" + | "delete provider" + | "edit provider" + | "copy" + | "not defined" | "credentials section title" | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } | "api base url" @@ -515,21 +569,6 @@ const { i18n } = declareComponentKeys< export type I18n = typeof i18n; const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ - modelRow: { - display: "flex", - alignItems: "center", - marginBottom: theme.spacing(3) - }, - modelRowTitle: { - width: 360, - display: "flex", - alignItems: "center" - }, - modelRowControl: { - flex: 1, - display: "flex", - alignItems: "center" - }, customProvidersSectionHeader: { display: "flex", alignItems: "flex-start", @@ -551,7 +590,76 @@ const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ providerCardActions: { display: "flex", alignItems: "center", - gap: theme.spacing(2) + gap: theme.spacing(1), + flexWrap: "wrap", + justifyContent: "flex-end" + }, + compactActionButton: { + minHeight: 28, + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5) + }, + defaultProviderBadge: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(1), + minHeight: 28, + borderRadius: 9999, + backgroundColor: theme.colors.useCases.alertSeverity.success.background + }, + defaultProviderBadgeIcon: { + color: theme.colors.useCases.alertSeverity.success.main + }, + defaultProviderBadgeText: { + color: theme.colors.useCases.alertSeverity.success.main + }, + providerFields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3) + }, + providerField: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + codeFrame: { + minHeight: 45, + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, + borderRadius: theme.spacing(1), + backgroundColor: theme.colors.useCases.surfaces.surface2, + minWidth: 0 + }, + codeFrameValue: { + flex: 1, + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontFamily: "monospace" + }, + codeFrameButton: { + minHeight: 28, + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + flexShrink: 0 + }, + modelSelect: { + flex: 1, + minWidth: 0, + "& .MuiOutlinedInput-notchedOutline": { + border: "none" + }, + "& .MuiSelect-select": { + paddingLeft: 0 + } }, errorText: { color: theme.colors.useCases.alertSeverity.error.main From 4bd1b13286e522208dc2e6fd4c24b6b5ff3716ce Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:18 +0200 Subject: [PATCH 18/25] overwriteListEnumWith relative path in array items --- .../decoupledLogic/computeHelmValues.test.ts | 87 ++++++++++++++++++ .../computeRootFormFieldGroup.test.ts | 90 +++++++++++++++++++ .../computeRootFormFieldGroup.ts | 24 ++++- 3 files changed, 199 insertions(+), 2 deletions(-) diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts index d30123c09..0ad8059f5 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts @@ -1076,4 +1076,91 @@ describe(symToStr({ computeHelmValues }), () => { expect(got).toStrictEqual(expected); }); + + it("array mapping with overwriteListEnumWith", () => { + const xOnyxiaContext = { + s3: {}, + a: { + b: [ + { p: "foo", q_x: "xxx_1", q_options: ["xxx_1", "yyy_1"] }, + { p: "bar", q_x: "xxx_2", q_options: ["xxx_2", "yyy_2"] }, + { p: "baz", q_x: "xxx_3", q_options: ["xxx_3", "yyy_3"] } + ] + } + }; + + const got = computeHelmValues({ + helmValuesSchema: { + type: "object", + properties: { + r: { + type: "array", + "x-onyxia": { + overwriteDefaultWith: "{{a.b}}" + }, + items: { + type: "object", + properties: { + p: { + type: "string" + }, + q: { + type: "string", + listEnum: [], + "x-onyxia": { + overwriteDefaultWith: "{{q_x}}", + overwriteListEnumWith: "{{q_options}}" + } + } + } + } + } + } + }, + helmValuesYaml: YAML.stringify({}), + xOnyxiaContext, + infoAmountInHelmValues: "user provided" + }); + + const expected = { + helmValues: { + r: [ + { p: "foo", q: "xxx_1" }, + { p: "bar", q: "xxx_2" }, + { p: "baz", q: "xxx_3" } + ] + }, + helmValuesSchema_forDataTextEditor: { + type: "object", + properties: { + r: { + type: "array", + default: [ + { p: "foo", q: "xxx_1" }, + { p: "bar", q: "xxx_2" }, + { p: "baz", q: "xxx_3" } + ], + items: { + type: "object", + properties: { + p: { + type: "string" + }, + q: { + type: "string" + } + }, + required: ["p", "q"], + additionalProperties: false + } + } + }, + required: ["r"], + additionalProperties: false + }, + isChartUsingS3: false + }; + + expect(got).toStrictEqual(expected); + }); }); diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts index 02a0fd014..1e9b6980b 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { computeRootFormFieldGroup } from "./computeRootFormFieldGroup"; import { symToStr } from "tsafe/symToStr"; +import { assert } from "tsafe/assert"; import type { FormFieldGroup } from "../formTypes"; describe(symToStr({ computeRootFormFieldGroup }), () => { @@ -396,6 +397,95 @@ describe(symToStr({ computeRootFormFieldGroup }), () => { expect(got).toStrictEqual(expected); }); + it("overwriteListEnumWith with relative path in array items", () => { + // Same item-scoped resolution as overwriteDefaultWith (issue #992): + // {{models}} should resolve against the current array item. + const xOnyxiaContext = { + ai: { + providers: [ + { + name: "provider1", + selectedModel: "model-a", + models: ["model-a", "model-b"] + } + ] + } + }; + + const got = computeRootFormFieldGroup({ + helmValuesSchema: { + type: "object", + properties: { + providers: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string" + }, + selectedModel: { + type: "string", + "x-onyxia": { + overwriteListEnumWith: "{{models}}" + } + }, + models: { + type: "array", + items: { type: "string" } + } + } + } + } + } + }, + // What computeHelmValues produces for this schema and context + helmValues: { + providers: [ + { + name: "provider1", + selectedModel: "model-a", + models: ["model-a", "model-b"] + } + ] + }, + xOnyxiaContext, + autoInjectionDisabledFields: undefined, + autocompleteOptions: [] + }); + + const selectedModelField = (() => { + const providersGroup = got.nodes.find( + node => node.type === "group" && node.title === "providers" + ); + + assert(providersGroup !== undefined && providersGroup.type === "group"); + + const [itemGroup] = providersGroup.nodes; + + assert(itemGroup !== undefined && itemGroup.type === "group"); + + const field = itemGroup.nodes.find( + node => node.type === "field" && node.title === "selectedModel" + ); + + assert(field !== undefined && field.type === "field"); + + return field; + })(); + + expect(selectedModelField).toStrictEqual({ + type: "field", + description: undefined, + title: "selectedModel", + isReadonly: false, + fieldType: "select", + helmValuesPath: ["providers", 0, "selectedModel"], + options: ["model-a", "model-b"], + selectedOptionIndex: 0 + }); + }); + it("with autocomplete options", () => { const xOnyxiaContext = { r: [1, 2, 3] diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.ts b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.ts index cac0fa9fa..660833366 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.ts @@ -457,12 +457,32 @@ function computeRootFormFieldGroup_rec(params: { assert(values instanceof Array); const nodes = values - .map((...[, index]) => { + .map((value_i, index) => { const helmValuesPath_child = [...helmValuesPath, index]; + // Same item scoping as in computeHelmValues_rec (array_mapping): + // let x-onyxia relative expressions resolve against the + // current array item before falling back to the global context. + const xOnyxiaContext_child = (() => { + if (!(value_i instanceof Object) || value_i instanceof Array) { + return xOnyxiaContext; + } + + return new Proxy(xOnyxiaContext, { + get(...args) { + const [, prop] = args; + + if (typeof prop === "string" && prop in value_i) { + return value_i[prop]; + } + return Reflect.get(...args); + } + }); + })(); + return computeRootFormFieldGroup_rec({ helmValues, - xOnyxiaContext, + xOnyxiaContext: xOnyxiaContext_child, helmValuesSchema: itemSchema, autoInjectionDisabledFields, helmValuesPath: helmValuesPath_child, From 4be60a9104a9b96455dd75bf4beb4708fccaa8f7 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:30:32 +0200 Subject: [PATCH 19/25] improve test --- .../computeRootFormFieldGroup.test.ts | 107 ++++++++---------- 1 file changed, 50 insertions(+), 57 deletions(-) diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts index 1e9b6980b..03abbcc70 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { computeRootFormFieldGroup } from "./computeRootFormFieldGroup"; import { symToStr } from "tsafe/symToStr"; -import { assert } from "tsafe/assert"; import type { FormFieldGroup } from "../formTypes"; describe(symToStr({ computeRootFormFieldGroup }), () => { @@ -398,20 +397,6 @@ describe(symToStr({ computeRootFormFieldGroup }), () => { }); it("overwriteListEnumWith with relative path in array items", () => { - // Same item-scoped resolution as overwriteDefaultWith (issue #992): - // {{models}} should resolve against the current array item. - const xOnyxiaContext = { - ai: { - providers: [ - { - name: "provider1", - selectedModel: "model-a", - models: ["model-a", "model-b"] - } - ] - } - }; - const got = computeRootFormFieldGroup({ helmValuesSchema: { type: "object", @@ -421,9 +406,6 @@ describe(symToStr({ computeRootFormFieldGroup }), () => { items: { type: "object", properties: { - name: { - type: "string" - }, selectedModel: { type: "string", "x-onyxia": { @@ -432,58 +414,69 @@ describe(symToStr({ computeRootFormFieldGroup }), () => { }, models: { type: "array", - items: { type: "string" } + items: { type: "string" }, + "x-onyxia": { + hidden: true + } } } } } } }, - // What computeHelmValues produces for this schema and context helmValues: { - providers: [ - { - name: "provider1", - selectedModel: "model-a", - models: ["model-a", "model-b"] - } - ] + providers: [{ selectedModel: "model-b" }] }, - xOnyxiaContext, + xOnyxiaContext: { models: ["model-a", "model-b"] }, autoInjectionDisabledFields: undefined, autocompleteOptions: [] }); - const selectedModelField = (() => { - const providersGroup = got.nodes.find( - node => node.type === "group" && node.title === "providers" - ); - - assert(providersGroup !== undefined && providersGroup.type === "group"); - - const [itemGroup] = providersGroup.nodes; - - assert(itemGroup !== undefined && itemGroup.type === "group"); - - const field = itemGroup.nodes.find( - node => node.type === "field" && node.title === "selectedModel" - ); - - assert(field !== undefined && field.type === "field"); - - return field; - })(); - - expect(selectedModelField).toStrictEqual({ - type: "field", + const expected: FormFieldGroup = { + type: "group", + helmValuesPath: [], + title: "", description: undefined, - title: "selectedModel", - isReadonly: false, - fieldType: "select", - helmValuesPath: ["providers", 0, "selectedModel"], - options: ["model-a", "model-b"], - selectedOptionIndex: 0 - }); + nodes: [ + { + type: "group", + helmValuesPath: ["providers"], + title: "providers", + description: undefined, + nodes: [ + { + type: "group", + helmValuesPath: ["providers", 0], + title: "providers 1", + description: undefined, + nodes: [ + { + type: "field", + title: "selectedModel", + isReadonly: false, + fieldType: "select", + helmValuesPath: ["providers", 0, "selectedModel"], + description: undefined, + options: ["model-a", "model-b"], + selectedOptionIndex: 1 + } + ], + canAdd: false, + canRemove: false, + isAutoInjected: undefined + } + ], + canAdd: true, + canRemove: true, + isAutoInjected: undefined + } + ], + canAdd: false, + canRemove: false, + isAutoInjected: undefined + }; + + expect(got).toStrictEqual(expected); }); it("with autocomplete options", () => { From 089186328b83af289d96bec29a9d8d8c07d42d0b Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:13:07 +0200 Subject: [PATCH 20/25] split AccountAiTab in multiples files --- web/src/ui/i18n/resources/de.tsx | 39 +- web/src/ui/i18n/resources/en.tsx | 35 +- web/src/ui/i18n/resources/es.tsx | 37 +- web/src/ui/i18n/resources/fi.tsx | 35 +- web/src/ui/i18n/resources/fr.tsx | 39 +- web/src/ui/i18n/resources/it.tsx | 39 +- web/src/ui/i18n/resources/nl.tsx | 39 +- web/src/ui/i18n/resources/no.tsx | 35 +- web/src/ui/i18n/resources/zh-CN.tsx | 35 +- web/src/ui/i18n/types.ts | 5 +- web/src/ui/pages/account/AccountAiTab.tsx | 680 ------------------ .../account/AccountAiTab/AccountAiTab.tsx | 337 +++++++++ .../AccountAiTab/CustomProviderFormDialog.tsx | 249 +++++++ .../account/AccountAiTab/ModelsSection.tsx | 115 +++ .../AccountAiTab/ProviderValueField.tsx | 87 +++ .../ui/pages/account/AccountAiTab/index.ts | 3 + 16 files changed, 1002 insertions(+), 807 deletions(-) delete mode 100644 web/src/ui/pages/account/AccountAiTab.tsx create mode 100644 web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx create mode 100644 web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx create mode 100644 web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx create mode 100644 web/src/ui/pages/account/AccountAiTab/ProviderValueField.tsx create mode 100644 web/src/ui/pages/account/AccountAiTab/index.ts diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index ba327dffd..eea9a1d5b 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -110,32 +110,17 @@ export const translations: Translations<"de"> = { ), "api base url": "API-Basis-URL", token: "Token", - "model label": "Modell", "gateway error": "Das KI-Gateway konnte nicht initialisiert werden.", "default provider": "Standardanbieter", "set default provider": "Als Standard festlegen", "refresh credentials": "Anmeldedaten aktualisieren", "delete provider": "Löschen", "edit provider": "Bearbeiten", - copy: "Kopieren", - "not defined": "Nicht definiert", "custom providers section title": "Benutzerdefinierte KI-Anbieter", "custom providers section helper": "Fügen Sie Ihre eigenen OpenAI-kompatiblen KI-Anbieter hinzu. Die Anmeldedaten werden in Ihrem Browser gespeichert.", - "edit custom provider title": "KI-Anbieter bearbeiten", - "custom provider label field": "Name", - "custom provider type field": "Provider-Typ", "custom provider api base field": "API-Basis-URL", "custom provider api key field": "API-Schlüssel", - "provider test": "Verbindung testen", - "provider test success": "Verbindung erfolgreich", - "provider test error": - "Verbindung fehlgeschlagen — URL und API-Schlüssel prüfen.", - "provider save": "Hinzufügen", - "provider update": "Speichern", - "provider cancel": "Abbrechen", - "models fetch error": - "Modelle konnten nicht abgerufen werden — überprüfen Sie URL und API-Schlüssel.", "no account": ({ webUiUrl }) => ( <> Sie haben noch kein Konto beim KI-Gateway. Bitte melden Sie sich zuerst an @@ -147,6 +132,30 @@ export const translations: Translations<"de"> = { ) }, + ProviderValueField: { + copy: "Kopieren" + }, + ModelsSection: { + "model label": "Modell", + "not defined": "Nicht definiert", + "models fetch error": + "Modelle konnten nicht abgerufen werden — überprüfen Sie URL und API-Schlüssel." + }, + CustomProviderFormDialog: { + "add custom provider title": "Benutzerdefinierte KI-Anbieter", + "edit custom provider title": "KI-Anbieter bearbeiten", + "custom provider label field": "Name", + "custom provider type field": "Provider-Typ", + "custom provider api base field": "API-Basis-URL", + "custom provider api key field": "API-Schlüssel", + "provider test": "Verbindung testen", + "provider test success": "Verbindung erfolgreich", + "provider test error": + "Verbindung fehlgeschlagen — URL und API-Schlüssel prüfen.", + "provider save": "Hinzufügen", + "provider update": "Speichern", + "provider cancel": "Abbrechen" + }, AccountVaultTab: { "credentials section title": "Vault-Anmeldeinformationen", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index bb2190090..7a4f35993 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -108,30 +108,17 @@ export const translations: Translations<"en"> = { ), "api base url": "API base URL", token: "Token", - "model label": "Model", "gateway error": "Unable to initialize the AI gateway.", "default provider": "Default provider", "set default provider": "Set default provider", "refresh credentials": "Refresh credentials", "delete provider": "Delete", "edit provider": "Edit", - copy: "Copy", - "not defined": "Not defined", "custom providers section title": "Custom AI providers", "custom providers section helper": "Add your own OpenAI-compatible AI providers. Credentials are stored in your browser.", - "edit custom provider title": "Edit AI provider", - "custom provider label field": "Label", - "custom provider type field": "Provider type", "custom provider api base field": "API base URL", "custom provider api key field": "API key", - "provider test": "Test connection", - "provider test success": "Connection successful", - "provider test error": "Unable to connect — check URL and API key.", - "provider save": "Add", - "provider update": "Save changes", - "provider cancel": "Cancel", - "models fetch error": "Unable to fetch models — check your URL and API key.", "no account": ({ webUiUrl }) => ( <> You don't have an AI gateway account yet. Please log in to{" "} @@ -142,6 +129,28 @@ export const translations: Translations<"en"> = { ) }, + ProviderValueField: { + copy: "Copy" + }, + ModelsSection: { + "model label": "Model", + "not defined": "Not defined", + "models fetch error": "Unable to fetch models — check your URL and API key." + }, + CustomProviderFormDialog: { + "add custom provider title": "Custom AI providers", + "edit custom provider title": "Edit AI provider", + "custom provider label field": "Label", + "custom provider type field": "Provider type", + "custom provider api base field": "API base URL", + "custom provider api key field": "API key", + "provider test": "Test connection", + "provider test success": "Connection successful", + "provider test error": "Unable to connect — check URL and API key.", + "provider save": "Add", + "provider update": "Save changes", + "provider cancel": "Cancel" + }, AccountVaultTab: { "credentials section title": "Vault credentials", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 6904220fa..a60d557ac 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -109,31 +109,17 @@ export const translations: Translations<"es"> = { ), "api base url": "URL base de la API", token: "Token", - "model label": "Modelo", "gateway error": "No se pudo inicializar la pasarela de IA.", "default provider": "Proveedor predeterminado", "set default provider": "Definir como predeterminado", "refresh credentials": "Actualizar credenciales", "delete provider": "Eliminar", "edit provider": "Editar", - copy: "Copiar", - "not defined": "No definido", "custom providers section title": "Proveedores de IA personalizados", "custom providers section helper": "Añade tus propios proveedores de IA compatibles con OpenAI. Las credenciales se almacenan en tu navegador.", - "edit custom provider title": "Editar proveedor de IA", - "custom provider label field": "Etiqueta", - "custom provider type field": "Tipo de proveedor", "custom provider api base field": "URL base de la API", "custom provider api key field": "Clave API", - "provider test": "Probar conexión", - "provider test success": "Conexión exitosa", - "provider test error": "No se puede conectar — compruebe la URL y la clave API.", - "provider save": "Añadir", - "provider update": "Guardar", - "provider cancel": "Cancelar", - "models fetch error": - "No se pueden obtener los modelos — compruebe la URL y la clave API.", "no account": ({ webUiUrl }) => ( <> Aún no tiene una cuenta en la pasarela de IA. Por favor, inicie sesión @@ -145,6 +131,29 @@ export const translations: Translations<"es"> = { ) }, + ProviderValueField: { + copy: "Copiar" + }, + ModelsSection: { + "model label": "Modelo", + "not defined": "No definido", + "models fetch error": + "No se pueden obtener los modelos — compruebe la URL y la clave API." + }, + CustomProviderFormDialog: { + "add custom provider title": "Proveedores de IA personalizados", + "edit custom provider title": "Editar proveedor de IA", + "custom provider label field": "Etiqueta", + "custom provider type field": "Tipo de proveedor", + "custom provider api base field": "URL base de la API", + "custom provider api key field": "Clave API", + "provider test": "Probar conexión", + "provider test success": "Conexión exitosa", + "provider test error": "No se puede conectar — compruebe la URL y la clave API.", + "provider save": "Añadir", + "provider update": "Guardar", + "provider cancel": "Cancelar" + }, AccountVaultTab: { "credentials section title": "Credenciales de Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 71291c3b8..da533443d 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -109,30 +109,17 @@ export const translations: Translations<"fi"> = { ), "api base url": "API-perus-URL", token: "Token", - "model label": "Malli", "gateway error": "Tekoäly-yhdyskäytävän alustus epäonnistui.", "default provider": "Oletustarjoaja", "set default provider": "Aseta oletukseksi", "refresh credentials": "Päivitä tunnistetiedot", "delete provider": "Poista", "edit provider": "Muokkaa", - copy: "Kopioi", - "not defined": "Ei määritetty", "custom providers section title": "Mukautetut tekoälyntarjoajat", "custom providers section helper": "Lisää omia OpenAI-yhteensopivia tekoälypalveluntarjoajia. Tunnukset tallennetaan selaimeesi.", - "edit custom provider title": "Muokkaa tekoälyntarjoajaa", - "custom provider label field": "Tunniste", - "custom provider type field": "Palveluntarjoajan tyyppi", "custom provider api base field": "API-perus-URL", "custom provider api key field": "API-avain", - "provider test": "Testaa yhteys", - "provider test success": "Yhteys onnistui", - "provider test error": "Yhteyttä ei voi muodostaa — tarkista URL ja API-avain.", - "provider save": "Lisää", - "provider update": "Tallenna", - "provider cancel": "Peruuta", - "models fetch error": "Mallien haku epäonnistui — tarkista URL ja API-avain.", "no account": ({ webUiUrl }) => ( <> Sinulla ei vielä ole tiliä tekoälyyhdyskäytävässä. Kirjaudu ensin sisään @@ -144,6 +131,28 @@ export const translations: Translations<"fi"> = { ) }, + ProviderValueField: { + copy: "Kopioi" + }, + ModelsSection: { + "model label": "Malli", + "not defined": "Ei määritetty", + "models fetch error": "Mallien haku epäonnistui — tarkista URL ja API-avain." + }, + CustomProviderFormDialog: { + "add custom provider title": "Mukautetut tekoälyntarjoajat", + "edit custom provider title": "Muokkaa tekoälyntarjoajaa", + "custom provider label field": "Tunniste", + "custom provider type field": "Palveluntarjoajan tyyppi", + "custom provider api base field": "API-perus-URL", + "custom provider api key field": "API-avain", + "provider test": "Testaa yhteys", + "provider test success": "Yhteys onnistui", + "provider test error": "Yhteyttä ei voi muodostaa — tarkista URL ja API-avain.", + "provider save": "Lisää", + "provider update": "Tallenna", + "provider cancel": "Peruuta" + }, AccountVaultTab: { "credentials section title": "Vault-todennustiedot", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 91e43e86c..fd4f5e249 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -110,32 +110,17 @@ export const translations: Translations<"fr"> = { ), "api base url": "URL de base de l'API", token: "Jeton", - "model label": "Modèles", "gateway error": "Impossible d'initialiser la passerelle IA.", "default provider": "Provider par défaut", "set default provider": "Définir par défaut", "refresh credentials": "Rafraîchir les identifiants", "delete provider": "Supprimer", "edit provider": "Modifier", - copy: "Copier", - "not defined": "Non défini", "custom providers section title": "Providers IA personnalisés", "custom providers section helper": "Ajoutez vos propres providers IA compatibles OpenAI. Les identifiants sont stockés dans votre navigateur.", - "edit custom provider title": "Modifier le provider IA", - "custom provider label field": "Nom", - "custom provider type field": "Type de provider", "custom provider api base field": "URL de base de l'API", "custom provider api key field": "Clé API", - "provider test": "Tester la connexion", - "provider test success": "Connexion réussie", - "provider test error": - "Impossible de se connecter — vérifiez l'URL et la clé API.", - "provider save": "Ajouter", - "provider update": "Enregistrer", - "provider cancel": "Annuler", - "models fetch error": - "Impossible de récupérer les modèles — vérifiez l'URL et la clé API.", "no account": ({ webUiUrl }) => ( <> Vous n'avez pas encore de compte sur la passerelle IA. Veuillez @@ -147,6 +132,30 @@ export const translations: Translations<"fr"> = { ) }, + ProviderValueField: { + copy: "Copier" + }, + ModelsSection: { + "model label": "Modèles", + "not defined": "Non défini", + "models fetch error": + "Impossible de récupérer les modèles — vérifiez l'URL et la clé API." + }, + CustomProviderFormDialog: { + "add custom provider title": "Providers IA personnalisés", + "edit custom provider title": "Modifier le provider IA", + "custom provider label field": "Nom", + "custom provider type field": "Type de provider", + "custom provider api base field": "URL de base de l'API", + "custom provider api key field": "Clé API", + "provider test": "Tester la connexion", + "provider test success": "Connexion réussie", + "provider test error": + "Impossible de se connecter — vérifiez l'URL et la clé API.", + "provider save": "Ajouter", + "provider update": "Enregistrer", + "provider cancel": "Annuler" + }, AccountVaultTab: { "credentials section title": "Identifiants Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index d2f12f442..ef09ab167 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -108,32 +108,17 @@ export const translations: Translations<"it"> = { ), "api base url": "URL base dell'API", token: "Token", - "model label": "Modello", "gateway error": "Impossibile inizializzare il gateway IA.", "default provider": "Provider predefinito", "set default provider": "Imposta come predefinito", "refresh credentials": "Aggiorna credenziali", "delete provider": "Elimina", "edit provider": "Modifica", - copy: "Copia", - "not defined": "Non definito", "custom providers section title": "Provider IA personalizzati", "custom providers section helper": "Aggiungi i tuoi provider IA compatibili con OpenAI. Le credenziali sono memorizzate nel tuo browser.", - "edit custom provider title": "Modifica provider IA", - "custom provider label field": "Etichetta", - "custom provider type field": "Tipo di provider", "custom provider api base field": "URL base API", "custom provider api key field": "Chiave API", - "provider test": "Testa connessione", - "provider test success": "Connessione riuscita", - "provider test error": - "Impossibile connettersi — controlla l'URL e la chiave API.", - "provider save": "Aggiungi", - "provider update": "Salva", - "provider cancel": "Annulla", - "models fetch error": - "Impossibile recuperare i modelli — controlla l'URL e la chiave API.", "no account": ({ webUiUrl }) => ( <> Non hai ancora un account sul gateway IA. Per favore accedi prima su{" "} @@ -144,6 +129,30 @@ export const translations: Translations<"it"> = { ) }, + ProviderValueField: { + copy: "Copia" + }, + ModelsSection: { + "model label": "Modello", + "not defined": "Non definito", + "models fetch error": + "Impossibile recuperare i modelli — controlla l'URL e la chiave API." + }, + CustomProviderFormDialog: { + "add custom provider title": "Provider IA personalizzati", + "edit custom provider title": "Modifica provider IA", + "custom provider label field": "Etichetta", + "custom provider type field": "Tipo di provider", + "custom provider api base field": "URL base API", + "custom provider api key field": "Chiave API", + "provider test": "Testa connessione", + "provider test success": "Connessione riuscita", + "provider test error": + "Impossibile connettersi — controlla l'URL e la chiave API.", + "provider save": "Aggiungi", + "provider update": "Salva", + "provider cancel": "Annulla" + }, AccountVaultTab: { "credentials section title": "Credenziali Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 033121802..79298f60f 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -109,32 +109,17 @@ export const translations: Translations<"nl"> = { ), "api base url": "API-basis-URL", token: "Token", - "model label": "Model", "gateway error": "Kan de AI-gateway niet initialiseren.", "default provider": "Standaardprovider", "set default provider": "Als standaard instellen", "refresh credentials": "Referenties vernieuwen", "delete provider": "Verwijderen", "edit provider": "Bewerken", - copy: "Kopiëren", - "not defined": "Niet gedefinieerd", "custom providers section title": "Aangepaste AI-providers", "custom providers section helper": "Voeg uw eigen OpenAI-compatibele AI-providers toe. Inloggegevens worden in uw browser opgeslagen.", - "edit custom provider title": "AI-provider bewerken", - "custom provider label field": "Label", - "custom provider type field": "Providertype", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-sleutel", - "provider test": "Verbinding testen", - "provider test success": "Verbinding geslaagd", - "provider test error": - "Kan geen verbinding maken — controleer URL en API-sleutel.", - "provider save": "Toevoegen", - "provider update": "Opslaan", - "provider cancel": "Annuleren", - "models fetch error": - "Kan modellen niet ophalen — controleer uw URL en API-sleutel.", "no account": ({ webUiUrl }) => ( <> U heeft nog geen account bij de AI-gateway. Meld u eerst aan bij{" "} @@ -145,6 +130,30 @@ export const translations: Translations<"nl"> = { ) }, + ProviderValueField: { + copy: "Kopiëren" + }, + ModelsSection: { + "model label": "Model", + "not defined": "Niet gedefinieerd", + "models fetch error": + "Kan modellen niet ophalen — controleer uw URL en API-sleutel." + }, + CustomProviderFormDialog: { + "add custom provider title": "Aangepaste AI-providers", + "edit custom provider title": "AI-provider bewerken", + "custom provider label field": "Label", + "custom provider type field": "Providertype", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-sleutel", + "provider test": "Verbinding testen", + "provider test success": "Verbinding geslaagd", + "provider test error": + "Kan geen verbinding maken — controleer URL en API-sleutel.", + "provider save": "Toevoegen", + "provider update": "Opslaan", + "provider cancel": "Annuleren" + }, AccountVaultTab: { "credentials section title": "Gebrukersnamen Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 0f0e2fa3a..f8c46aeae 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -109,30 +109,17 @@ export const translations: Translations<"no"> = { ), "api base url": "API-basis-URL", token: "Token", - "model label": "Modell", "gateway error": "Kunne ikke initialisere AI-gatewayen.", "default provider": "Standardleverandør", "set default provider": "Angi som standard", "refresh credentials": "Oppdater legitimasjon", "delete provider": "Slett", "edit provider": "Rediger", - copy: "Kopier", - "not defined": "Ikke definert", "custom providers section title": "Tilpassede AI-leverandører", "custom providers section helper": "Legg til dine egne OpenAI-kompatible AI-leverandører. Påloggingsinformasjonen lagres i nettleseren din.", - "edit custom provider title": "Rediger AI-leverandør", - "custom provider label field": "Etikett", - "custom provider type field": "Leverandørtype", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-nøkkel", - "provider test": "Test tilkobling", - "provider test success": "Tilkobling vellykket", - "provider test error": "Kan ikke koble til — sjekk URL og API-nøkkel.", - "provider save": "Legg til", - "provider update": "Lagre", - "provider cancel": "Avbryt", - "models fetch error": "Kan ikke hente modeller — sjekk URL-en og API-nøkkelen.", "no account": ({ webUiUrl }) => ( <> Du har ikke en konto på AI-gatewayen ennå. Logg inn først på{" "} @@ -143,6 +130,28 @@ export const translations: Translations<"no"> = { ) }, + ProviderValueField: { + copy: "Kopier" + }, + ModelsSection: { + "model label": "Modell", + "not defined": "Ikke definert", + "models fetch error": "Kan ikke hente modeller — sjekk URL-en og API-nøkkelen." + }, + CustomProviderFormDialog: { + "add custom provider title": "Tilpassede AI-leverandører", + "edit custom provider title": "Rediger AI-leverandør", + "custom provider label field": "Etikett", + "custom provider type field": "Leverandørtype", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-nøkkel", + "provider test": "Test tilkobling", + "provider test success": "Tilkobling vellykket", + "provider test error": "Kan ikke koble til — sjekk URL og API-nøkkel.", + "provider save": "Legg til", + "provider update": "Lagre", + "provider cancel": "Avbryt" + }, AccountVaultTab: { "credentials section title": "Vault credentials", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 50a3ed2cc..1ca65e252 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -100,30 +100,17 @@ export const translations: Translations<"zh-CN"> = { ), "api base url": "API 基础 URL", token: "令牌", - "model label": "模型", "gateway error": "无法初始化 AI 网关。", "default provider": "默认提供商", "set default provider": "设为默认提供商", "refresh credentials": "刷新凭据", "delete provider": "删除", "edit provider": "编辑", - copy: "复制", - "not defined": "未定义", "custom providers section title": "自定义 AI 提供商", "custom providers section helper": "添加您自己的兼容 OpenAI 的 AI 提供商。凭据存储在您的浏览器中。", - "edit custom provider title": "编辑 AI 提供商", - "custom provider label field": "标签", - "custom provider type field": "提供商类型", "custom provider api base field": "API 基础 URL", "custom provider api key field": "API 密钥", - "provider test": "测试连接", - "provider test success": "连接成功", - "provider test error": "无法连接 — 请检查 URL 和 API 密钥。", - "provider save": "添加", - "provider update": "保存", - "provider cancel": "取消", - "models fetch error": "无法获取模型 — 请检查您的 URL 和 API 密钥。", "no account": ({ webUiUrl }) => ( <> 您还没有 AI 网关账户。请先登录{" "} @@ -134,6 +121,28 @@ export const translations: Translations<"zh-CN"> = { ) }, + ProviderValueField: { + copy: "复制" + }, + ModelsSection: { + "model label": "模型", + "not defined": "未定义", + "models fetch error": "无法获取模型 — 请检查您的 URL 和 API 密钥。" + }, + CustomProviderFormDialog: { + "add custom provider title": "自定义 AI 提供商", + "edit custom provider title": "编辑 AI 提供商", + "custom provider label field": "标签", + "custom provider type field": "提供商类型", + "custom provider api base field": "API 基础 URL", + "custom provider api key field": "API 密钥", + "provider test": "测试连接", + "provider test success": "连接成功", + "provider test error": "无法连接 — 请检查 URL 和 API 密钥。", + "provider save": "添加", + "provider update": "保存", + "provider cancel": "取消" + }, AccountVaultTab: { "credentials section title": "保险库凭证", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index f358689fc..2ae98292f 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -55,7 +55,10 @@ export type ComponentKey = | import("ui/pages/account/AccountKubernetesTab").I18n | import("ui/pages/account/AccountUserInterfaceTab").I18n | import("ui/pages/account/AccountVaultTab").I18n - | import("ui/pages/account/AccountAiTab").I18n + | import("ui/pages/account/AccountAiTab/AccountAiTab").I18n + | import("ui/pages/account/AccountAiTab/ProviderValueField").I18n + | import("ui/pages/account/AccountAiTab/ModelsSection").I18n + | import("ui/pages/account/AccountAiTab/CustomProviderFormDialog").I18n | import("ui/App/Footer").I18n | import("ui/pages/catalog/Page").I18n | import("ui/pages/catalog/CatalogChartCard").I18n diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx deleted file mode 100644 index 89060f32c..000000000 --- a/web/src/ui/pages/account/AccountAiTab.tsx +++ /dev/null @@ -1,680 +0,0 @@ -import { memo, useState } from "react"; -import { useTranslation } from "ui/i18n"; -import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; -import { useCallbackFactory } from "powerhooks/useCallbackFactory"; -import { copyToClipboard } from "ui/tools/copyToClipboard"; -import { tss } from "tss"; -import { declareComponentKeys } from "i18nifty"; -import { useConstCallback } from "powerhooks/useConstCallback"; -import { IconButton } from "onyxia-ui/IconButton"; -import { Icon } from "onyxia-ui/Icon"; -import { CircularProgress } from "onyxia-ui/CircularProgress"; -import { Dialog } from "onyxia-ui/Dialog"; -import { Button } from "onyxia-ui/Button"; -import { useCoreState, getCoreSync } from "core"; -import { getIconUrlByName } from "lazy-icons"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import TextField from "@mui/material/TextField"; -import { Text } from "onyxia-ui/Text"; - -export type Props = { - className?: string; -}; - -type AiModel = { id: string; name: string }; - -type Models = - | { stateDescription: "fetching" } - | { stateDescription: "error" } - | { stateDescription: "loaded"; availableModels: AiModel[] } - | undefined; - -type FormValues = { - name: string; - provider: string; - apiBase: string; - apiKey: string; -}; - -type FormTest = - | { stateDescription: "idle" } - | { stateDescription: "testing" } - | { stateDescription: "success"; modelCount: number } - | { stateDescription: "error" }; - -const AccountAiGatewayTab = memo((props: Props) => { - const { className } = props; - - const { classes } = useStyles(); - - const { - functions: { ai } - } = getCoreSync(); - - const { stateDescription, regionProviders, customProviders } = useCoreState( - "ai", - "main" - ); - - const { t } = useTranslation({ AccountAiGatewayTab }); - - const onFieldRequestCopyFactory = useCallbackFactory(([text]: [string]) => - copyToClipboard(text) - ); - - const onRefreshClickFactory = useCallbackFactory(([providerId]: [string]) => - ai.refreshToken({ providerId }) - ); - - const onSetDefaultProviderFactory = useCallbackFactory(([providerId]: [string]) => - ai.setActiveProvider({ - activeProviderId: providerId - }) - ); - - const onDeleteCustomProviderFactory = useCallbackFactory(([providerId]: [string]) => - ai.deleteCustomProvider({ providerId }) - ); - - // The add/edit custom-provider form is entirely UI-owned: its open state, edited - // values and connection-test result never go through the core. The core only - // exposes the resulting operations (add/edit/test). - const [isFormOpen, setIsFormOpen] = useState(false); - const [editedProviderId, setEditedProviderId] = useState( - undefined - ); - const [values, setValues] = useState({ - name: "", - provider: "openai", - apiBase: "", - apiKey: "" - }); - const [test, setTest] = useState({ stateDescription: "idle" }); - - const isEditing = editedProviderId !== undefined; - const canSave = - values.name !== "" && - values.provider !== "" && - values.apiBase !== "" && - values.apiKey !== ""; - const canTest = - values.apiBase !== "" && - values.apiKey !== "" && - test.stateDescription !== "testing"; - - const onAddClick = useConstCallback(() => { - setEditedProviderId(undefined); - setValues({ name: "", provider: "openai", apiBase: "", apiKey: "" }); - setTest({ stateDescription: "idle" }); - setIsFormOpen(true); - }); - - const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => { - const provider = customProviders.find(p => p.id === providerId); - if (provider === undefined) return; - setEditedProviderId(providerId); - setValues({ - name: provider.name, - provider: provider.provider, - apiBase: provider.apiBase, - apiKey: provider.apiKey - }); - setTest({ stateDescription: "idle" }); - setIsFormOpen(true); - }); - - const onClose = useConstCallback(() => setIsFormOpen(false)); - - const onFieldChangeFactory = useCallbackFactory( - ([key]: [keyof FormValues], [event]: [{ target: { value: string } }]) => { - const { value } = event.target; - setValues(values => ({ ...values, [key]: value })); - // Only credential changes invalidate a previous connection-test result; - // the display label and provider type don't affect connectivity. - if (key !== "name" && key !== "provider") { - setTest({ stateDescription: "idle" }); - } - } - ); - - const onTest = useConstCallback(async () => { - setTest({ stateDescription: "testing" }); - try { - const { modelCount } = await ai.testCustomProviderConnection({ - apiBase: values.apiBase, - apiKey: values.apiKey - }); - setTest({ stateDescription: "success", modelCount }); - } catch { - setTest({ stateDescription: "error" }); - } - }); - - const onSave = useConstCallback(async () => { - setIsFormOpen(false); - if (editedProviderId === undefined) { - await ai.addCustomProvider(values); - } else { - await ai.editCustomProvider({ providerId: editedProviderId, ...values }); - } - }); - - if (stateDescription !== "initialized") { - return stateDescription === "error" ? ( - - {t("gateway error")} - - ) : ( - - ); - } - - const renderDefaultProviderAction = (params: { - providerId: string; - isDefault: boolean; - }) => - params.isDefault ? ( -
- - - {t("default provider")} - -
- ) : ( - - ); - - return ( -
- {regionProviders.map(regionProvider => ( -
-
- {regionProvider.name} -
- {regionProvider.auth.stateDescription === "authenticated" && ( - <> - - {renderDefaultProviderAction({ - providerId: regionProvider.id, - isDefault: regionProvider.isDefault - })} - - )} -
-
- - {regionProvider.auth.stateDescription === "no account" && ( - - {t("no account", { webUiUrl: regionProvider.webUiUrl })} - - )} - - {regionProvider.auth.stateDescription === "error" && ( - - {t("gateway error")} - - )} - - {regionProvider.auth.stateDescription === "authenticated" && ( - <> - -
- - - -
- - )} -
- ))} - -
- - -
- - {customProviders.map(provider => ( -
-
- {provider.name} -
- - - {renderDefaultProviderAction({ - providerId: provider.id, - isDefault: provider.isDefault - })} -
-
-
- - - -
-
- ))} - - - - - - -
- - {test.stateDescription === "success" && ( - - {t("provider test success")} ({test.modelCount}) - - )} - {test.stateDescription === "error" && ( - - {t("provider test error")} - - )} -
-
- } - buttons={ - <> - - - - } - /> -
- ); -}); - -type ProviderValueFieldProps = { - label: string; - value: string; - onRequestCopy: () => void; - isSensitiveInformation?: boolean; -}; - -const ProviderValueField = memo((props: ProviderValueFieldProps) => { - const { label, value, onRequestCopy, isSensitiveInformation = false } = props; - - const { classes } = useStyles(); - const { t } = useTranslation({ AccountAiGatewayTab }); - const [isHidden, setIsHidden] = useState(isSensitiveInformation); - - const onToggleHidden = useConstCallback(() => setIsHidden(isHidden => !isHidden)); - - return ( -
- {label} -
- - {isHidden ? "•".repeat(Math.max(value.length, 30)) : value} - - {isSensitiveInformation && ( - - )} - -
-
- ); -}); - -type ModelsSectionProps = { - providerId: string; - models: Models; - selectedModel: string | undefined; -}; - -const ModelsSection = memo((props: ModelsSectionProps) => { - const { providerId, models, selectedModel } = props; - - const { classes } = useStyles(); - const { t } = useTranslation({ AccountAiGatewayTab }); - const { - functions: { ai } - } = getCoreSync(); - - const onModelChange = useConstCallback((event: { target: { value: string } }) => - ai.setSelectedModel({ providerId, modelId: event.target.value }) - ); - - if (models === undefined) { - return null; - } - - switch (models.stateDescription) { - case "fetching": - return ; - case "error": - return ( - - {t("models fetch error")} - - ); - case "loaded": - return ( - - ); - } -}); - -type ModelSelectRowProps = { - label: string; - models: AiModel[]; - selectedModel: string | undefined; - onChange: (event: { target: { value: string } }) => void; -}; - -const ModelSelectRow = memo((props: ModelSelectRowProps) => { - const { label, models, selectedModel, onChange } = props; - - const { classes } = useStyles(); - const { t } = useTranslation({ AccountAiGatewayTab }); - - return ( -
- {label} -
- -
-
- ); -}); - -export default AccountAiGatewayTab; - -const { i18n } = declareComponentKeys< - | "default provider" - | "set default provider" - | "refresh credentials" - | "delete provider" - | "edit provider" - | "copy" - | "not defined" - | "credentials section title" - | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } - | "api base url" - | "token" - | "model label" - | "gateway error" - | "custom providers section title" - | "custom providers section helper" - | "edit custom provider title" - | "custom provider label field" - | "custom provider type field" - | "custom provider api base field" - | "custom provider api key field" - | "provider test" - | "provider test success" - | "provider test error" - | "provider save" - | "provider update" - | "provider cancel" - | "models fetch error" - | { K: "no account"; P: { webUiUrl: string }; R: JSX.Element } ->()({ AccountAiGatewayTab }); -export type I18n = typeof i18n; - -const useStyles = tss.withName({ AccountAiGatewayTab }).create(({ theme }) => ({ - customProvidersSectionHeader: { - display: "flex", - alignItems: "flex-start", - gap: theme.spacing(1), - marginTop: theme.spacing(4) - }, - providerCard: { - border: `1px solid ${theme.colors.useCases.typography.textDisabled}`, - borderRadius: theme.spacing(1), - padding: theme.spacing(3), - marginTop: theme.spacing(3) - }, - providerCardHeader: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: theme.spacing(2) - }, - providerCardActions: { - display: "flex", - alignItems: "center", - gap: theme.spacing(1), - flexWrap: "wrap", - justifyContent: "flex-end" - }, - compactActionButton: { - minHeight: 28, - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5) - }, - defaultProviderBadge: { - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: theme.spacing(1), - minHeight: 28, - borderRadius: 9999, - backgroundColor: theme.colors.useCases.alertSeverity.success.background - }, - defaultProviderBadgeIcon: { - color: theme.colors.useCases.alertSeverity.success.main - }, - defaultProviderBadgeText: { - color: theme.colors.useCases.alertSeverity.success.main - }, - providerFields: { - display: "flex", - flexDirection: "column", - gap: theme.spacing(2), - marginTop: theme.spacing(2), - paddingLeft: theme.spacing(3), - paddingRight: theme.spacing(3) - }, - providerField: { - display: "flex", - flexDirection: "column", - gap: theme.spacing(0.5) - }, - codeFrame: { - minHeight: 45, - display: "flex", - alignItems: "center", - gap: theme.spacing(1.5), - padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, - borderRadius: theme.spacing(1), - backgroundColor: theme.colors.useCases.surfaces.surface2, - minWidth: 0 - }, - codeFrameValue: { - flex: 1, - minWidth: 0, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - fontFamily: "monospace" - }, - codeFrameButton: { - minHeight: 28, - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - flexShrink: 0 - }, - modelSelect: { - flex: 1, - minWidth: 0, - "& .MuiOutlinedInput-notchedOutline": { - border: "none" - }, - "& .MuiSelect-select": { - paddingLeft: 0 - } - }, - errorText: { - color: theme.colors.useCases.alertSeverity.error.main - }, - addFormFields: { - display: "flex", - flexDirection: "column", - gap: theme.spacing(4) - }, - testRow: { - display: "flex", - alignItems: "center", - gap: theme.spacing(3) - }, - testSuccess: { - color: theme.colors.useCases.alertSeverity.success.main - } -})); diff --git a/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx new file mode 100644 index 000000000..e86073582 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx @@ -0,0 +1,337 @@ +import { memo } from "react"; +import { useTranslation } from "ui/i18n"; +import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { useConst } from "powerhooks/useConst"; +import { copyToClipboard } from "ui/tools/copyToClipboard"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { Evt } from "evt"; +import type { UnpackEvt } from "evt"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Icon } from "onyxia-ui/Icon"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { useCoreState, getCoreSync } from "core"; +import { getIconUrlByName } from "lazy-icons"; +import { ProviderValueField } from "./ProviderValueField"; +import { ModelsSection } from "./ModelsSection"; +import { + CustomProviderFormDialog, + type Props as CustomProviderFormDialogProps +} from "./CustomProviderFormDialog"; + +export type Props = { + className?: string; +}; + +export const AccountAiTab = memo((props: Props) => { + const { className } = props; + + const { classes } = useStyles(); + + const { + functions: { ai } + } = getCoreSync(); + + const { stateDescription, regionProviders, customProviders } = useCoreState( + "ai", + "main" + ); + + const { t } = useTranslation({ AccountAiGatewayTab: AccountAiTab }); + + const evtCustomProviderFormDialogOpen = useConst(() => + Evt.create>() + ); + + const onFieldRequestCopyFactory = useCallbackFactory(([text]: [string]) => + copyToClipboard(text) + ); + + const onRefreshClickFactory = useCallbackFactory(([providerId]: [string]) => + ai.refreshToken({ providerId }) + ); + + const onSetDefaultProviderFactory = useCallbackFactory(([providerId]: [string]) => + ai.setActiveProvider({ + activeProviderId: providerId + }) + ); + + const onDeleteCustomProviderFactory = useCallbackFactory(([providerId]: [string]) => + ai.deleteCustomProvider({ providerId }) + ); + + const onAddClick = useConstCallback(() => + evtCustomProviderFormDialogOpen.post({ editedProvider: undefined }) + ); + + const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => { + const provider = customProviders.find(p => p.id === providerId); + if (provider === undefined) return; + evtCustomProviderFormDialogOpen.post({ + editedProvider: { + id: provider.id, + name: provider.name, + provider: provider.provider, + apiBase: provider.apiBase, + apiKey: provider.apiKey + } + }); + }); + + if (stateDescription !== "initialized") { + return stateDescription === "error" ? ( + + {t("gateway error")} + + ) : ( + + ); + } + + const renderDefaultProviderAction = (params: { + providerId: string; + isDefault: boolean; + }) => + params.isDefault ? ( +
+ + + {t("default provider")} + +
+ ) : ( + + ); + + return ( +
+ {regionProviders.map(regionProvider => ( +
+
+ {regionProvider.name} +
+ {regionProvider.auth.stateDescription === "authenticated" && ( + <> + + {renderDefaultProviderAction({ + providerId: regionProvider.id, + isDefault: regionProvider.isDefault + })} + + )} +
+
+ + {regionProvider.auth.stateDescription === "no account" && ( + + {t("no account", { webUiUrl: regionProvider.webUiUrl })} + + )} + + {regionProvider.auth.stateDescription === "error" && ( + + {t("gateway error")} + + )} + + {regionProvider.auth.stateDescription === "authenticated" && ( + <> + +
+ + + +
+ + )} +
+ ))} + +
+ + +
+ + {customProviders.map(provider => ( +
+
+ {provider.name} +
+ + + {renderDefaultProviderAction({ + providerId: provider.id, + isDefault: provider.isDefault + })} +
+
+
+ + + +
+
+ ))} + + +
+ ); +}); + +const { i18n } = declareComponentKeys< + | "default provider" + | "set default provider" + | "refresh credentials" + | "delete provider" + | "edit provider" + | "credentials section title" + | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } + | "api base url" + | "token" + | "gateway error" + | "custom providers section title" + | "custom providers section helper" + | "custom provider api base field" + | "custom provider api key field" + | { K: "no account"; P: { webUiUrl: string }; R: JSX.Element } +>()({ AccountAiGatewayTab: AccountAiTab }); +export type I18n = typeof i18n; + +const useStyles = tss + .withName({ AccountAiGatewayTab: AccountAiTab }) + .create(({ theme }) => ({ + customProvidersSectionHeader: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(1), + marginTop: theme.spacing(4) + }, + providerCard: { + border: `1px solid ${theme.colors.useCases.typography.textDisabled}`, + borderRadius: theme.spacing(1), + padding: theme.spacing(3), + marginTop: theme.spacing(3) + }, + providerCardHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: theme.spacing(2) + }, + providerCardActions: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + flexWrap: "wrap", + justifyContent: "flex-end" + }, + compactActionButton: { + minHeight: 28, + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5) + }, + defaultProviderBadge: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(1), + minHeight: 28, + borderRadius: 9999, + backgroundColor: theme.colors.useCases.alertSeverity.success.background + }, + defaultProviderBadgeIcon: { + color: theme.colors.useCases.alertSeverity.success.main + }, + defaultProviderBadgeText: { + color: theme.colors.useCases.alertSeverity.success.main + }, + providerFields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3) + }, + errorText: { + color: theme.colors.useCases.alertSeverity.error.main + } + })); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx new file mode 100644 index 000000000..ada17beb6 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx @@ -0,0 +1,249 @@ +import { memo, useState } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { useEvt } from "evt/hooks"; +import type { NonPostableEvt, UnpackEvt } from "evt"; +import { assert } from "tsafe/assert"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import TextField from "@mui/material/TextField"; +import { getCoreSync } from "core"; + +export type Props = { + evtOpen: NonPostableEvt<{ + editedProvider: + | { + id: string; + name: string; + provider: string; + apiBase: string; + apiKey: string; + } + | undefined; + }>; +}; + +type OpenParams = UnpackEvt; + +type FormValues = { + name: string; + provider: string; + apiBase: string; + apiKey: string; +}; + +type FormTest = + | { stateDescription: "idle" } + | { stateDescription: "testing" } + | { stateDescription: "success"; modelCount: number } + | { stateDescription: "error" }; + +const defaultFormValues: FormValues = { + name: "", + provider: "openai", + apiBase: "", + apiKey: "" +}; + +export const CustomProviderFormDialog = memo((props: Props) => { + const { evtOpen } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ CustomProviderFormDialog }); + + const { + functions: { ai } + } = getCoreSync(); + + // The add/edit custom-provider form is entirely UI-owned: its open state, edited + // values and connection-test result never go through the core. The core only + // exposes the resulting operations (add/edit/test). + const [openState, setOpenState] = useState< + { editedProviderId: string | undefined } | undefined + >(undefined); + const [values, setValues] = useState(defaultFormValues); + const [test, setTest] = useState({ stateDescription: "idle" }); + + useEvt( + ctx => { + evtOpen.attach(ctx, ({ editedProvider }: OpenParams) => { + setValues( + editedProvider === undefined + ? defaultFormValues + : { + name: editedProvider.name, + provider: editedProvider.provider, + apiBase: editedProvider.apiBase, + apiKey: editedProvider.apiKey + } + ); + setTest({ stateDescription: "idle" }); + setOpenState({ editedProviderId: editedProvider?.id }); + }); + }, + [evtOpen] + ); + + const isEditing = openState?.editedProviderId !== undefined; + const canSave = + values.name !== "" && + values.provider !== "" && + values.apiBase !== "" && + values.apiKey !== ""; + const canTest = + values.apiBase !== "" && + values.apiKey !== "" && + test.stateDescription !== "testing"; + + const onClose = useConstCallback(() => setOpenState(undefined)); + + const onFieldChangeFactory = useCallbackFactory( + ([key]: [keyof FormValues], [event]: [{ target: { value: string } }]) => { + const { value } = event.target; + setValues(values => ({ ...values, [key]: value })); + // Only credential changes invalidate a previous connection-test result; + // the display label and provider type don't affect connectivity. + if (key !== "name" && key !== "provider") { + setTest({ stateDescription: "idle" }); + } + } + ); + + const onTest = useConstCallback(async () => { + setTest({ stateDescription: "testing" }); + try { + const { modelCount } = await ai.testCustomProviderConnection({ + apiBase: values.apiBase, + apiKey: values.apiKey + }); + setTest({ stateDescription: "success", modelCount }); + } catch { + setTest({ stateDescription: "error" }); + } + }); + + const onSave = useConstCallback(async () => { + assert(openState !== undefined); + const { editedProviderId } = openState; + setOpenState(undefined); + if (editedProviderId === undefined) { + await ai.addCustomProvider(values); + } else { + await ai.editCustomProvider({ providerId: editedProviderId, ...values }); + } + }); + + return ( + + + + + +
+ + {test.stateDescription === "success" && ( + + {t("provider test success")} ({test.modelCount}) + + )} + {test.stateDescription === "error" && ( + + {t("provider test error")} + + )} +
+
+ } + buttons={ + <> + + + + } + /> + ); +}); + +const { i18n } = declareComponentKeys< + | "add custom provider title" + | "edit custom provider title" + | "custom provider label field" + | "custom provider type field" + | "custom provider api base field" + | "custom provider api key field" + | "provider test" + | "provider test success" + | "provider test error" + | "provider save" + | "provider update" + | "provider cancel" +>()({ CustomProviderFormDialog }); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ CustomProviderFormDialog }).create(({ theme }) => ({ + formFields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(4) + }, + testRow: { + display: "flex", + alignItems: "center", + gap: theme.spacing(3) + }, + testSuccess: { + color: theme.colors.useCases.alertSeverity.success.main + }, + testError: { + color: theme.colors.useCases.alertSeverity.error.main + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx new file mode 100644 index 000000000..e4e9aa3ab --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx @@ -0,0 +1,115 @@ +import { memo } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import { getCoreSync } from "core"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; + +export type AiModel = { id: string; name: string }; + +export type Models = + | { stateDescription: "fetching" } + | { stateDescription: "error" } + | { stateDescription: "loaded"; availableModels: AiModel[] } + | undefined; + +export type Props = { + providerId: string; + models: Models; + selectedModel: string | undefined; +}; + +export const ModelsSection = memo((props: Props) => { + const { providerId, models, selectedModel } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ ModelsSection }); + const { + functions: { ai } + } = getCoreSync(); + + const onModelChange = useConstCallback((event: { target: { value: string } }) => + ai.setSelectedModel({ providerId, modelId: event.target.value }) + ); + + if (models === undefined) { + return null; + } + + switch (models.stateDescription) { + case "fetching": + return ; + case "error": + return ( + + {t("models fetch error")} + + ); + case "loaded": + return ( +
+ {t("model label")} +
+ +
+
+ ); + } +}); + +const { i18n } = declareComponentKeys< + "model label" | "not defined" | "models fetch error" +>()({ + ModelsSection +}); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ ModelsSection }).create(({ theme }) => ({ + root: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + codeFrame: { + minHeight: 45, + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, + borderRadius: theme.spacing(1), + backgroundColor: theme.colors.useCases.surfaces.surface2, + minWidth: 0 + }, + modelSelect: { + flex: 1, + minWidth: 0, + "& .MuiOutlinedInput-notchedOutline": { + border: "none" + }, + "& .MuiSelect-select": { + paddingLeft: 0 + } + }, + errorText: { + color: theme.colors.useCases.alertSeverity.error.main + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/ProviderValueField.tsx b/web/src/ui/pages/account/AccountAiTab/ProviderValueField.tsx new file mode 100644 index 000000000..ee242e0c1 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ProviderValueField.tsx @@ -0,0 +1,87 @@ +import { memo, useState } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { getIconUrlByName } from "lazy-icons"; + +export type Props = { + label: string; + value: string; + onRequestCopy: () => void; + isSensitiveInformation?: boolean; +}; + +export const ProviderValueField = memo((props: Props) => { + const { label, value, onRequestCopy, isSensitiveInformation = false } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ ProviderValueField }); + const [isHidden, setIsHidden] = useState(isSensitiveInformation); + + const onToggleHidden = useConstCallback(() => setIsHidden(isHidden => !isHidden)); + + return ( +
+ {label} +
+ + {isHidden ? "•".repeat(Math.max(value.length, 30)) : value} + + {isSensitiveInformation && ( + + )} + +
+
+ ); +}); + +const { i18n } = declareComponentKeys<"copy">()({ ProviderValueField }); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ ProviderValueField }).create(({ theme }) => ({ + root: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + codeFrame: { + minHeight: 45, + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, + borderRadius: theme.spacing(1), + backgroundColor: theme.colors.useCases.surfaces.surface2, + minWidth: 0 + }, + codeFrameValue: { + flex: 1, + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontFamily: "monospace" + }, + codeFrameButton: { + minHeight: 28, + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + flexShrink: 0 + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/index.ts b/web/src/ui/pages/account/AccountAiTab/index.ts new file mode 100644 index 000000000..5b93e4d96 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/index.ts @@ -0,0 +1,3 @@ +import { AccountAiTab } from "./AccountAiTab"; + +export default AccountAiTab; From e3d4e410c983f4c670913b41e760cb4c58436738 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:25:38 +0200 Subject: [PATCH 21/25] implement AI provider settings design --- web/src/core/adapters/ai/openWebUi.ts | 18 +- web/src/core/adapters/onyxiaApi/ApiTypes.ts | 7 + web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 15 + web/src/core/bootstrap.ts | 5 +- web/src/core/ports/Ai.ts | 4 + .../core/ports/OnyxiaApi/DeploymentRegion.ts | 9 + web/src/core/ports/OnyxiaApi/XOnyxia.ts | 1 + web/src/core/usecases/ai/selectors.ts | 3 + web/src/core/usecases/ai/state.ts | 12 +- web/src/core/usecases/ai/thunks.ts | 58 +- web/src/ui/assets/img/openWebUiIcon.png | Bin 0 -> 23275 bytes web/src/ui/i18n/resources/de.tsx | 24 +- web/src/ui/i18n/resources/en.tsx | 38 +- web/src/ui/i18n/resources/es.tsx | 23 +- web/src/ui/i18n/resources/fi.tsx | 23 +- web/src/ui/i18n/resources/fr.tsx | 23 +- web/src/ui/i18n/resources/it.tsx | 23 +- web/src/ui/i18n/resources/nl.tsx | 24 +- web/src/ui/i18n/resources/no.tsx | 23 +- web/src/ui/i18n/resources/zh-CN.tsx | 20 +- .../account/AccountAiTab/AccountAiTab.tsx | 427 ++++++-- .../CustomProviderFormDialog.stories.tsx | 53 + .../AccountAiTab/CustomProviderFormDialog.tsx | 978 +++++++++++++++--- .../account/AccountAiTab/ModelsSection.tsx | 1 + .../AccountAiTab/ProviderValueField.tsx | 60 +- 25 files changed, 1570 insertions(+), 302 deletions(-) create mode 100644 web/src/ui/assets/img/openWebUiIcon.png create mode 100644 web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx diff --git a/web/src/core/adapters/ai/openWebUi.ts b/web/src/core/adapters/ai/openWebUi.ts index 51dbe9a11..ab27bad49 100644 --- a/web/src/core/adapters/ai/openWebUi.ts +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -5,18 +5,32 @@ import { z } from "zod"; export function createAi(params: { id: string; name: string; + provider: Ai["provider"]; + description: Ai["description"]; + accountCreation: Ai["accountCreation"]; webUiUrl: string; oauthProvider: string; getOidcAccessToken: () => Promise; }): Ai { - const { id, name, webUiUrl, oauthProvider, getOidcAccessToken } = params; + const { + id, + name, + provider, + description, + accountCreation, + webUiUrl, + oauthProvider, + getOidcAccessToken + } = params; const apiBase = `${webUiUrl}/api`; return { id, name, - provider: "openai", + provider, + description, + accountCreation, webUiUrl, apiBase, getToken: async (): Promise => { diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index d7ee21217..91f042198 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -86,6 +86,13 @@ export type ApiTypes = { id?: string; URL: string; name?: string; + provider?: string; + description?: LocalizedString; + accountCreation?: { + title?: LocalizedString; + description?: LocalizedString; + buttonLabel?: LocalizedString; + }; oauthProvider: string; oidcConfiguration?: Partial; }>; diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 04128b32f..53ab8e1b6 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -449,6 +449,21 @@ export function createOnyxiaApi(params: { id: aiConfig_api.id ?? `onyxia-${i}`, url: aiConfig_api.URL, name: aiConfig_api.name, + provider: aiConfig_api.provider ?? "openai", + description: aiConfig_api.description, + accountCreation: + aiConfig_api.accountCreation === undefined + ? undefined + : { + title: aiConfig_api.accountCreation + .title, + description: + aiConfig_api.accountCreation + .description, + buttonLabel: + aiConfig_api.accountCreation + .buttonLabel + }, oauthProvider: aiConfig_api.oauthProvider, oidcParams: apiTypesOidcConfigurationToOidcParams_Partial( diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 41a2e5b3a..8a7831fe8 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -318,7 +318,7 @@ export async function bootstrapCore( oidcParams_partial: aiConfig.oidcParams }); - const oidcKey = `${oidcParams_ai.issuerUri}${oidcParams_ai.clientId}`; + const oidcKey = `${oidcParams_ai.issuerUri}\0${oidcParams_ai.clientId}`; let getOidcAccessToken = getOidcAccessTokenByOidcKey.get(oidcKey); @@ -346,6 +346,9 @@ export async function bootstrapCore( createAi({ id: aiConfig.id, name: aiConfig.name ?? new URL(aiConfig.url).hostname, + provider: aiConfig.provider, + description: aiConfig.description, + accountCreation: aiConfig.accountCreation, webUiUrl: aiConfig.url, oauthProvider: aiConfig.oauthProvider, getOidcAccessToken diff --git a/web/src/core/ports/Ai.ts b/web/src/core/ports/Ai.ts index 47b0c98c3..76d1d29e3 100644 --- a/web/src/core/ports/Ai.ts +++ b/web/src/core/ports/Ai.ts @@ -1,3 +1,5 @@ +import type { DeploymentRegion, LocalizedString } from "./OnyxiaApi"; + export type Ai = { id: string; name: string; @@ -7,6 +9,8 @@ export type Ai = { * API dialect to use. Defaults to "openai" (OpenAI-compatible) for region gateways. */ provider: string; + description: LocalizedString | undefined; + accountCreation: DeploymentRegion.AiAccountCreation | undefined; webUiUrl: string; apiBase: string; getToken: () => Promise; diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 7120e98bd..fd5959156 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -48,6 +48,9 @@ export type DeploymentRegion = { id: string; url: string; name: string | undefined; + provider: string; + description: LocalizedString | undefined; + accountCreation: DeploymentRegion.AiAccountCreation | undefined; oauthProvider: string; oidcParams: OidcParams_Partial; }[]; @@ -111,6 +114,12 @@ export type DeploymentRegion = { | undefined; }; export namespace DeploymentRegion { + export type AiAccountCreation = { + title: LocalizedString | undefined; + description: LocalizedString | undefined; + buttonLabel: LocalizedString | undefined; + }; + export type S3Profile = { url: string; pathStyleAccess: boolean; diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index f1307c4cb..2bda8c479 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -215,6 +215,7 @@ assert>(); type AiProvider = { id: string; + isDefault: boolean; apiKey: string; apiBase: string; name: string; diff --git a/web/src/core/usecases/ai/selectors.ts b/web/src/core/usecases/ai/selectors.ts index 80654dc0f..62040b90c 100644 --- a/web/src/core/usecases/ai/selectors.ts +++ b/web/src/core/usecases/ai/selectors.ts @@ -35,6 +35,8 @@ const main = createSelector( .map(p => ({ ...toCommonView(p), webUiUrl: p.webUiUrl, + description: p.description, + accountCreation: p.accountCreation, auth: p.auth })), customProviders: (providers ?? []) @@ -70,6 +72,7 @@ const aiOnyxiaContext = createSelector( return { id: provider.id, + isDefault: provider.id === activeProviderId, name: provider.name, provider: provider.provider, apiBase: provider.apiBase, diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts index dd1688fde..652f2b00f 100644 --- a/web/src/core/usecases/ai/state.ts +++ b/web/src/core/usecases/ai/state.ts @@ -1,4 +1,5 @@ import { createUsecaseActions } from "clean-architecture"; +import type { Ai } from "core/ports/Ai"; import { assert } from "tsafe"; import { id } from "tsafe/id"; @@ -40,6 +41,8 @@ export declare namespace State { export type Region = Common & { kind: "region"; webUiUrl: string; + description: Ai["description"]; + accountCreation: Ai["accountCreation"]; auth: | { stateDescription: "no account" } | { stateDescription: "error" } @@ -154,6 +157,8 @@ export const { reducer, actions } = createUsecaseActions({ provider: string; apiBase: string; apiKey: string; + models: State.AiModel[]; + selectedModelId: string; }; } ) => { @@ -166,8 +171,11 @@ export const { reducer, actions } = createUsecaseActions({ provider.provider = payload.provider; provider.apiBase = payload.apiBase; provider.apiKey = payload.apiKey; - // Credentials changed → the previous models list no longer applies. - provider.models = { stateDescription: "fetching" }; + provider.models = { + stateDescription: "loaded", + availableModels: payload.models + }; + provider.selectedModelId = payload.selectedModelId; }, deleteCustomProvider: ( state, diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts index 2267e89f7..9513f1aa8 100644 --- a/web/src/core/usecases/ai/thunks.ts +++ b/web/src/core/usecases/ai/thunks.ts @@ -95,9 +95,25 @@ export const thunks = { // The add/edit form (values, validation, connection-test result, open state) is // owned by the UI. The core only exposes the resulting operations on the state. addCustomProvider: - (params: { name: string; provider: string; apiBase: string; apiKey: string }) => + (params: { + name: string; + provider: string; + apiBase: string; + apiKey: string; + models: State.AiModel[]; + selectedModelId: string; + doSetAsDefault: boolean; + }) => async (...args) => { - const { name, provider, apiBase, apiKey } = params; + const { + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + } = params; const [dispatch] = args; const providerId = crypto.randomUUID(); @@ -111,14 +127,17 @@ export const thunks = { provider, apiBase, apiKey, - models: { stateDescription: "fetching" }, - selectedModelId: undefined + models: { stateDescription: "loaded", availableModels: models }, + selectedModelId } }) ); + if (doSetAsDefault) { + dispatch(actions.activeProviderChanged({ activeProviderId: providerId })); + } + await dispatch(privateThunks.persistConfig()); - await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); }, editCustomProvider: (params: { @@ -127,9 +146,21 @@ export const thunks = { provider: string; apiBase: string; apiKey: string; + models: State.AiModel[]; + selectedModelId: string; + doSetAsDefault: boolean; }) => async (...args) => { - const { providerId, name, provider, apiBase, apiKey } = params; + const { + providerId, + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + } = params; const [dispatch] = args; dispatch( @@ -138,21 +169,26 @@ export const thunks = { name: name, provider, apiBase, - apiKey + apiKey, + models, + selectedModelId }) ); + if (doSetAsDefault) { + dispatch(actions.activeProviderChanged({ activeProviderId: providerId })); + } + await dispatch(privateThunks.persistConfig()); - await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); }, // Command-query thunk: the connection-test result is purely UI-local (it never // touches the persisted state), so returning it here is intentional. testCustomProviderConnection: (params: { apiBase: string; apiKey: string }) => - async (): Promise<{ modelCount: number }> => { + async (): Promise<{ models: State.AiModel[] }> => { const { apiBase, apiKey } = params; const models = await fetchAiModels({ apiBase, token: apiKey }); - return { modelCount: models.length }; + return { models }; } } satisfies Thunks; @@ -218,6 +254,8 @@ const privateThunks = { id: aiProvider.id, name: aiProvider.name, provider: aiProvider.provider, + description: aiProvider.description, + accountCreation: aiProvider.accountCreation, webUiUrl: aiProvider.webUiUrl, apiBase: aiProvider.apiBase, auth: getTokenResultToAuth(tokenResult), diff --git a/web/src/ui/assets/img/openWebUiIcon.png b/web/src/ui/assets/img/openWebUiIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..7ce77bad8cd098490eedb8f03fe3f4cd5760fc31 GIT binary patch literal 23275 zcmYIwcRZE<`~Q7T2M5KmlkAy23Q?S7j})?3L}X`XoxNJIg0D~8 zShDLxJuhT=kVZJ-a?1`*ZRTZPlBqMY`6?k1-duBo$*}+oXu@>R9FZBXF(INK#Lso z*Whm&jRb~9L)YVHT_q}Mt%i9zyJ*w2w+)WGYK=z)!_g320i29O-VYNkM(j~{k)uu@ z=62nCW~@#9D-GizZ4+!BzSyso(Kn}G&4oguaS&VwBEdAjf5nMAU2ssSPU!7?mUp_L zyhXg|6>O0CM{Nmlb6|@YiC~H4gz-WKfo%GZiEXHA&pye@@7(VRQrM^a{oOtEHli&2 z+K8&~y&cjXA+^pWITQ+sh@-&4VTlsXTZ?Kg)%{a#qN*{1m_UQHQ^T&%#-XBtvm=9N-^ZGeQs z!nnXzdfNASeDdF~f5pib$V<0!pA*4Z12e}jOosbwWg3bMafG8;C=mxU5%S_n6Dk6; zF?7G>%t+6dGm(f^s_tuH84*u|r|L4}6)2D|5+E8(x3$9u#y+?y>5;blaoF-LUj)38 zGKrhqM!~!&N0-Eg5rtItLf(fVHBA-A<3r$6Iz6?SBN<&|K3I&G;uLq-l4Z}nnp@Tr z5h@AN;BY6$NoIr~E^}6skO+_-9bfL@UGWiG=lyh)Bp@>d1X$}9;s~lZ2 z6jEOS-1OlvRzwb$w&)};L~FHEi$-;T5~Q6 z)}rjihna?(5|qF)u*HakU%9%7(ue5gODrfJUT~5^liI8tF2W|@L0Ut$+725EgZ75Y zwyoXC+>eOXV18YV!a#^sHeAHt)N7wEq+9S@^dj2!CvtPm>{Bq zWLT=ILYGkP2w)DOXfC>*CPK>n-OM_=e4|FU&?|5hj~bfvoJip-ThXUgfliiKMzH5$ z7d9OOc-3wJ?a>H_K_UMC5R934(LydJbb_;$wn-+C{H~S`)DRnSc8D9@FlF~nw@j>@|3xJ<3m?3SrqCbD zkFw&W1F{&KR#}<%?nOwvbBToNWYQ8v$eEXHrsD3T|}C{YEp$#f)yf=de*Tw2TI? z+D*#tbC>ay_hpNcJsp|K^29)%Ha{aqAIiH6o}+v2%G+L|L?RJvXmsQfVp=7p!t{Y3VV%Nu2rH12Lv;7D0~pTBKqcUZ4e*}2pc{R{4V362WA1!H8! zjl+%P>!`;C!(SvpCJ<}q80DbUuZ6x$lOG$rOmYyMi6V((%b4rvjnWoHR4`%@UL>r2 zOVm~(Y=P_n&cuCPjyeY8&TrfBJj)GD7PBuQC)*QvC$w5AIAUjD9&>Nlp%P2gCYH z+2s#)54l1NZI+eNKZDLj_;Y?a)pbi=EKmQb{uqnG;gE>x>@e~8Att)p!+rW;7vaEB zp2CA>2R7sblo?-0mjT6@`;cXoZ(Og;_dC7bmbK7mf<)K*z&~@U<|As>{rVdhiC{mW zgFC~=*WF&!MR)osy>6KFscl5UkdL`=meHnEeh*4@=cis2ML_~k*_^VqPnAlno;>|* zVloXYF!2DSuh6elZmEHcS>+~D_(k|mV5Y2#jn$gr0|!UDRn52$cqsr2+~s^thUyoc zbA2FAM~N&W1*ksBnD@IpgGZ=%l>hx@T^>(VaSdv;SK~x<)Co|#?K9QZayGH z>egE}6I4N~EiboqOXEqU8ZrcY+Ty3L!copq_39!AZavou=$SlxY+*%0FOS1j%)+$} zMiV9U5isQJJ@ga)r3z@RQ=Xr#o*(iNz592@ER#G;x4fC3ALMP8`@-0#ls^nIN78ar zNF3-C;9O`rbUpq3xRdqm!%vGY9!Wto_(&D5X#S$nH+r!*GYj2lfX|vVA2SJ;*}jld zJ3Q>y2?e{sU~VI5L5k(Xji)K2fw3WxzAD9Zt=VLIcGP+FAIN|V>-FGLZAvb9#XD!* z8sYfu4TB@y5*ePYo6c$1Hn|hjPJuc9s=0-MU8AVCsmh>HUbogMo`W5n5P{N7xqhnovtX41p;lv z5@1Cr=XHAK3|??5lYyi)3-Pt#eAFZvm+H(Crilr`LJ)k3xIJN%JG@hsIkLJEWloD0 zEERouTR+g3RdqV!KZ`~_R>MUhPK-lO!YvNAiO zlR9nYQO&FH@Ib!v)w7^m-Z4C4*S4Ru#N*G|=EThJd9RFlNm2$S7z1bB_^A7*K2o?0c`7pl}uQI!_ataB0-nf_R{5qp{?>SlFZA}9|Y6!za6!YbrNkJEXqC1w6ygr-k?~foJ=4=UKBxG1PYe)#P8%V46lHkMLx?Q4ol(G0NHsaK@ZVF%zN$AQ z?lEzD_A{217?nkW*8U}ywlc}a@%$yje{vPTR#K)FP!u(e+N?zi@RmTINYCzo)a_CG zQl9{?eL{GCW2mPByU1-}-XMrM@#1X*YJ8jprtyKjC~?f5LrT&FCp;D*M=(Y5E2l1) zwPX_p9u}A-Yqy4$0nbsCP%go1<)LP1kx-UhfxRb{s#{vV7=K2CvU$9u`-jEBM{f80 zNL&eUk5qns)l&N3lcA##^J)hLnQgAVoncdt7!f%p429uk&)DdMK~@5n+jHP9G@H>~ zvhsY+lNQ%rrM3fq05Uh;UhX5Y-{z)UGc#x?!=k!EzL4TFF#bx{BWs5b$R1IF-D8WW zLc@ZdMAR~JDgDq@E{kuc=QiY4Pktfv?|PIZ*4^U!yG-p1<&EqY8gyXYppFT^<9ht+ z5Uh~cW;!5r(YuhHjICBU3g)E>?UBIQYym_8ah3zQ2*0BtKS>@`{wNnpy_USyCHxpE z^PfNbMpT8m_bFuSJ6O^F41Pz7PITz^OJ#UGEp+Ff9YXM6N__zeH|pQ+@KlCc zGwQ5O&pnz(XS~WgDY^yb%qZQfXvS^-5S(>nCWf>9Z&$L!rLP z1s{abyC4aOMsQL6FA9}|0+qz221R^5M@30+SMuex~3Vifu7C)pq` z)X{h?Z9CiO^#3+xgj`Jc&L|&H)Pz&_s1U!jN#PROAgf*QjQQW5PPmGyIdb@VrPu|d zU`GEm z+Z#d5aiclOxqQJE@xPo>M%nGS%x!<8pwvXL6uK%v7{I$6n4dNL?Bc(}J)F)76=!7~ z>!YSQ11PU}`TZ~SY-RW?3gpmA(JhEMRnU7=?bwmK76fgew73oKlHUEFeugP>IKMB4 z8&;!ZI|Lvi;)*YsWqMkT^8aUh*m{r3XCHFbjV#lK3}HF^AKY9COw9go-xaD;0Ty0# zJpyGn%~uLnQV{*m%f2@-{pZcV=C7z7WXR`_iVyvyg%~%pZ+yg8&;RaR?+#~Z#9okJ z7@iG{T7kxdFgzy?-fBez|29p+iW3`Izs3=0am&t#6z$})UDxFNT9OqX!y!0~YLR`( zNc-oo4lIY)+^sX98Y31u`@e%tEW#RWYdlYzFz6IQ*x3VIs`ka1#{cAqD#D=IzdZVy zbI*m`*xnXq!^QD2Vz~j&PT*H&$wLf;TUObbPo)qH_rf=0>m?=gDx=K)vq&r@{P%20 z^x_mQY^WQKJ7uMkcSwDOayyHa`FHt?n0j#+6}qaFs~_ydsjH-JG!RM$wiES+;GGP+ zi0U5LCkphCSPa~M40E>cMk)ViGb~eLSkt(PEtzZsu1c0_$n9G|+Ftf3C)@w-nnTh3 z&`CP%X(DEW@-b;W=Zrvb-G;%vz<-C$iLrYY%>;LbDRoFOg1ge%rO)q2#|!>X#BliE zMnV(FsbsPY@f+?OC8;{S;prI9^zZPHA};H4zbx{7pbacXAi9(Gw*ck;1*?AI6CTKo zd(7QG5SdAUPCUul+!sq!`Oop}xo~O-vz2nJe>t=&NaoM|DIfZu03moCN}ruMUqSDX zpmBp1bIuq2;NbX8^@j`p^j3h%;R`Wc$o}GA23@CauBFR@Pdy=vr~S8|3OTd{$y3f{ zH9ky+03;EvO??K3{m(r(SaB(cLvh;rLRt*rOuz91USI_O7r0rbM)jdOL&y=ObnQfY zRy^^4hQNt% zihq}~s3Vpm#OUZo-&vGHI#fh%vjKe;(sC#NQwWNpySOBOalqnGXO$Kc(dbNaZZXHS z^6;vn zTp!*yx1$`MJU%&^{1wRFOx&NV-F*FNe@lO%*<}A`y{`UG58HHdwK)B2+61jux{b;m zZ>$XDi!n$zUS|-u7yhi9FUqG^aJ?;2NJ92>C;ktIuq+)s(fCC+_oSA5qaQ8-yc+P5hqQsYtYdwoQ!Jj7NmH1ISf-r%x{ZS{`zKdKA0FWkwjb9_a=8k{>w;39GiUIHZEDxC6iv(zlL7I(YU7g z{{0WPu3iSn8~cvjuAw~K@}uS2^?D&h+`TJArxWWseHIpA_VXVpd$o(UVk{keh3zeKQ@$50(bU zimf8(MJ!%C2T$E0y-{PCz;6(DNwDES9`JOUxc6_`m0k9E3XS)Q;oWy4j|TXDgc8A* zkgJso>GNZ*T~T%mooRgpQH|dmM$4M2iA|r+(fr*Tbz(3i4D9mYg}yJ`Q5KNAe{z&T zSNq%m=B0kIs+aVA=mq#Mq)xu!@12#wE@R)Vrum9L(PAUbF*+PYwtXMHBIM6SZdHZi7)FFZ8uBNg>m{MmSuO<^Egr-}w%W^Rv?qt3(s) zx7Tg8j%b_dk&DznnwfmsZ|#q{TJJ9Zrk8UaRRasvd{8mYLN3;4SZ5CK^FOP+{9ab< z{$;N{va4?0cfLqWc(HT&jc>ElUVBl0w7X(SE_>pvK5%oBKj0uh^W2Y{1|wJ8p7`X~ zkDcc>A{h~l;&zYo^c(0%FW!1iw> zJ%}g;c7d>wj`3OZGf)03()zTTXWd%)_2o2aCZTJltAt*;m(kN^%7XGI!|XA)dLx-` zk6fe|ebF$P?Rh6#HT_xZwfWD5wW1e@$gUr6)YWNTi@yj81sF`gRfzX`x*VzF_>x7f zO`qJQ>BB_|o56y#%9Vm@^~qYl_X{;vOea)Y%m_^sB_Y;2m<4?FW)77V)eo5o12TWX=XsqYkljmas zPTu!iO9V0B{01zI*x8BRCt zv**O~CpFJJR=*JVl@D$3)5_KR!3o}c4|ap~(|U~PR-_zX+`vKx+LLyneT$lyKfn<>5~o#k-cB>da9#_NR! z4fOF*Vb$`dgOg_l9YSNBiY?OP4lcbPR1^p8@Z5tPa)&}~du0FCBuRZC=bbcoC>nPW z8*+)^?VTXPWF@BOjjLr0x8<#-UQEgFTXYk=VDg%RY3Cb$MR~m-MLDOVX||^Fd}~_D z;m#g1VO#1I#8;oSuv#~~Al@RdZdkJ|{nkgDZddqi!1wHM;p4;4x;-J-eN<5p#)$@E zbn!}Jl(7sJKory-Eg$&r2pEVhlq1O^9s@N`%-QYTKw)l}<7 zM&L;#dW`+S3eqNE^GSxEwP4jE+lu`UmUHB4)k?v9*{EYg7qxlW%YZQzV)z_#)>wSO z&}#5ih?+b-q#y0uf6qO9@+GssGP85Ps7z3uWpnh6>~!x>hg;3eVlOV9JJ7;O0+@{E zr)j@rhb?}xUu*qJfKIv0b5fGU(Cb`v&hKgdXonelQ9z&0|zB2C(KgobwOg{jC zjvOw$z7>lHVcxOh7!GT82aNKT>8et9Cs-MoMeT=#`@Z>jZm@GJg8R_G zOsIKQ2&A6nLRZ--236ni4*t-<1)QI4ozA#B1R&tUs2q@YYrZZfS?+wk0HBL63(jii z)$qr7H3F9G+F<)jEUBjm|NZL8WPG$+v~dF6BUFw-iM8+BDVJu(#5T(pAn|W=J8PB2 z%x_0^!l#C{E6mf7deKu2-WyJ*=)`g^yzY-o~ z(Lbycs+fY4oab2AHv4n)!hpytnnkD>L46rrKk=;AtkORA>2yOLGV^9 z33#zxGgR=@vp$yfu6lDcV?-MD)?LtLE9NZ$=XPW1-VNqH=}Mp99W;};co1>_c?ko@*T+;js_30~hA0-W4L;sOV1R9Ean+ zq}K+;L-piS4`^YmNVqxX%&UwK8cRt1GE$_omFZ%CpjEz*e ze>Nz79A;2z^U9#|S(d@qr*91E{J#y?JTDn8dUW^JJ0P9r?0d^s4y0nW4wvgyAL?qU zXVA5(W~VPx_X`-g8M%T|Zxe$_n7*sk6rT%s*7-zKxh@V9h&A<^CMX#+^{HG7Z62_@ zBkR7bBj$hNC1%^pA!gM|E#|XlUt<0p{(gU(f^li$(?h<#G6$pgef-ky%a5x()}FeJ z8uJoLhb{AB&kveq+chM{OaHPd0ZM@t(VH;6^b_iTrQ?1UkR_+JU?5H0y>YHqYja*g zyO-g+t>Sw;B2;_OU^AJ|+zVjRf$@RqH*flJekDGl2Z?K=<;@1Zz|A|~n_tBIHMmSj zw4)ahJ!h2Ip6o^gjh}E^_1!6MshB6k?yZfc0FkyX_Sf-*i;o5XoMk;#kKM|$O%yV7 zBp^%+94>b9bGW~svb_&~!H)j84 z!W8;$+Jfcu^b0W+S7Rp6_KJ*06OCTuT3yCjmU{9$Aknwop!8DbVSt=3Ps+=HS;I zv%{`{pO3GY+DuLYc;A@rf3%dOp2$a?xZ7p767~7KtrU8FO7`pq^IZ$id+d=yOe#s2pFcbz=_Am{%sfg@=+s2k*jNeGT?jOyA_)5kb1DFUsYUy#h77PcU z+THhpZJDrjSqo^YDRZAM6;)f*{&Fixvaf!6n&?+c7|kFd2&#h-DvE=99|Q}>G0w(lvdcSDjckl*Sa^V#^OICuk@viQ0lA8PF#r^yi~9L~zcjd7RY z{lBKGY|tMc!sDBoZ?qv76SLKi#i}N}lOFFZ_H2E3-fojnccfX3SYO+mi4gj!N6#-Q z8o|~*mk3b$U`V)aB7DnoKrESRrk#)qobBAI~wDm+@uXV%{^2Ve_~kvhc#; zj8&OIUdq_qz^#P1rU*K{nx!e4ah;_BBPaLnXX+ss8U*j_wO_oDNuDqbI|-Zf(NcDE zN+4-?W86z3a;B?h3;`h8CFsva(~I71z#|+*#olU5w<~I0(i^wf`JFn0r-I1CUkA$eqz9ZGegqicj6;zZg(Sy{THT_- zY(BOhi)V1G_--4M?lnW~+NU9Lb1i7?ryb|iYDt&)kC)`-eh~6?JmEz1m2fUA`f)-Y{JUmV1n8Jig|z6lGc)(!4?Ahks*Jyg zYiJ)F?-Le)pwpr#XT8*$b;h~;NVQ683KW_vk0Rv>u-#PMH%q1c4>lxYBc!A@tGlb2 zwTRq1wiF_0lf8GBZ!Wx}MOU?S1rm^^mfXHb$n4Tcv2`tQByas~B(vK>Nxw5=W1rUtY>MP$gUcjHGA!xu~hbt{5o{5Rd3+xzj%P9W50# zG!ema+;;(^GIfT|vJ^bKqLCtgqpB0DAxDSNsKg3@vXx6DV8GQ7QhngEdw)T`nhVdz z(3!YV1lK9hX9vMe+T%gdngU3dVYav=IP5d=stt&S=I%09Pg3{ZRdWR#{9tY$t8_IH zlit}|9)V(H&njH~MH%WV`F~7JAy;!Cjw)!!^_xx9ls(&=FLIG3r?Bo!Dnsdlk0Q{- z{B{1n4EnSmG%D_;Nm;kPzGhwcZBLb<^J7YixPvH2MB*dGk0ZJX%+>ChE8T=2X=*7I zavDM&iIyY*oHI$O?gIw*4EPx;FPju%8YU;~jjBEJT}$Af^a`2G`sz(T=@WwLr=M1B z`SXo15=GPXbUmZ83pKm|TwvW#I;`D7dp=TXRZ2WpokT_&vw}@A#f~VB(@pl>F7U?0IW6 z_loli83APt_p6}17Q#s7duXK&GL@4jeel?;aliH4>Gcw4*Q+-^ZhG?kaC>vxf!JBW zZ8b1j05mDEkqAQ@BrimNbC_Z31=YQx!HQ=IpxqyEempr>@a(BH%p~Vm)+xR5QAW}2 zjW0s|h#A=y%I=DLCh-g<79c?B-z422;wEld z@F4=(?tg?AJ-M$N>*$ZD^4xHAn=K*D)GlH2+j&5z-t?0Lv0n4HlS6Nib&vlD4S0oISiqLm@nLx#l^FW?_)Z7Q!ioD{q_t>ymy~6sDBT+g;Yi(HRHT=}dG=zX>;X4GgQmOZK*-Tb#-eQ=4{5^jt4#q0+1wi9o9M%GV#59*j{ zibyLO$@(I44{B-=rtEGy=d~k1+rh(jp@v19Mc-w>n2DX9kDSC*XrlF;0WUfOEeRmp_VlDe@!L)1-c@zrB7f zBKAAc5`YDpismrHDp$8MAGC8mnx;aN*CzSfVO(E^#>)W%#Zw z3`rTlWV>Q5B}H2AL4Whvhn|l6vCb3+fIUk~x`cc4SWjGpWRM|Hf{Y@x9O`#(nQBS- zBn?%uNS%@sGs8>z76M7hA?!48oC*#o+SL;o5rp`zWs7DwcjwqQDCr4BFb?Ed1?m@$$(erqig_T|`2rhm*EPdyDVj8(Y! zHfyh};kPJ{5yW8X7v9+a2)XpvyR+-1Zh^5aAd&w(iTgkUVK-`z1u;6VYQ?(whMlW# zlT)6&2*Ge8W=x>px5L7f!|&nz{f^5Wk|dl=dv3;}HxYm{wJ5V6rmSJ5=O6O(9N)VN z>hw5Ns2;@2UHP4p^dGS<_qp}$&JS|g*Wc<5c!=#MYsdLbs`|YrjSn*Z3F8ggiV>kq zQ2%Px{Sh>^J=ezgE-X2i@&K?Iq*0lRU3LJT2ReK*ugS1Ta^IE?8Hcxb;hExga z-_^K1>ZbWy!07#sD{6>t!TXPWRO)E7RE^`f)QZ59HTRX07O@K^Lk?ZRUrBDYq1Xb^ zj))lnNC$crM5}K@=P_(azE^KbZbmh*$|QX$dgm3X zT49+@4_ohZYMO=pAxrW<4UsW4e7f?SYMWGGgm)+>2$GmTqMi<7ooYxl2qL@`N$U6G z`>eT_mcw|}*WFH=zp_D4eizrpi_z#Buxv~56lCybyV$BNubBId$nko$(FHP#nV4&m@yz52_P5wX~SuD?JgSx=Fflq>|J+xFU;X*#TGc14B4v0emV zmh@$@_jE(>fYIm|OC{i-^$ke3#qM`SNMA4So=gRq4|0?!=LFz81y|5~J9Du1{LE+N_8NvAjf8WG)$lI1Hp$h2JB7O`Pn@*^bVv2TPe@=tLRpa zB_b@SpO0Uuf46N51-z&w8F_TP% zC5t^FjOA64tEY#5&V?l7_3AE5`5n1|bN7ZU|DDT@J3Vh^^fY!PCqQ}s`56@=0>Loc zDv8LvcfjU7!tzH5rW$6rj`(_l$7im#oz-{zE?}ff@JW|)|1@0d=QC9!41tMNeFcL0 znK2dX5s(u)0NociUc7Ah_33Z*jfrph#l)5*+}-6)lMU=!tRX7#T!*nFD(HG!_?95% z;k|0qoj=_7QWswg;yW%>9u|-H5XQgy@b#rnHj)@?LmBD`JGrbeIlk+Cai~P&U$>`&~wWv>|Gp*;Lr2Jn*N)ZW9>FRsCtLU_v0WHhNeXCCd0(_)vgKlKI z`cwm{Q2W)EK-7qa_@f2{DgSo_QeVS7Sr;lL!1RdfE@&J4DJ4ZK-K=IwH1*CUwYIP% zgg^YGBSL5Fd?)0-$5{HU=f%TCSb=-1Etm3&Ue}9%u?yCP%EAju`<2^O<)#2c5-%u) zFM30Qt5*{dVu`90l5?-M+nIb;A1sG|dkNH1HYmv-gkeM^M9`0x!f`V{WlHLXTFqED z4;M0|zu#Gi!ef1W4$mb%Ioq$(^y@2@W><W&Hk_$0ZF9UC0gWMSPHN`cMNY(P1`K%XcDWWqOt*cC0U ziLoM(08Bb)n(87u!xco7Q^ zpR$B3TngF!#u3`x1jKWM9%B1%LUtgVb3Kgcvflg|ALy?g@$l$;AcZ9&+x0ASp^Dd5 zbM~iQ0cYCl;oq{sek1kZt6x!VaTFDR?A6FpeFF?-`>onJl`gz7CkGXJzBrlHXk;?! zdSB-jc|=B)&~YTaSgOkt(6I!yl=yrzvw=@*usT5VDWJ`m+Jj9MY($T2f8`pp|Nx3g;^jW|a1DYn|u0;?sF^K~ij6_LXeG zsSwBNJQW;<0vF(x&N&R(p}We(5WMR4XS=ra<%=Nz55}z-lD6zy$QYJ8n!JC%lybvP zxBc<0C?le#r+?2u{c$_0Jho*K&hvt)ix$jYg;f{)%vgkX|Jm3fpP|KqLg{Dw+Z%1y z-s5{~u5)knq^w+DM}GSgEuG4Ha>I#`oXP&-G;`KIfms^6F!v*uC6fBpMg^I1w$0_w zg;iEX`aTP3o^OFiNybm#izO1>7pGe5}uvh4lv7|I@jTl@82ng9tghS^AW;Hx3b({~PcYW4C;9O2f>E19d&p8`Bv`T^wBws@O1K-X29e7Q zsa7Wwm|K;ZzZU5rXpkd#P(@&#p6b*e5$c0*yV6f8R5O)6ZQ=4>F{&M)10X2wk_gGgR0St&)bbunwK38?DxcCVVy4bZ#Q46ioc3q=3^^svLo=^Kq%R8Pb~V5lW%`Obr~o0!eE_XVa6 zG|($jeUj~&Wqd0Rx7&oo2uhC5E=i1;yWNm#^nn{5)NTD_*czbSu-q3?G`TwiIKxe$ zJ6eHgN#yHX=(T5VzH#>%tp5=qs5aiXW5*Ift5lY2999#-^ev)yl{uc%H7!7-%yIn6 zDDbZQ0HMdUnBBa?f*50i?2=P0RnE%yDR6`HuHdNLzsjT?VN~3P=X3E<*kVx=bwa{z z8QKHcYZuNeK*`@iSh@QMPw;Y25b;8}b?*<($X;|vO`Cokt!ob(8{?8Ppms^Z_y*zI zzLKlo@AU~)EoL5=e+BS&qo(D-J%nMY%_Euf6W2Zvc2mhsG=BjaB&(5#xHp`Oksp%b3yieIKIL@RGw7WWH(nP>R5mA-t4v_3g6Jn)@tM7+DaM52ed0V#$sNmmNhyiR#Ad4Bf9t*Nvq z%n<_J%E=@Kry3ErHz~f`s{0Q>n}%pcrR&OTvFWsU|0DMZEb9uo-y-=pU8eUK@*QvioYJX#Tj)Qsk@avDDiX$tXS0@)m}t z^yZvi?ip8mx<$V1Jg^xqw&FP*tM#j{T>kVq^4>WZ&coIa&+2?!No8GlIyRIl)#Wkn zKA6-_P<}RO`i^k9umB8sh<(gwdgR9p!Z)K2XaQSRv6&7jR28OnC>cUp0nEX>U~H*w zk7~4-)`4DMV0l%RxlFHJdiYSF3`}PfCDOZ`4-}X%RD(GI3!nAMCpHt)z^7$D^0A+t z{Ox+x@&mBB&29vD_0AJ>A)OOI=L<2G8!2^ue7crs^vf#8*kdn)d^CPx-0W;SQJI>F znA>XIOI@+4xypTIezrPKKmr6NUwaUcMQq75D`>(r8AqqK&qZi3vy*oO7joa1ohz+& z8k}9zlsqL5J*D}pIruw~2G0Hf^V(C3h}6`zb58|${TB2ab^wJE$IOtld*ZH5e(?3algrC}APm!V#5{a-(mkp?0 z8v63y;G3Zj>o`p=lyv3BQ~fox&ZNj^u_m8C(eXTA73u;%9EbFb>* zz38}0etWitIamt!zMWe=$=vO?{IGyf?E)r}ndkaQUiu)XcZ}o=vajTcW*^{)`oPdq zUOK5OOGk`l^v$0anPtYFO8`Ke5&X#6JVNq;nU3dSKFbm>bmf4r$B`y|Ch#w!bY9k` zo5i*DTDIgyL?W3D49r)RdS#h!&;C-R(*mF)p~oys^gyui1D=(eY_X|3-4h!*>V0>Y zh~1JhGIG9)uU1&rZPcMWxekmzymNjIGd694DbwehAv)x);|_4q zhk~ZvOCDd8z2Sx;`o)h`YO2kF%_c(!Di*%2Uwo723H=4hojiwq>@_u~ z+Jx$^51Iw|9ihj9(GH@S6!11MZ5ZXk*|acxhcRp=zO3D86^{}e&^dZi{l zE8ftQ$sLttL2YK#gA{`bzY}m4&#r*{z$t9H{xMAzv_f%T0^-Dp9YX@^@xf5+X@AcB zgFvcvlozm~Y=aeMLmF1P^Lx5c<(G8@a!Qgqdv<}b>}8j5z1L`1$X^A zj=LHJ`F&Ss`$fE1a~_hJ5L%tQ&i;t*D^xo331_r@NWj-aJ>Afa5t9kdbDDRXT9UTvNYwHa66`i3vc z-jUi&|5uwA7}i_B=zUBuQohoem*%;p~4vQ6@X4`BR>q-KBwm4OEMe%Pvr_#+0K&$(f zaGFtZi6G_U>v8tupsO#Ci+tIq5GYu8@~r9t+X>n1$`OF89V*;u)RX=5rCp@1<5$qHk&g+#6Z$TY2fuDyAocQys0O1y%v3D2%NXkl?E8&~! z7cMG@SIxgzx0+DC{yAUG)fU!$jo_}yd+fIf3NKKI{D~kCQRtTb_$bO2)adw5T)6!- z^MG%Y&R{c60&|5B^hIT-Fsp7`d|C#4fY z!i@rbC&9B4-o*ZAK0_%0?6+&&MgaNeoNlIsl!jx68lXH+VRB5qKQw|2Jha>D^s= zW@DMIWP;Ir7F4zyEQzC#Yj_9A}9FgsPbEm;tfwO4X}a=U)j z=pH&Tq!utNfBH`5yE2?Aza|l3l~G>O%tC*c;N5O+@32joZ*~z_^mF(Kr}L!Weln>0 z-_$i#{y@0#O)lFI`hYJGP{Dr}K!i~35EwkrO^Ld>YCHY0kKn%KU5l1*3|Y;v_VtCz z;lna$F&yJ|Wc5a(=i&zwc$&1QIKU}kTo{!1=}!iyBrr5^|KO#tlglq#c$JY;XK4&aD{M znm}(&H6Adcax##JUdkbmpl13AVs7sZl}=aKlEcU#f%A(G{mO%PX2ICY>$H&)B zOj>W+M_tBxHcr-%1kC9ugAE6$fDN}K;2Yi+_}QBEZ~LaSQNL*4#!Lft)$(lfrEC%y zv3)!JTQnbVGCd_>JIwyE_o*NDlk_c!REBU_sZsKum=VALb zTX-USkyRV*-`RN0w*VL?F$eUE)GEINuOsrjX6bsuQgna4oN_#Y)wp#uxD2vsDEtta z#(+}3izXsYe?FhQmGFSg6c_i^&T>;|k03ge0Z`{+& z^*aGw(lzam)J)ezz^wNjithRIz^2cy^r|L4ucP#{%_9JZHiyK|P48?>P8xd*Tm1u$ z>(Aas*Mk}FKPy7F&QJbYk5{=b1wQMt#J?GPnWu*nnU`?kl#SoZ{RSAYeLBRRska1! zV=YiQ#+Ai4mp;U|gU-s+dteUv{erRr+;FqzbZ->Qzod&~1e`r@f{eF&hZ zTrK+1UQq3QqqIp==$WP+zAWt1l#Pm+4*(@jJD|~BV_tBFD}uM>db$;F&P>mo^q9gn ztKiP*m-mYQO+`P$4@bTC+Q)cMEY@DA-l+8{c%aYde|B>CZ?O8a%_^7o>3AoISUx-PGnN z*cBTe(rL<#;lD^B%J*4|>bP%V@M4$g@TxljdhMusQNWa5`4&j2=(YrbKbC~}I3?x2 zOkK!=pRzXPdZlMvC5#_+adI;~YYe0603}67h0C9Iz!PtW$Gi;W1}{t=+(IT3l!wb+ zsM*QA42&@Bh8}q3DTI;^5w3s(LfyI)?iSBBUZ&4S{K-({e!f9p3S%Lt#O6%i0Ebj6>&@9&yP$WPbeB}mUsk!f? zzNO63(&oN~oH;t=`j#WdSBWj;m>fmQk+sTIltN6gijcxWiR6gvOF}5g@q2GmKmYoC zK6}4k@7L?~e!Y+9ql}l-`Q+WxX!Z&WoDPS{@_wJhja;(Qu8J)$KLttLcL0Z8uWt^r zq8x&<8$1)sRG5x zkF^zkP!ww+@KgGDsqKLiM<0|{B9l342=GMaR&oI9u*>@3^}%EB6SYqW zkz``LBTpc?5>&8m3WI;mTmYiKJlmZfklpL*G`e5tX`|nAG-O*U!$hDaqts7mBipX# zsum>gtrU~LL*=DD_tzn?0D%t*Q55IppqDMDpxwIzDV^dK_f)_n$Q2(22;9wX&>~Ri z6@FD7klyDJG*J*=zvUXHFzmOc(!?hB5i=oDv7<|PrN7Z{9Up`|4u9J=;Y_=8dHDO+ zav)0ytiUebk2)dF7V&{EV6x%i-w!i|;*=&`z}vT*)|+&G_OkEiafEv0LU_!trF#}D zeK2Z3z{Sr*klMIy0*Z8;OZU>myQbnVe&Ee__KOBg?50v07!9d%-*Ww{)|3Y?qe}|1 z6q2ZVheust^Sk#iGls%g9;5f#SBAic-=Ye&2Jm*sP5nu;fTsAyVDeOT^zf}q!D=(s z<}P{z>H~h0O6i+Fo~1OWG9QyY{E?ptqZ&I)7HSW;|9KrR@Jh1jhxpK!M;iyKA*C6E z_?og$PJf0&Oev&d*+>pC93Kd^_pbSN!#1j0e6wB1oiOp1;ZwAgoBdJqXQglf{C%U- zmfj=MTyr4hVD5Z;4$B&!FdC4&bzSC?^kS`(ZTLD-w`hZcOCHaTq~_*iQU%Ol+z&*YGi}vdr|S^m%vfM+C!$P`cs(g_?0hpk1 ze3X!3?IIz4Qx%nt>K|P2X@%J=p{4I5?n+)<)Ny_;@^;M<47nvHdAqf$9t35*1pj zeO~zukY3}QdYTsAfFjSeE_6`9P>#>|pN6d3D5&CcUrxLl?KKL$oWOnC)qI4%>OTl^ zX!u27)e|8M)b1T$opI
fYn9ANbK8B2*x4^H3a#bkBOzC6&>EU@VoPzvI)R&9ZA zmQjFBXa%g>=`qs{+EB8T3aWI@*W&~uPm zpWdICvp){Iqqv?<6G|+PL#ees)Z@IrR)nm{qTCDsL% zG><`ga}!t&Py6s0)`v&Lxw*j}LXK;Dm?1g$I49#m1+avhhaTWhY_r)+EnuE6oQ*rr zs*O>&zf^zU8It{|QfRKmKj=?l(g)NV_EgsV0cST$LgOgK!=sL|MjnOD~bMAe;POes3?>4 zH^Xnj-W`mV`Mwi!`|pz}1uQ3#p%#J7OqH#C-_&*`)s!~p(s~nEIxeDz+JoOa{X3eG zfKLzX;0AWX}IZW;1-auUW!?(I)HV#ry-7E=k3W?S7mdfsN- z+{|a>Hq>(5*w2H+MdNZw$m?k_S_Q#iYV-413Uhh?#(0iD)(+h_WixUp;dvZBBV|g6 zG|Y}Cc}rXze7la!`<+LK_aLmZ-Y((U{%1FPhgpOdrzQ;%6pS>|1Q|NB(HcLy_rUU2 z!(MuG;sBurEkwnZvL0uy z*YnZzy`wZ0!QO!`S1@N51j#k#sq6f~8g4coc1q=`j|yyly+ z#|iRgvgJ|O*+f>#fAOJcjyG${QxiA#(Cl1~UydlYhqe_cd0QY0xA&`@wm0EaMI>JM zx~86?4_VeZAJmWyUzmjR>Xh$o+WT&Bt$~5@C=Lz6ECz$Ps0FO`wJsLerV8Rtz2ED8emdsOr?9hW-JIM9vO?VD{D>D<-kR96$)}20Z-whA%Ax zEkl$jDuHFjRoY!K1Eh>{a>%P91v_?*YD;2teaqDD{~l;8x*%-R@Aa66P8so_HAIX3 z70MOFn!@sxn)PY%yDli=EMbdBO7NF^F5XmJ_n87-SP9iO0M z1y&7i+Y<|96-DC|2dJ6m|4MNT>j2OMQPq7B)-RDC+VUVg{SQoYc2X;)`#qfe5Zvi} zM2oD1^3)*;#%0twK<5z0Ep~sJSkNU7nIpJC7L2gY@)wa@dy&sdCIIuyts5XaHosKZUDldaAdRWXs{TGo?n3zXMUL(Dl=9i zGRRj_ZPBIX)?B{-}bsWuxoc8Bd9E{;`Hh@!i1yq%#rU=X?*NS|U&;$(yNH_A&OE0g{=m}U95^$v8 z@J6Qx;uEfWQ7ge^LEG3qgU}#vBp)jzsIBsJ9sy4jK$Y5qv%6gIOO7Q%Z+0mFv>6WL z1P>1_YQXDA4k6XMwnbbx|DrL?z|H!MMfMWxOUG7C!1wv4K;>E0MxEQ+)EZqb&9-iQ z;ArSq4I-{S+2$CwH1?b6 zcdM{&pbYS*2}4t8w;z7b2k+u%UJxe z%9idQ#e8I_QJV3|7P+Ed)%Ez8z=gs0 zv<|Dvc__$tRB8AX^ILu?Q>MmNlq^W-iWwj>tygJBS~@C`(fAqz>ai+ug;>yN2Y9&b zMlRu3YI;WYq+H~cosiL2hbs#AE57U}g?!fWI>4}l{jFG~fj3*ZrWznw1nxn$_-(@r z4#><^`)?t7UbtMjolF8@qc7I4eq&Z@t%g-la9#1fINTo^BA+j~%Yk2D%Hf%(P-Qpv-6I`2uu3cZgTw3}0k*XV{ zGBF8YALqt>v39i`ubDKg`TZ=_csMWHoFG2pS3gS4sFe3mmbMA#L+KEc>rd4_7`RvN zS1s=lQ~;uYYJnK7uih%<`%#$)F`p3Egk25Ucb<>eJuF@Cxu+IU2x1K*@Fy7=;?pv%jI4*8u>fi&uq7g(1I)rw0 zy8YZoJM9fmCl}5EeF#cg^VHf7=IrzTwtG#m*+bgZG_milp$prIe1fJ5P4l~C;Nk8? zkv}h{U1<3C+7@VD%)=WJ6}ksoJU#y&K{2D5VY0z;NGxo{YU2@&j*(7`V?B7Jo8%8w zP4n(As#F*8QpDo2eG}P6Fhgjf4QiBThiL)L*xX=d0{g$%zXT`Qhk>a zm;ps#)6HpSgkZF1P?Ck~5e@KM?dP<@$Udoum8jeDK7Q*yhr$IE1!HB0n$+pibB1UT zEnznZ{ctj#fXfxGD5`4%75<0;R4{fpD@o;mI%KrH17pbiN(v%GxD(?s!WbI_KARyp z@A2zL1i?;fA(driUpffLrAloJmQ*_HJ#8f5k0D*lZJ?6PKOn&{X>05E>sWw#RIcuj3F@!`8@HE<((l-9L! zVNr>BF-w`e(1;r8c<1s#(K(&eDP8BfxQlsV;%lfNSy#MUY&AI9SQD%NDt#>f3CM2! z`Fn{EtaD}Fo=lG5k(V`WJFAAw>=x^u7p2Q}J){LwP9X^xq%^eApXZ?6Gd0BS-08{G z@92|-^1``tV4+7i`lW+rN9l}g5BX=yUNB~(o!+@mdiL<9i$B3t7nRnc*xXQ(HuKl@ z((lu8>U2*DGU*WbVb>g4>C5LymAkK&3bP<4vgXM+h8~RmxkZ<#wKy#1sgsIhxPuYC z`&vfW>Hv;k=(<|DmFNy7cMAzJ&K_QC`Rq9%;^R7wne%_H>9aNocsLJWu|`0S1QXr) z;4p`9wvP%iAK|^xzMTM0`auC~+&wXTJ~j>y_cQU_b*KSRz02MMG|C13))K!VdCq&0 zhp0CuhDaU+t*k4Ib1f<$MG9?GF0bG@2Qak+3@9sR$H_Nn@r0VW4M2{<`Ll7ZHqK+_ zGO%CmPmF2UO#9X?x;Jqcj3k0ZeIiCHN?tb$FII(-y8+MvIdHe73beC;F-jY-p-P_b zqzNlZAjBkQI*KqWRi#oZ#|S@jbdZ_;>v$%Ub`!r}(zh3Mi)I0$c=v+rJxB3wL2@n9KN0un*r5uI)H*Ytnyag~OVj(`!Mx)} zr)zt`oPq&8o0Gv|jG&%b_N_>N0?tElaRI_qr?CmV$!d;xao)yQTXf&j55R)K2!LU= zpoR#*{zCYV4m(!|sm-Xi4xAxL6p=*U=F4FZIX!w~!!p$eG&(Z3IFxwG#^f0(rPYe8 zmiPMc>z}ZJku#J2x#IUZc}nN3))(=jp7EkKG2>Ps$~JP?K`iHCDZxO zYo9yp71XxpB(YSRo?oi{?=44}2&ser3F5~Z4}X*!h-(aJeXnElh5n(Uld@qE9$%{0 zKCSoY%w66n6PKKA(|)vWZ{^nAJ#5NmEoyl~yzIwryD}wS2>7!g*y1a}-pKy|o;_(L literal 0 HcmV?d00001 diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index eea9a1d5b..4590bf848 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -99,7 +99,6 @@ export const translations: Translations<"de"> = { `Diese Anmeldedaten sind für die nächsten ${howMuchTime} gültig` }, AccountAiGatewayTab: { - "credentials section title": "KI-Gateway-Anmeldedaten", "credentials section helper": ({ webUiUrl }) => ( <> Ihre OIDC-Sitzung gibt Ihnen nahtlosen Zugriff auf das KI-Gateway.{" "} @@ -119,6 +118,7 @@ export const translations: Translations<"de"> = { "custom providers section title": "Benutzerdefinierte KI-Anbieter", "custom providers section helper": "Fügen Sie Ihre eigenen OpenAI-kompatiblen KI-Anbieter hinzu. Die Anmeldedaten werden in Ihrem Browser gespeichert.", + "add custom provider": "Benutzerdefinierten Anbieter hinzufügen", "custom provider api base field": "API-Basis-URL", "custom provider api key field": "API-Schlüssel", "no account": ({ webUiUrl }) => ( @@ -133,7 +133,8 @@ export const translations: Translations<"de"> = { ) }, ProviderValueField: { - copy: "Kopieren" + copy: "Kopieren", + copied: "Kopiert" }, ModelsSection: { "model label": "Modell", @@ -144,17 +145,32 @@ export const translations: Translations<"de"> = { CustomProviderFormDialog: { "add custom provider title": "Benutzerdefinierte KI-Anbieter", "edit custom provider title": "KI-Anbieter bearbeiten", + "custom provider section title": "Benutzerdefinierte KI-Anbieter", + "custom provider section subtitle": + "Fügen Sie eigene OpenAI-kompatible KI-Anbieter hinzu.", "custom provider label field": "Name", "custom provider type field": "Provider-Typ", + "openai provider option": "Open AI", + "credentials section title": "Anmeldedaten des Anbieters", + "credentials section subtitle": + "Geben Sie Ihre Anmeldedaten ein. Sie werden in Ihrem Browser gespeichert.", "custom provider api base field": "API-Basis-URL", "custom provider api key field": "API-Schlüssel", + "verification section title": "Modelle prüfen und laden", + "verification section subtitle": + "Prüfen Sie Ihre Anmeldedaten und laden Sie automatisch die verfügbaren Modelle.", + "custom provider model field": "Modelltyp", "provider test": "Verbindung testen", - "provider test success": "Verbindung erfolgreich", + "provider testing": "Verbindung wird getestet...", + "provider test success": + "Verbindung erfolgreich. Der Anbieter ist einsatzbereit.", "provider test error": "Verbindung fehlgeschlagen — URL und API-Schlüssel prüfen.", + "set as default provider": "Als Standardanbieter festlegen", "provider save": "Hinzufügen", "provider update": "Speichern", - "provider cancel": "Abbrechen" + "provider cancel": "Abbrechen", + "close aria label": "Schließen" }, AccountVaultTab: { "credentials section title": "Vault-Anmeldeinformationen", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 7a4f35993..c838aa6af 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -97,7 +97,6 @@ export const translations: Translations<"en"> = { `These credentials are valid for the next ${howMuchTime}` }, AccountAiGatewayTab: { - "credentials section title": "AI Gateway credentials", "credentials section helper": ({ webUiUrl }) => ( <> Your OIDC session gives you seamless access to the AI gateway.{" "} @@ -117,6 +116,7 @@ export const translations: Translations<"en"> = { "custom providers section title": "Custom AI providers", "custom providers section helper": "Add your own OpenAI-compatible AI providers. Credentials are stored in your browser.", + "add custom provider": "Add custom provider", "custom provider api base field": "API base URL", "custom provider api key field": "API key", "no account": ({ webUiUrl }) => ( @@ -130,7 +130,8 @@ export const translations: Translations<"en"> = { ) }, ProviderValueField: { - copy: "Copy" + copy: "Copy", + copied: "Copied" }, ModelsSection: { "model label": "Model", @@ -138,18 +139,33 @@ export const translations: Translations<"en"> = { "models fetch error": "Unable to fetch models — check your URL and API key." }, CustomProviderFormDialog: { - "add custom provider title": "Custom AI providers", + "add custom provider title": "Add Custom AI Providers", "edit custom provider title": "Edit AI provider", - "custom provider label field": "Label", - "custom provider type field": "Provider type", - "custom provider api base field": "API base URL", - "custom provider api key field": "API key", + "custom provider section title": "Custom AI Providers", + "custom provider section subtitle": + "Add your own OpenAI-compatible AI providers.", + "custom provider label field": "Define a custom name", + "custom provider type field": "Provider Type", + "openai provider option": "Open AI", + "credentials section title": "Providers Credentials", + "credentials section subtitle": + "Enter your credentials. They will be stored in your browser.", + "custom provider api base field": "API Base URL", + "custom provider api key field": "API Key", + "verification section title": "Verify & Load Models", + "verification section subtitle": + "Verify your credentials and automatically discover the available models.", + "custom provider model field": "Model Type", "provider test": "Test connection", - "provider test success": "Connection successful", - "provider test error": "Unable to connect — check URL and API key.", - "provider save": "Add", + "provider testing": "Testing connection...", + "provider test success": "Connection successful. Your provider is ready to use.", + "provider test error": + "Connection failed. Please check your credentials or endpoint.", + "set as default provider": "Set as default provider", + "provider save": "Add Custom AI Providers", "provider update": "Save changes", - "provider cancel": "Cancel" + "provider cancel": "Cancel", + "close aria label": "Close" }, AccountVaultTab: { "credentials section title": "Vault credentials", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index a60d557ac..8a0154653 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -98,7 +98,6 @@ export const translations: Translations<"es"> = { `Estas credenciales son válidas por los próximos ${howMuchTime}` }, AccountAiGatewayTab: { - "credentials section title": "Credenciales de la pasarela de IA", "credentials section helper": ({ webUiUrl }) => ( <> Su sesión OIDC le da acceso sin interrupciones a la pasarela de IA.{" "} @@ -118,6 +117,7 @@ export const translations: Translations<"es"> = { "custom providers section title": "Proveedores de IA personalizados", "custom providers section helper": "Añade tus propios proveedores de IA compatibles con OpenAI. Las credenciales se almacenan en tu navegador.", + "add custom provider": "Añadir proveedor personalizado", "custom provider api base field": "URL base de la API", "custom provider api key field": "Clave API", "no account": ({ webUiUrl }) => ( @@ -132,7 +132,8 @@ export const translations: Translations<"es"> = { ) }, ProviderValueField: { - copy: "Copiar" + copy: "Copiar", + copied: "Copiado" }, ModelsSection: { "model label": "Modelo", @@ -143,16 +144,30 @@ export const translations: Translations<"es"> = { CustomProviderFormDialog: { "add custom provider title": "Proveedores de IA personalizados", "edit custom provider title": "Editar proveedor de IA", + "custom provider section title": "Proveedores de IA personalizados", + "custom provider section subtitle": + "Añada sus propios proveedores de IA compatibles con OpenAI.", "custom provider label field": "Etiqueta", "custom provider type field": "Tipo de proveedor", + "openai provider option": "Open AI", + "credentials section title": "Credenciales del proveedor", + "credentials section subtitle": + "Introduzca sus credenciales. Se almacenarán en su navegador.", "custom provider api base field": "URL base de la API", "custom provider api key field": "Clave API", + "verification section title": "Verificar y cargar modelos", + "verification section subtitle": + "Verifique sus credenciales y descubra automáticamente los modelos disponibles.", + "custom provider model field": "Tipo de modelo", "provider test": "Probar conexión", - "provider test success": "Conexión exitosa", + "provider testing": "Probando conexión...", + "provider test success": "Conexión exitosa. El proveedor está listo para usar.", "provider test error": "No se puede conectar — compruebe la URL y la clave API.", + "set as default provider": "Establecer como proveedor predeterminado", "provider save": "Añadir", "provider update": "Guardar", - "provider cancel": "Cancelar" + "provider cancel": "Cancelar", + "close aria label": "Cerrar" }, AccountVaultTab: { "credentials section title": "Credenciales de Vault", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index da533443d..eec206f55 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -98,7 +98,6 @@ export const translations: Translations<"fi"> = { `Nämä käyttöoikeudet ovat voimassa seuraavat ${howMuchTime}` }, AccountAiGatewayTab: { - "credentials section title": "Tekoälyyhdyskäytävän tunnistetiedot", "credentials section helper": ({ webUiUrl }) => ( <> OIDC-istuntosi antaa sinulle saumattoman pääsyn tekoälyyhdyskäytävään.{" "} @@ -118,6 +117,7 @@ export const translations: Translations<"fi"> = { "custom providers section title": "Mukautetut tekoälyntarjoajat", "custom providers section helper": "Lisää omia OpenAI-yhteensopivia tekoälypalveluntarjoajia. Tunnukset tallennetaan selaimeesi.", + "add custom provider": "Lisää mukautettu tarjoaja", "custom provider api base field": "API-perus-URL", "custom provider api key field": "API-avain", "no account": ({ webUiUrl }) => ( @@ -132,7 +132,8 @@ export const translations: Translations<"fi"> = { ) }, ProviderValueField: { - copy: "Kopioi" + copy: "Kopioi", + copied: "Kopioitu" }, ModelsSection: { "model label": "Malli", @@ -142,16 +143,30 @@ export const translations: Translations<"fi"> = { CustomProviderFormDialog: { "add custom provider title": "Mukautetut tekoälyntarjoajat", "edit custom provider title": "Muokkaa tekoälyntarjoajaa", + "custom provider section title": "Mukautetut tekoälyntarjoajat", + "custom provider section subtitle": + "Lisää omia OpenAI-yhteensopivia tekoälyntarjoajia.", "custom provider label field": "Tunniste", "custom provider type field": "Palveluntarjoajan tyyppi", + "openai provider option": "Open AI", + "credentials section title": "Palveluntarjoajan tunnistetiedot", + "credentials section subtitle": + "Anna tunnistetietosi. Ne tallennetaan selaimeesi.", "custom provider api base field": "API-perus-URL", "custom provider api key field": "API-avain", + "verification section title": "Vahvista ja lataa mallit", + "verification section subtitle": + "Vahvista tunnistetietosi ja etsi käytettävissä olevat mallit automaattisesti.", + "custom provider model field": "Mallin tyyppi", "provider test": "Testaa yhteys", - "provider test success": "Yhteys onnistui", + "provider testing": "Testataan yhteyttä...", + "provider test success": "Yhteys onnistui. Palveluntarjoaja on käyttövalmis.", "provider test error": "Yhteyttä ei voi muodostaa — tarkista URL ja API-avain.", + "set as default provider": "Aseta oletuspalveluntarjoajaksi", "provider save": "Lisää", "provider update": "Tallenna", - "provider cancel": "Peruuta" + "provider cancel": "Peruuta", + "close aria label": "Sulje" }, AccountVaultTab: { "credentials section title": "Vault-todennustiedot", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index fd4f5e249..ae884ead4 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -99,7 +99,6 @@ export const translations: Translations<"fr"> = { `Ces identifiants sont valables pour les ${howMuchTime} prochaines` }, AccountAiGatewayTab: { - "credentials section title": "Identifiants de la passerelle IA", "credentials section helper": ({ webUiUrl }) => ( <> Votre session OIDC vous donne accès à la passerelle IA.{" "} @@ -119,6 +118,7 @@ export const translations: Translations<"fr"> = { "custom providers section title": "Providers IA personnalisés", "custom providers section helper": "Ajoutez vos propres providers IA compatibles OpenAI. Les identifiants sont stockés dans votre navigateur.", + "add custom provider": "Ajouter un provider personnalisé", "custom provider api base field": "URL de base de l'API", "custom provider api key field": "Clé API", "no account": ({ webUiUrl }) => ( @@ -133,7 +133,8 @@ export const translations: Translations<"fr"> = { ) }, ProviderValueField: { - copy: "Copier" + copy: "Copier", + copied: "Copié" }, ModelsSection: { "model label": "Modèles", @@ -144,17 +145,31 @@ export const translations: Translations<"fr"> = { CustomProviderFormDialog: { "add custom provider title": "Providers IA personnalisés", "edit custom provider title": "Modifier le provider IA", + "custom provider section title": "Providers IA personnalisés", + "custom provider section subtitle": + "Ajoutez vos propres providers IA compatibles avec OpenAI.", "custom provider label field": "Nom", "custom provider type field": "Type de provider", + "openai provider option": "Open AI", + "credentials section title": "Identifiants du provider", + "credentials section subtitle": + "Saisissez vos identifiants. Ils seront stockés dans votre navigateur.", "custom provider api base field": "URL de base de l'API", "custom provider api key field": "Clé API", + "verification section title": "Vérifier et charger les modèles", + "verification section subtitle": + "Vérifiez vos identifiants et découvrez automatiquement les modèles disponibles.", + "custom provider model field": "Type de modèle", "provider test": "Tester la connexion", - "provider test success": "Connexion réussie", + "provider testing": "Test de la connexion...", + "provider test success": "Connexion réussie. Votre provider est prêt à l'emploi.", "provider test error": "Impossible de se connecter — vérifiez l'URL et la clé API.", + "set as default provider": "Définir comme provider par défaut", "provider save": "Ajouter", "provider update": "Enregistrer", - "provider cancel": "Annuler" + "provider cancel": "Annuler", + "close aria label": "Fermer" }, AccountVaultTab: { "credentials section title": "Identifiants Vault", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index ef09ab167..68e2f155b 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -97,7 +97,6 @@ export const translations: Translations<"it"> = { `Queste credenziali sono valide per i prossimi ${howMuchTime}` }, AccountAiGatewayTab: { - "credentials section title": "Credenziali del gateway IA", "credentials section helper": ({ webUiUrl }) => ( <> La tua sessione OIDC ti dà accesso senza interruzioni al gateway IA.{" "} @@ -117,6 +116,7 @@ export const translations: Translations<"it"> = { "custom providers section title": "Provider IA personalizzati", "custom providers section helper": "Aggiungi i tuoi provider IA compatibili con OpenAI. Le credenziali sono memorizzate nel tuo browser.", + "add custom provider": "Aggiungi provider personalizzato", "custom provider api base field": "URL base API", "custom provider api key field": "Chiave API", "no account": ({ webUiUrl }) => ( @@ -130,7 +130,8 @@ export const translations: Translations<"it"> = { ) }, ProviderValueField: { - copy: "Copia" + copy: "Copia", + copied: "Copiato" }, ModelsSection: { "model label": "Modello", @@ -141,17 +142,31 @@ export const translations: Translations<"it"> = { CustomProviderFormDialog: { "add custom provider title": "Provider IA personalizzati", "edit custom provider title": "Modifica provider IA", + "custom provider section title": "Provider IA personalizzati", + "custom provider section subtitle": + "Aggiungi i tuoi provider IA compatibili con OpenAI.", "custom provider label field": "Etichetta", "custom provider type field": "Tipo di provider", + "openai provider option": "Open AI", + "credentials section title": "Credenziali del provider", + "credentials section subtitle": + "Inserisci le credenziali. Verranno memorizzate nel browser.", "custom provider api base field": "URL base API", "custom provider api key field": "Chiave API", + "verification section title": "Verifica e carica i modelli", + "verification section subtitle": + "Verifica le credenziali e individua automaticamente i modelli disponibili.", + "custom provider model field": "Tipo di modello", "provider test": "Testa connessione", - "provider test success": "Connessione riuscita", + "provider testing": "Test della connessione...", + "provider test success": "Connessione riuscita. Il provider è pronto all'uso.", "provider test error": "Impossibile connettersi — controlla l'URL e la chiave API.", + "set as default provider": "Imposta come provider predefinito", "provider save": "Aggiungi", "provider update": "Salva", - "provider cancel": "Annulla" + "provider cancel": "Annulla", + "close aria label": "Chiudi" }, AccountVaultTab: { "credentials section title": "Credenziali Vault", diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 79298f60f..c89052f7f 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -98,7 +98,6 @@ export const translations: Translations<"nl"> = { `Deze inloggegevens zijn geldig voor de komende ${howMuchTime}` }, AccountAiGatewayTab: { - "credentials section title": "AI-gateway-inloggegevens", "credentials section helper": ({ webUiUrl }) => ( <> Uw OIDC-sessie geeft u naadloze toegang tot de AI-gateway.{" "} @@ -118,6 +117,7 @@ export const translations: Translations<"nl"> = { "custom providers section title": "Aangepaste AI-providers", "custom providers section helper": "Voeg uw eigen OpenAI-compatibele AI-providers toe. Inloggegevens worden in uw browser opgeslagen.", + "add custom provider": "Aangepaste provider toevoegen", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-sleutel", "no account": ({ webUiUrl }) => ( @@ -131,7 +131,8 @@ export const translations: Translations<"nl"> = { ) }, ProviderValueField: { - copy: "Kopiëren" + copy: "Kopiëren", + copied: "Gekopieerd" }, ModelsSection: { "model label": "Model", @@ -142,17 +143,32 @@ export const translations: Translations<"nl"> = { CustomProviderFormDialog: { "add custom provider title": "Aangepaste AI-providers", "edit custom provider title": "AI-provider bewerken", + "custom provider section title": "Aangepaste AI-providers", + "custom provider section subtitle": + "Voeg uw eigen OpenAI-compatibele AI-providers toe.", "custom provider label field": "Label", "custom provider type field": "Providertype", + "openai provider option": "Open AI", + "credentials section title": "Providerreferenties", + "credentials section subtitle": + "Voer uw referenties in. Ze worden in uw browser opgeslagen.", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-sleutel", + "verification section title": "Modellen verifiëren en laden", + "verification section subtitle": + "Verifieer uw referenties en ontdek automatisch de beschikbare modellen.", + "custom provider model field": "Modeltype", "provider test": "Verbinding testen", - "provider test success": "Verbinding geslaagd", + "provider testing": "Verbinding testen...", + "provider test success": + "Verbinding geslaagd. De provider is klaar voor gebruik.", "provider test error": "Kan geen verbinding maken — controleer URL en API-sleutel.", + "set as default provider": "Instellen als standaardprovider", "provider save": "Toevoegen", "provider update": "Opslaan", - "provider cancel": "Annuleren" + "provider cancel": "Annuleren", + "close aria label": "Sluiten" }, AccountVaultTab: { "credentials section title": "Gebrukersnamen Vault", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index f8c46aeae..9c27f6187 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -98,7 +98,6 @@ export const translations: Translations<"no"> = { `Disse legitimasjonene er gyldige for de neste ${howMuchTime}` }, AccountAiGatewayTab: { - "credentials section title": "AI-gateway-legitimasjon", "credentials section helper": ({ webUiUrl }) => ( <> Din OIDC-økt gir deg sømløs tilgang til AI-gatewayen.{" "} @@ -118,6 +117,7 @@ export const translations: Translations<"no"> = { "custom providers section title": "Tilpassede AI-leverandører", "custom providers section helper": "Legg til dine egne OpenAI-kompatible AI-leverandører. Påloggingsinformasjonen lagres i nettleseren din.", + "add custom provider": "Legg til tilpasset leverandør", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-nøkkel", "no account": ({ webUiUrl }) => ( @@ -131,7 +131,8 @@ export const translations: Translations<"no"> = { ) }, ProviderValueField: { - copy: "Kopier" + copy: "Kopier", + copied: "Kopiert" }, ModelsSection: { "model label": "Modell", @@ -141,16 +142,30 @@ export const translations: Translations<"no"> = { CustomProviderFormDialog: { "add custom provider title": "Tilpassede AI-leverandører", "edit custom provider title": "Rediger AI-leverandør", + "custom provider section title": "Tilpassede AI-leverandører", + "custom provider section subtitle": + "Legg til dine egne OpenAI-kompatible AI-leverandører.", "custom provider label field": "Etikett", "custom provider type field": "Leverandørtype", + "openai provider option": "Open AI", + "credentials section title": "Leverandørlegitimasjon", + "credentials section subtitle": + "Skriv inn legitimasjonen din. Den lagres i nettleseren.", "custom provider api base field": "API-basis-URL", "custom provider api key field": "API-nøkkel", + "verification section title": "Bekreft og last inn modeller", + "verification section subtitle": + "Bekreft legitimasjonen og finn tilgjengelige modeller automatisk.", + "custom provider model field": "Modelltype", "provider test": "Test tilkobling", - "provider test success": "Tilkobling vellykket", + "provider testing": "Tester tilkobling...", + "provider test success": "Tilkobling vellykket. Leverandøren er klar til bruk.", "provider test error": "Kan ikke koble til — sjekk URL og API-nøkkel.", + "set as default provider": "Angi som standardleverandør", "provider save": "Legg til", "provider update": "Lagre", - "provider cancel": "Avbryt" + "provider cancel": "Avbryt", + "close aria label": "Lukk" }, AccountVaultTab: { "credentials section title": "Vault credentials", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 1ca65e252..671c7bf9a 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -89,7 +89,6 @@ export const translations: Translations<"zh-CN"> = { "expires in": ({ howMuchTime }) => `这些凭证在接下来的 ${howMuchTime} 内有效` }, AccountAiGatewayTab: { - "credentials section title": "AI 网关凭据", "credentials section helper": ({ webUiUrl }) => ( <> 您的 OIDC 会话使您可以无缝访问 AI 网关。{" "} @@ -109,6 +108,7 @@ export const translations: Translations<"zh-CN"> = { "custom providers section title": "自定义 AI 提供商", "custom providers section helper": "添加您自己的兼容 OpenAI 的 AI 提供商。凭据存储在您的浏览器中。", + "add custom provider": "添加自定义提供商", "custom provider api base field": "API 基础 URL", "custom provider api key field": "API 密钥", "no account": ({ webUiUrl }) => ( @@ -122,7 +122,8 @@ export const translations: Translations<"zh-CN"> = { ) }, ProviderValueField: { - copy: "复制" + copy: "复制", + copied: "已复制" }, ModelsSection: { "model label": "模型", @@ -132,16 +133,27 @@ export const translations: Translations<"zh-CN"> = { CustomProviderFormDialog: { "add custom provider title": "自定义 AI 提供商", "edit custom provider title": "编辑 AI 提供商", + "custom provider section title": "自定义 AI 提供商", + "custom provider section subtitle": "添加兼容 OpenAI 的自定义 AI 提供商。", "custom provider label field": "标签", "custom provider type field": "提供商类型", + "openai provider option": "Open AI", + "credentials section title": "提供商凭证", + "credentials section subtitle": "请输入凭证。凭证将存储在您的浏览器中。", "custom provider api base field": "API 基础 URL", "custom provider api key field": "API 密钥", + "verification section title": "验证并加载模型", + "verification section subtitle": "验证凭证并自动发现可用模型。", + "custom provider model field": "模型类型", "provider test": "测试连接", - "provider test success": "连接成功", + "provider testing": "正在测试连接...", + "provider test success": "连接成功。提供商已准备就绪。", "provider test error": "无法连接 — 请检查 URL 和 API 密钥。", + "set as default provider": "设为默认提供商", "provider save": "添加", "provider update": "保存", - "provider cancel": "取消" + "provider cancel": "取消", + "close aria label": "关闭" }, AccountVaultTab: { "credentials section title": "保险库凭证", diff --git a/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx index e86073582..5b6c6fb6e 100644 --- a/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx @@ -1,15 +1,16 @@ import { memo } from "react"; import { useTranslation } from "ui/i18n"; -import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; +import openWebUiIconUrl from "ui/assets/img/openWebUiIcon.png"; +import { LocalizedMarkdown } from "ui/shared/Markdown"; import { useCallbackFactory } from "powerhooks/useCallbackFactory"; import { useConstCallback } from "powerhooks/useConstCallback"; import { useConst } from "powerhooks/useConst"; import { copyToClipboard } from "ui/tools/copyToClipboard"; import { tss } from "tss"; +import { alpha } from "@mui/material/styles"; import { declareComponentKeys } from "i18nifty"; import { Evt } from "evt"; import type { UnpackEvt } from "evt"; -import { IconButton } from "onyxia-ui/IconButton"; import { Icon } from "onyxia-ui/Icon"; import { CircularProgress } from "onyxia-ui/CircularProgress"; import { Button } from "onyxia-ui/Button"; @@ -22,6 +23,7 @@ import { CustomProviderFormDialog, type Props as CustomProviderFormDialogProps } from "./CustomProviderFormDialog"; +import { assert } from "tsafe"; export type Props = { className?: string; @@ -71,14 +73,20 @@ export const AccountAiTab = memo((props: Props) => { const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => { const provider = customProviders.find(p => p.id === providerId); - if (provider === undefined) return; + assert(provider !== undefined); evtCustomProviderFormDialogOpen.post({ editedProvider: { id: provider.id, name: provider.name, provider: provider.provider, apiBase: provider.apiBase, - apiKey: provider.apiKey + apiKey: provider.apiKey, + availableModels: + provider.models?.stateDescription === "loaded" + ? provider.models.availableModels + : undefined, + selectedModelId: provider.selectedModelId, + isDefault: provider.isDefault } }); }); @@ -121,12 +129,26 @@ export const AccountAiTab = memo((props: Props) => { return (
{regionProviders.map(regionProvider => ( -
-
- {regionProvider.name} -
+
+
+ {regionProvider.name} +
+ + {regionProvider.description === undefined ? ( + t("credentials section helper", { + webUiUrl: regionProvider.webUiUrl + }) + ) : ( + + {regionProvider.description} + + )} + {regionProvider.auth.stateDescription === "authenticated" && ( - <> +
)}
- {regionProvider.auth.stateDescription === "no account" && ( - - {t("no account", { webUiUrl: regionProvider.webUiUrl })} - - )} + {regionProvider.auth.stateDescription === "no account" && + (() => { + const { accountCreation } = regionProvider; + + if ( + accountCreation === undefined || + (accountCreation.title === undefined && + accountCreation.description === undefined && + accountCreation.buttonLabel === undefined) + ) { + return ( + + {t("no account", { + webUiUrl: regionProvider.webUiUrl + })} + + ); + } + return ( +
+
+
+ + {accountCreation.title !== undefined && ( + + + {accountCreation.title} + + + )} +
+ + {accountCreation.description === undefined ? ( + t("no account", { + webUiUrl: regionProvider.webUiUrl + }) + ) : ( + + {accountCreation.description} + + )} + +
+ {accountCreation.buttonLabel !== undefined && ( + + )} +
+ ); + })()} {regionProvider.auth.stateDescription === "error" && ( @@ -157,99 +243,112 @@ export const AccountAiTab = memo((props: Props) => { )} {regionProvider.auth.stateDescription === "authenticated" && ( - <> - + -
- - - -
- + + +
)}
))} -
- - -
+
+
+
+ + {t("custom providers section title")} + + + {t("custom providers section helper")} + +
- {customProviders.map(provider => ( -
-
- {provider.name} -
- - - {renderDefaultProviderAction({ - providerId: provider.id, - isDefault: provider.isDefault - })} + {customProviders.map(provider => ( +
+
+ {provider.name} +
+ + + {renderDefaultProviderAction({ + providerId: provider.id, + isDefault: provider.isDefault + })} +
+
+
+ + +
-
- - - -
-
- ))} + ))} + + +
@@ -262,13 +361,13 @@ const { i18n } = declareComponentKeys< | "refresh credentials" | "delete provider" | "edit provider" - | "credentials section title" | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } | "api base url" | "token" | "gateway error" | "custom providers section title" | "custom providers section helper" + | "add custom provider" | "custom provider api base field" | "custom provider api key field" | { K: "no account"; P: { webUiUrl: string }; R: JSX.Element } @@ -278,11 +377,47 @@ export type I18n = typeof i18n; const useStyles = tss .withName({ AccountAiGatewayTab: AccountAiTab }) .create(({ theme }) => ({ - customProvidersSectionHeader: { + regionProviderSection: { display: "flex", - alignItems: "flex-start", - gap: theme.spacing(1), - marginTop: theme.spacing(4) + flexDirection: "column", + gap: theme.spacing(3), + "& + &": { + marginTop: theme.spacing(5) + } + }, + regionProviderHeader: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + regionProviderSubtitleRow: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + flexWrap: "wrap" + }, + regionProviderDescription: { + flex: 1, + minWidth: 260, + color: theme.colors.useCases.typography.textSecondary + }, + customProvidersSection: { + marginTop: theme.spacing(4), + display: "flex", + flexDirection: "column" + }, + customProvidersDivider: { + height: 1, + backgroundColor: theme.colors.useCases.surfaces.surface2, + marginBottom: theme.spacing(3) + }, + customProvidersHeader: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + customProvidersDescription: { + color: theme.colors.useCases.typography.textSecondary }, providerCard: { border: `1px solid ${theme.colors.useCases.typography.textDisabled}`, @@ -314,14 +449,16 @@ const useStyles = tss justifyContent: "center", gap: theme.spacing(1), minHeight: 28, + paddingLeft: theme.spacing(1.5), + paddingRight: theme.spacing(1.5), borderRadius: 9999, - backgroundColor: theme.colors.useCases.alertSeverity.success.background + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1) }, defaultProviderBadgeIcon: { - color: theme.colors.useCases.alertSeverity.success.main + color: theme.colors.useCases.typography.textFocus }, defaultProviderBadgeText: { - color: theme.colors.useCases.alertSeverity.success.main + color: theme.colors.useCases.typography.textPrimary }, providerFields: { display: "flex", @@ -331,6 +468,80 @@ const useStyles = tss paddingLeft: theme.spacing(3), paddingRight: theme.spacing(3) }, + addCustomProviderAction: { + width: "100%", + minHeight: 76, + display: "flex", + alignItems: "center", + gap: 24, + marginTop: 24, + padding: 24, + boxSizing: "border-box", + appearance: "none", + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + borderRadius: 16, + backgroundColor: "transparent", + color: theme.colors.useCases.typography.textPrimary, + font: "inherit", + textAlign: "left", + cursor: "pointer", + transition: "background-color 120ms ease, border-color 120ms ease", + "&:hover": { + backgroundColor: alpha(theme.colors.useCases.surfaces.surface2, 0.4) + }, + "&:active": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "&:focus-visible": { + outline: `2px solid ${theme.colors.useCases.typography.textFocus}`, + outlineOffset: 2 + } + }, + addCustomProviderIcon: { + flexShrink: 0, + width: 24, + height: 24 + }, + addCustomProviderLabel: { + flex: 1, + minWidth: 0 + }, + noAccountCard: { + maxWidth: 759, + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + gap: theme.spacing(3), + padding: theme.spacing(3), + borderRadius: theme.spacing(2), + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1) + }, + noAccountText: { + width: "100%" + }, + noAccountTitle: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1) + }, + noAccountLogo: { + flexShrink: 0, + width: 32, + height: 32, + objectFit: "contain", + display: "block" + }, + noAccountTitleText: { + minWidth: 0, + fontWeight: 600 + }, + noAccountDescription: { + marginTop: theme.spacing(1), + color: theme.colors.useCases.typography.textSecondary + }, + noAccountButton: { + alignSelf: "flex-end" + }, errorText: { color: theme.colors.useCases.alertSeverity.error.main } diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx new file mode 100644 index 000000000..75a32c649 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Evt, type UnpackEvt } from "evt"; +import { CustomProviderFormDialog, type Props } from "./CustomProviderFormDialog"; + +const meta = { + title: "Pages/Account/CustomProviderFormDialog", + component: CustomProviderFormDialog, + parameters: { + layout: "fullscreen" + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const evtOpenDefault = Evt.create>(); + +export const Default: Story = { + args: { + evtOpen: evtOpenDefault + }, + play: () => { + evtOpenDefault.post({ editedProvider: undefined }); + } +}; + +const evtOpenFilled = Evt.create>(); + +export const Filled: Story = { + args: { + evtOpen: evtOpenFilled + }, + play: () => { + evtOpenFilled.post({ + editedProvider: { + id: "custom-provider-1", + name: "Custom Provider 1", + provider: "openai", + apiBase: "https://llm.example.test/api", + apiKey: "storybook-api-key", + availableModels: [ + { id: "gemma4-26b-moe", name: "gemma4-26b-moe" }, + { id: "qwen3-6-35b-mo", name: "qwen3-6-35b-mo" }, + { id: "qwen3-embedding-8b", name: "qwen3-embedding-8b" }, + { id: "qwen3-vl", name: "qwen3-vl" } + ], + selectedModelId: "qwen3-vl", + isDefault: false + } + }); + } +}; diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx index ada17beb6..856c66631 100644 --- a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx @@ -1,18 +1,35 @@ -import { memo, useState } from "react"; -import { useTranslation } from "ui/i18n"; -import { tss } from "tss"; -import { declareComponentKeys } from "i18nifty"; -import { useConstCallback } from "powerhooks/useConstCallback"; -import { useCallbackFactory } from "powerhooks/useCallbackFactory"; -import { useEvt } from "evt/hooks"; +import FormControl from "@mui/material/FormControl"; +import Input from "@mui/material/Input"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import Checkbox from "@mui/material/Checkbox"; +import { alpha } from "@mui/material/styles"; import type { NonPostableEvt, UnpackEvt } from "evt"; -import { assert } from "tsafe/assert"; -import { Dialog } from "onyxia-ui/Dialog"; +import { useEvt } from "evt/hooks"; +import { declareComponentKeys } from "i18nifty"; +import { getIconUrlByName } from "lazy-icons"; import { Button } from "onyxia-ui/Button"; import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Icon } from "onyxia-ui/Icon"; +import { IconButton } from "onyxia-ui/IconButton"; import { Text } from "onyxia-ui/Text"; -import TextField from "@mui/material/TextField"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { + memo, + useEffect, + useId, + useRef, + useState, + type MouseEvent, + type ReactNode +} from "react"; +import { assert } from "tsafe/assert"; +import { keyframes } from "tss-react"; import { getCoreSync } from "core"; +import { tss } from "tss"; +import { useTranslation } from "ui/i18n"; + +type AiModel = { id: string; name: string }; export type Props = { evtOpen: NonPostableEvt<{ @@ -23,6 +40,9 @@ export type Props = { provider: string; apiBase: string; apiKey: string; + availableModels: AiModel[] | undefined; + selectedModelId: string | undefined; + isDefault: boolean; } | undefined; }>; @@ -35,19 +55,21 @@ type FormValues = { provider: string; apiBase: string; apiKey: string; + selectedModelId: string; }; type FormTest = | { stateDescription: "idle" } | { stateDescription: "testing" } - | { stateDescription: "success"; modelCount: number } + | { stateDescription: "success"; models: AiModel[] } | { stateDescription: "error" }; const defaultFormValues: FormValues = { name: "", - provider: "openai", + provider: "", apiBase: "", - apiKey: "" + apiKey: "", + selectedModelId: "" }; export const CustomProviderFormDialog = memo((props: Props) => { @@ -56,194 +78,898 @@ export const CustomProviderFormDialog = memo((props: Props) => { const { classes } = useStyles(); const { t } = useTranslation({ CustomProviderFormDialog }); - const { - functions: { ai } - } = getCoreSync(); - - // The add/edit custom-provider form is entirely UI-owned: its open state, edited - // values and connection-test result never go through the core. The core only - // exposes the resulting operations (add/edit/test). const [openState, setOpenState] = useState< - { editedProviderId: string | undefined } | undefined + | { + editedProviderId: string | undefined; + isAlreadyDefault: boolean; + } + | undefined >(undefined); const [values, setValues] = useState(defaultFormValues); const [test, setTest] = useState({ stateDescription: "idle" }); + const [doSetAsDefault, setDoSetAsDefault] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const testRequestIdRef = useRef(0); useEvt( ctx => { evtOpen.attach(ctx, ({ editedProvider }: OpenParams) => { - setValues( - editedProvider === undefined - ? defaultFormValues - : { - name: editedProvider.name, - provider: editedProvider.provider, - apiBase: editedProvider.apiBase, - apiKey: editedProvider.apiKey - } - ); - setTest({ stateDescription: "idle" }); - setOpenState({ editedProviderId: editedProvider?.id }); + testRequestIdRef.current++; + + if (editedProvider === undefined) { + setValues(defaultFormValues); + setTest({ stateDescription: "idle" }); + setDoSetAsDefault(false); + setOpenState({ + editedProviderId: undefined, + isAlreadyDefault: false + }); + } else { + const selectedModelId = + editedProvider.availableModels?.some( + model => model.id === editedProvider.selectedModelId + ) === true + ? editedProvider.selectedModelId + : undefined; + + setValues({ + name: editedProvider.name, + provider: editedProvider.provider, + apiBase: editedProvider.apiBase, + apiKey: editedProvider.apiKey, + selectedModelId: selectedModelId ?? "" + }); + setTest( + editedProvider.availableModels === undefined + ? { stateDescription: "idle" } + : { + stateDescription: "success", + models: editedProvider.availableModels + } + ); + setDoSetAsDefault(editedProvider.isDefault); + setOpenState({ + editedProviderId: editedProvider.id, + isAlreadyDefault: editedProvider.isDefault + }); + } + + setIsSaving(false); }); }, [evtOpen] ); const isEditing = openState?.editedProviderId !== undefined; + const testedModels = test.stateDescription === "success" ? test.models : undefined; const canSave = - values.name !== "" && + values.name.trim() !== "" && values.provider !== "" && - values.apiBase !== "" && - values.apiKey !== ""; + values.apiBase.trim() !== "" && + values.apiKey.trim() !== "" && + values.selectedModelId !== "" && + testedModels?.some(model => model.id === values.selectedModelId) === true && + !isSaving; const canTest = - values.apiBase !== "" && - values.apiKey !== "" && - test.stateDescription !== "testing"; - - const onClose = useConstCallback(() => setOpenState(undefined)); - - const onFieldChangeFactory = useCallbackFactory( - ([key]: [keyof FormValues], [event]: [{ target: { value: string } }]) => { - const { value } = event.target; - setValues(values => ({ ...values, [key]: value })); - // Only credential changes invalidate a previous connection-test result; - // the display label and provider type don't affect connectivity. - if (key !== "name" && key !== "provider") { - setTest({ stateDescription: "idle" }); - } + values.apiBase.trim() !== "" && values.apiKey.trim() !== "" && !isSaving; + + const onClose = useConstCallback(() => { + testRequestIdRef.current++; + setOpenState(undefined); + }); + + const onFieldChange = useConstCallback((key: keyof FormValues, value: string) => { + setValues(values => ({ ...values, [key]: value })); + + if (key === "apiBase" || key === "apiKey") { + testRequestIdRef.current++; + setValues(values => ({ ...values, selectedModelId: "" })); + setTest({ stateDescription: "idle" }); } - ); + }); const onTest = useConstCallback(async () => { + if (!canTest || test.stateDescription === "testing") { + return; + } + + const { + functions: { ai } + } = getCoreSync(); + + const testRequestId = ++testRequestIdRef.current; setTest({ stateDescription: "testing" }); + try { - const { modelCount } = await ai.testCustomProviderConnection({ + const { models } = await ai.testCustomProviderConnection({ apiBase: values.apiBase, apiKey: values.apiKey }); - setTest({ stateDescription: "success", modelCount }); + + if (testRequestId !== testRequestIdRef.current) { + return; + } + + setValues(values => ({ + ...values, + selectedModelId: models.some(model => model.id === values.selectedModelId) + ? values.selectedModelId + : "" + })); + setTest({ stateDescription: "success", models }); } catch { + if (testRequestId !== testRequestIdRef.current) { + return; + } + + setValues(values => ({ ...values, selectedModelId: "" })); setTest({ stateDescription: "error" }); } }); const onSave = useConstCallback(async () => { assert(openState !== undefined); - const { editedProviderId } = openState; - setOpenState(undefined); - if (editedProviderId === undefined) { - await ai.addCustomProvider(values); - } else { - await ai.editCustomProvider({ providerId: editedProviderId, ...values }); + assert(test.stateDescription === "success"); + assert(values.selectedModelId !== ""); + + const { + functions: { ai } + } = getCoreSync(); + + setIsSaving(true); + + const params = { + name: values.name.trim(), + provider: values.provider, + apiBase: values.apiBase.trim(), + apiKey: values.apiKey.trim(), + models: test.models, + selectedModelId: values.selectedModelId, + doSetAsDefault + }; + + try { + if (openState.editedProviderId === undefined) { + await ai.addCustomProvider(params); + } else { + await ai.editCustomProvider({ + providerId: openState.editedProviderId, + ...params + }); + } + + onClose(); + } catch { + setIsSaving(false); } }); + if (openState === undefined) { + return null; + } + return ( - - - - - -
- +
+ + onFieldChange("selectedModelId", value)} + models={testedModels ?? []} + disabled={testedModels === undefined} + /> + + {test.stateDescription === "testing" && ( +
- ) : ( - t("provider test") - )} - + {t("provider testing")} +
+ )} + {test.stateDescription === "success" && ( - - {t("provider test success")} ({test.modelCount}) - + + {t("provider test success")} + )} + {test.stateDescription === "error" && ( - + {t("provider test error")} - + )} + +
+ +
+ + +
+ +
+ + + ); +}); + +function SideDialog(props: { + title: ReactNode; + onClose: () => void; + children: ReactNode; +}) { + const { children, title, onClose } = props; + + const { classes } = useStyles_SideDialog(); + const { t } = useTranslation({ CustomProviderFormDialog }); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); } - buttons={ - <> - - - - } + }; + + window.addEventListener("keydown", onKeyDown); + + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const onRootClick = (event: MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + return ( +
+
+
+ + {title} + + +
+ +
{children}
+
+
+ ); +} + +function SectionHeading(props: { title: string; subtitle: string }) { + const { title, subtitle } = props; + const { classes } = useStyles_SectionHeading(); + + return ( +
+ + {title} + + + {subtitle} + +
+ ); +} + +function FormTextField(props: { + label: string; + value: string; + onChange: (value: string) => void; + autoComplete: string; + isSensitive?: boolean; +}) { + const { label, value, onChange, autoComplete, isSensitive = false } = props; + const inputId = useId(); + const { classes } = useStyles_FormField(); + + return ( + onChange(event.target.value)} + type={isSensitive ? "password" : "text"} + fullWidth={true} + disableUnderline={true} + autoComplete={autoComplete} + inputProps={{ "aria-label": label }} /> ); -}); +} + +function FormSelectField(props: { + label: string; + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; +}) { + const { label, value, onChange, options } = props; + const { classes } = useStyles_FormField(); + + return ( + + + value={value} + displayEmpty={true} + onChange={event => onChange(event.target.value)} + inputProps={{ "aria-label": label }} + renderValue={selectedValue => + selectedValue === "" + ? label + : (options.find(option => option.value === selectedValue) + ?.label ?? selectedValue) + } + > + {options.map(option => ( + + {option.label} + + ))} + + + ); +} + +function ModelSelectField(props: { + label: string; + value: string; + onChange: (value: string) => void; + models: AiModel[]; + disabled: boolean; +}) { + const { label, value, onChange, models, disabled } = props; + const { classes } = useStyles_FormField(); + + const renderModel = (model: AiModel) => ( + + + {model.name} + + ); + + return ( + + + value={value} + displayEmpty={true} + onChange={event => onChange(event.target.value)} + inputProps={{ "aria-label": label }} + renderValue={selectedValue => { + if (selectedValue === "") { + return label; + } + + const model = models.find(model => model.id === selectedValue); + + return model === undefined ? selectedValue : renderModel(model); + }} + MenuProps={{ + PaperProps: { className: classes.modelMenu } + }} + > + {models.map(model => ( + + {renderModel(model)} + + ))} + + + ); +} + +function StatusMessage(props: { severity: "success" | "error"; children: ReactNode }) { + const { severity, children } = props; + const { classes, cx } = useStyles_StatusMessage(); + + return ( +
+ + {children} +
+ ); +} const { i18n } = declareComponentKeys< | "add custom provider title" | "edit custom provider title" + | "custom provider section title" + | "custom provider section subtitle" | "custom provider label field" | "custom provider type field" + | "openai provider option" + | "credentials section title" + | "credentials section subtitle" | "custom provider api base field" | "custom provider api key field" + | "verification section title" + | "verification section subtitle" + | "custom provider model field" | "provider test" + | "provider testing" | "provider test success" | "provider test error" + | "set as default provider" | "provider save" | "provider update" | "provider cancel" + | "close aria label" >()({ CustomProviderFormDialog }); export type I18n = typeof i18n; const useStyles = tss.withName({ CustomProviderFormDialog }).create(({ theme }) => ({ - formFields: { + root: { + height: "100%", + minHeight: 0, + display: "flex", + flexDirection: "column", + color: theme.colors.useCases.typography.textPrimary + }, + body: { + flex: 1, + minHeight: 0, + overflowY: "auto", + paddingRight: theme.spacing(0.5) + }, + section: { + minWidth: 0, + paddingBottom: theme.spacing(4), + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + "& + &": { + marginTop: theme.spacing(4) + }, + "&:last-child": { + borderBottom: "none", + paddingBottom: 0 + } + }, + fields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), + marginTop: theme.spacing(4) + 6 + }, + verificationHeadingRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + marginBottom: theme.spacing(5) + }, + testButton: { + flex: "none", + "&&": { + minHeight: 40, + borderColor: "transparent", + backgroundColor: theme.colors.useCases.typography.textPrimary, + color: theme.colors.getUseCases({ isDarkModeEnabled: false }).surfaces + .surface1 + }, + "&&:hover": { + borderColor: "transparent", + backgroundColor: theme.colors.useCases.typography.textSecondary, + color: theme.colors.getUseCases({ isDarkModeEnabled: false }).surfaces + .surface1 + }, + "&&.Mui-disabled": { + borderColor: "transparent", + backgroundColor: theme.colors.useCases.buttons.actionDisabledBackground, + color: theme.colors.useCases.typography.textDisabled + } + }, + testingMessage: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + minHeight: 40, + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(2) + }, + footer: { + flex: "none", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + paddingTop: theme.spacing(3), + marginTop: theme.spacing(2) + }, + defaultProviderControl: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + cursor: "pointer" + }, + checkbox: { + padding: 0, + color: theme.colors.useCases.typography.textPrimary, + "&.Mui-checked": { + color: theme.colors.useCases.buttons.actionActive + } + }, + actions: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing(1) + }, + cancelButton: { + "&&": { + borderColor: "transparent", + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "&&:hover": { + borderColor: "transparent", + backgroundColor: theme.colors.useCases.buttons.actionHoverSecondary + } + }, + submitButton: { + "&&": { + borderColor: "transparent", + backgroundColor: theme.colors.useCases.buttons.actionHoverPrimary, + color: theme.colors.getUseCases({ isDarkModeEnabled: true }).typography + .textPrimary + }, + "&&:hover": { + borderColor: "transparent", + backgroundColor: theme.colors.useCases.buttons.actionActive, + color: theme.colors.getUseCases({ isDarkModeEnabled: true }).typography + .textPrimary + }, + "&&.Mui-disabled": { + borderColor: "transparent", + backgroundColor: alpha( + theme.colors.useCases.buttons.actionHoverPrimary, + 0.35 + ), + color: theme.colors.getUseCases({ isDarkModeEnabled: true }).typography + .textPrimary + } + } +})); + +const useStyles_SideDialog = tss.withName({ SideDialog }).create(({ theme }) => ({ + root: { + position: "fixed", + inset: 0, + zIndex: theme.muiTheme.zIndex.modal, + marginRight: theme.spacing(3), + display: "flex", + justifyContent: "flex-end", + alignItems: "stretch", + padding: `64px ${theme.spacing(2)}px 32px`, + boxSizing: "border-box", + backgroundColor: alpha(theme.colors.useCases.surfaces.background, 0.72), + backdropFilter: "blur(1px)", + "@media (max-width: 720px)": { + marginRight: 0, + padding: 0 + } + }, + panel: { + width: 657, + maxWidth: "100%", + minHeight: 0, display: "flex", flexDirection: "column", - gap: theme.spacing(4) + overflow: "hidden", + borderRadius: 16, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[4], + animation: `${keyframes` + from { + opacity: 0; + transform: translateX(28px); + } + to { + opacity: 1; + transform: translateX(0); + } + `} 340ms cubic-bezier(0.2, 0, 0, 1)`, + "@media (max-width: 720px)": { + borderRadius: 0, + borderTop: "none", + borderBottom: "none" + } }, - testRow: { + headingWrapper: { + flex: "none", display: "flex", alignItems: "center", - gap: theme.spacing(3) + justifyContent: "space-between", + gap: theme.spacing(2), + margin: `0 ${theme.spacing(5)}px`, + padding: `${theme.spacing(5)}px 0 ${theme.spacing(2)}px`, + borderBottom: `1px solid ${theme.colors.useCases.typography.textSecondary}` + }, + title: { + minWidth: 0, + color: theme.colors.useCases.typography.textPrimary + }, + closeButton: { + flex: "none" + }, + childrenWrapper: { + flex: 1, + minHeight: 0, + overflow: "hidden", + padding: `${theme.spacing(5)}px ${theme.spacing(5)}px ${theme.spacing(4)}px`, + boxSizing: "border-box" + } +})); + +const useStyles_SectionHeading = tss.withName({ SectionHeading }).create(({ theme }) => ({ + root: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + title: { + color: theme.colors.useCases.typography.textPrimary + }, + subtitle: { + color: theme.colors.useCases.typography.textSecondary + } +})); + +const useStyles_FormField = tss.withName({ FormTextField }).create(({ theme }) => { + const fieldBackground = theme.colors.useCases.surfaces.background; + + return { + input: { + minHeight: 45, + borderRadius: 8, + border: "2px solid transparent", + backgroundColor: fieldBackground, + transition: "border-color 160ms ease, background-color 160ms ease", + "&:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "&.Mui-focused": { + borderColor: theme.colors.useCases.buttons.actionActive + }, + "& .MuiInputBase-input": { + ...theme.typography.variants["body 2"].style, + padding: `${theme.spacing(1.25)}px ${theme.spacing(1.5)}px`, + color: theme.colors.useCases.typography.textPrimary, + "&::placeholder": { + color: theme.colors.useCases.typography.textSecondary, + opacity: 1 + } + } + }, + selectControl: { + "& .MuiInputBase-root": { + minHeight: 45, + borderRadius: 8, + backgroundColor: fieldBackground, + color: theme.colors.useCases.typography.textPrimary + }, + "& .MuiOutlinedInput-notchedOutline": { + border: "2px solid transparent" + }, + "& .MuiInputBase-root:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "& .Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: theme.colors.useCases.buttons.actionActive + }, + "& .MuiSelect-select": { + ...theme.typography.variants["body 2"].style, + display: "flex", + alignItems: "center", + minHeight: "unset", + padding: `${theme.spacing(1.25)}px ${theme.spacing(5)}px ${theme.spacing( + 1.25 + )}px ${theme.spacing(1.5)}px` + }, + "& .MuiSelect-icon": { + color: theme.colors.useCases.typography.textPrimary, + right: theme.spacing(1.5) + }, + "& .Mui-disabled": { + color: theme.colors.useCases.typography.textDisabled, + WebkitTextFillColor: theme.colors.useCases.typography.textDisabled + } + }, + modelOption: { + minWidth: 0, + display: "flex", + alignItems: "center", + gap: theme.spacing(1) + }, + modelIcon: { + flex: "none", + color: theme.colors.useCases.buttons.actionActive + }, + modelMenu: { + marginTop: theme.spacing(0.5), + padding: theme.spacing(0.5), + borderRadius: 8, + backgroundColor: theme.colors.useCases.surfaces.surface1 + }, + modelMenuItem: { + minHeight: 32, + borderRadius: 6, + "&.Mui-selected": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + } + } + }; +}); + +const useStyles_StatusMessage = tss.withName({ StatusMessage }).create(({ theme }) => ({ + root: { + minHeight: 40, + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + marginTop: theme.spacing(2), + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + borderRadius: 8, + boxSizing: "border-box", + color: theme.colors.useCases.typography.textPrimary + }, + success: { + backgroundColor: alpha(theme.colors.useCases.alertSeverity.success.main, 0.2) + }, + error: { + backgroundColor: alpha(theme.colors.useCases.alertSeverity.error.main, 0.2) + }, + dot: { + flex: "none", + width: 16, + height: 16, + borderRadius: "50%" }, - testSuccess: { - color: theme.colors.useCases.alertSeverity.success.main + dotSuccess: { + backgroundColor: theme.colors.useCases.alertSeverity.success.main }, - testError: { - color: theme.colors.useCases.alertSeverity.error.main + dotError: { + backgroundColor: theme.colors.useCases.alertSeverity.error.main } })); diff --git a/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx index e4e9aa3ab..f8310c406 100644 --- a/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx +++ b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx @@ -56,6 +56,7 @@ export const ModelsSection = memo((props: Props) => {
- - {t("not defined")} - {models.availableModels.map(({ id, name }) => ( {name} From 403bfc24c150dbdc465e21bd3c5792b59d575c1f Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:48:37 +0200 Subject: [PATCH 23/25] improve code --- .../resolveApiBaseOnProviderChange.test.ts | 41 +++ .../resolveApiBaseOnProviderChange.ts | 38 ++ .../aiCustomProviderFormUiController/index.ts | 3 + .../selectors.ts | 89 +++++ .../state.test.ts | 107 ++++++ .../aiCustomProviderFormUiController/state.ts | 169 +++++++++ .../thunks.ts | 215 ++++++++++++ web/src/core/usecases/index.ts | 2 + .../account/AccountAiTab/AccountAiTab.tsx | 156 ++++----- .../ConfirmCustomProviderDeletionDialog.tsx | 38 +- .../CustomProviderFormDialog.stories.tsx | 90 +++-- .../AccountAiTab/CustomProviderFormDialog.tsx | 328 +++++------------- .../account/AccountAiTab/ModelsSection.tsx | 15 +- 13 files changed, 895 insertions(+), 396 deletions(-) create mode 100644 web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.test.ts create mode 100644 web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.ts create mode 100644 web/src/core/usecases/aiCustomProviderFormUiController/index.ts create mode 100644 web/src/core/usecases/aiCustomProviderFormUiController/selectors.ts create mode 100644 web/src/core/usecases/aiCustomProviderFormUiController/state.test.ts create mode 100644 web/src/core/usecases/aiCustomProviderFormUiController/state.ts create mode 100644 web/src/core/usecases/aiCustomProviderFormUiController/thunks.ts diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.test.ts b/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.test.ts new file mode 100644 index 000000000..96e6fe02d --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { symToStr } from "tsafe/symToStr"; +import { resolveApiBaseOnProviderChange } from "./resolveApiBaseOnProviderChange"; + +describe(symToStr({ resolveApiBaseOnProviderChange }), () => { + it("uses the selected provider default when the field is empty", () => { + expect( + resolveApiBaseOnProviderChange({ + currentApiBase: "", + nextProvider: "mistral" + }) + ).toBe("https://api.mistral.ai/v1"); + }); + + it("replaces another provider default", () => { + expect( + resolveApiBaseOnProviderChange({ + currentApiBase: "https://api.openai.com/v1", + nextProvider: "anthropic" + }) + ).toBe("https://api.anthropic.com/v1"); + }); + + it("preserves a custom API base", () => { + expect( + resolveApiBaseOnProviderChange({ + currentApiBase: " https://llm.example.test/v1 ", + nextProvider: "openai" + }) + ).toBe(" https://llm.example.test/v1 "); + }); + + it("clears the field for an OpenAI-compatible provider", () => { + expect( + resolveApiBaseOnProviderChange({ + currentApiBase: "https://api.mistral.ai/v1", + nextProvider: "openai-compatible" + }) + ).toBe(""); + }); +}); diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.ts b/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.ts new file mode 100644 index 000000000..28ca638d0 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.ts @@ -0,0 +1,38 @@ +export const customProviderProtocolDefaultApiBase = { + openai: "https://api.openai.com/v1", + "openai-compatible": "", + mistral: "https://api.mistral.ai/v1", + anthropic: "https://api.anthropic.com/v1" +} as const; + +type CustomProviderProtocol = keyof typeof customProviderProtocolDefaultApiBase; + +export const customProviderProtocols = Object.keys( + customProviderProtocolDefaultApiBase +) as CustomProviderProtocol[]; + +export function isCustomProviderProtocol(value: string): value is CustomProviderProtocol { + return Object.prototype.hasOwnProperty.call( + customProviderProtocolDefaultApiBase, + value + ); +} + +export function resolveApiBaseOnProviderChange(params: { + currentApiBase: string; + nextProvider: CustomProviderProtocol; +}): string { + const { currentApiBase, nextProvider } = params; + + const currentApiBaseTrimmed = currentApiBase.trim(); + + const isUsingProviderDefaultApiBase = Object.values( + customProviderProtocolDefaultApiBase + ).some(defaultApiBase => defaultApiBase === currentApiBaseTrimmed); + + if (currentApiBaseTrimmed !== "" && !isUsingProviderDefaultApiBase) { + return currentApiBase; + } + + return customProviderProtocolDefaultApiBase[nextProvider]; +} diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/index.ts b/web/src/core/usecases/aiCustomProviderFormUiController/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/selectors.ts b/web/src/core/usecases/aiCustomProviderFormUiController/selectors.ts new file mode 100644 index 000000000..06f88fc96 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/selectors.ts @@ -0,0 +1,89 @@ +import { createSelector } from "clean-architecture"; +import type { State as RootState } from "core/bootstrap"; +import { customProviderProtocols } from "./decoupledLogic/resolveApiBaseOnProviderChange"; +import { name } from "./state"; + +const state = (rootState: RootState) => rootState[name]; + +const isFormSubmittable = createSelector(state, state => { + if (state.stateDescription !== "opened" || state.isSubmitting) { + return false; + } + + const { formValues, connectionTest } = state; + + return ( + formValues.name.trim() !== "" && + formValues.provider !== "" && + formValues.apiBase.trim() !== "" && + formValues.apiKey.trim() !== "" && + formValues.selectedModelId !== "" && + connectionTest.stateDescription === "success" && + connectionTest.models.some(model => model.id === formValues.selectedModelId) + ); +}); + +const canTestConnection = createSelector(state, state => { + if (state.stateDescription !== "opened" || state.isSubmitting) { + return false; + } + + return ( + state.formValues.provider !== "" && + state.formValues.apiBase.trim() !== "" && + state.formValues.apiKey.trim() !== "" + ); +}); + +const submittableForm = createSelector(state, isFormSubmittable, (state, isValid) => { + if (state.stateDescription !== "opened" || !isValid) { + return undefined; + } + + if (state.connectionTest.stateDescription !== "success") { + return undefined; + } + + return { + editedProviderId: state.editedProviderId, + name: state.formValues.name.trim(), + provider: state.formValues.provider, + apiBase: state.formValues.apiBase.trim(), + apiKey: state.formValues.apiKey.trim(), + models: state.connectionTest.models, + selectedModelId: state.formValues.selectedModelId, + doSetAsDefault: state.doSetAsDefault + }; +}); + +const main = createSelector( + state, + isFormSubmittable, + canTestConnection, + (state, canSubmit, canTest) => { + if (state.stateDescription === "closed") { + return { isOpen: false as const }; + } + + return { + isOpen: true as const, + isEditing: state.editedProviderId !== undefined, + isAlreadyDefault: state.isAlreadyDefault, + formValues: state.formValues, + connectionTest: state.connectionTest, + testedModels: + state.connectionTest.stateDescription === "success" + ? state.connectionTest.models + : undefined, + doSetAsDefault: state.doSetAsDefault, + isSubmitting: state.isSubmitting, + canSubmit, + canTest, + supportedProtocols: customProviderProtocols + }; + } +); + +export const privateSelectors = { state, canTestConnection, submittableForm }; + +export const selectors = { main }; diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/state.test.ts b/web/src/core/usecases/aiCustomProviderFormUiController/state.test.ts new file mode 100644 index 000000000..61e7184f2 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/state.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { actions, reducer, type State } from "./state"; + +function createOpenedState(params?: { + isAlreadyDefault?: boolean; + selectedModelId?: string; + connectionTest?: State.ConnectionTest; +}) { + return reducer( + undefined, + actions.opened({ + editedProviderId: undefined, + isAlreadyDefault: params?.isAlreadyDefault ?? false, + formValues: { + name: "My provider", + provider: "openai", + apiBase: "https://api.openai.com/v1", + apiKey: "secret", + selectedModelId: params?.selectedModelId ?? "model-1" + }, + connectionTest: params?.connectionTest ?? { + stateDescription: "success", + models: [{ id: "model-1", name: "Model 1" }] + }, + connectionTestRequestId: undefined, + doSetAsDefault: params?.isAlreadyDefault ?? false, + isSubmitting: false, + submissionRequestId: undefined + }) + ); +} + +describe("aiCustomProviderFormUiController state", () => { + it("invalidates the connection test when credentials change", () => { + const state = reducer( + createOpenedState(), + actions.formValueChanged({ key: "apiKey", value: "new-secret" }) + ); + + expect(state).toMatchObject({ + stateDescription: "opened", + formValues: { + apiKey: "new-secret", + selectedModelId: "" + }, + connectionTest: { stateDescription: "idle" } + }); + }); + + it("ignores a stale connection-test result", () => { + const state = reducer( + reducer( + createOpenedState({ + selectedModelId: "", + connectionTest: { stateDescription: "idle" } + }), + actions.connectionTestStarted({ requestId: "current-request" }) + ), + actions.connectionTestSucceeded({ + requestId: "stale-request", + models: [{ id: "model-2", name: "Model 2" }] + }) + ); + + expect(state).toMatchObject({ + stateDescription: "opened", + connectionTest: { stateDescription: "testing" }, + connectionTestRequestId: "current-request" + }); + }); + + it("does not allow an already-default provider to be unset", () => { + const state = reducer( + createOpenedState({ isAlreadyDefault: true }), + actions.doSetAsDefaultChanged({ doSetAsDefault: false }) + ); + + expect(state).toMatchObject({ + stateDescription: "opened", + doSetAsDefault: true + }); + }); + + it("closes only after the current submission succeeds", () => { + const submittingState = reducer( + createOpenedState(), + actions.submissionStarted({ requestId: "current-request" }) + ); + + const afterStaleSuccess = reducer( + submittingState, + actions.submissionSucceeded({ requestId: "stale-request" }) + ); + + expect(afterStaleSuccess).toMatchObject({ + stateDescription: "opened", + isSubmitting: true + }); + + expect( + reducer( + afterStaleSuccess, + actions.submissionSucceeded({ requestId: "current-request" }) + ) + ).toEqual({ stateDescription: "closed" }); + }); +}); diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/state.ts b/web/src/core/usecases/aiCustomProviderFormUiController/state.ts new file mode 100644 index 000000000..d4971a17e --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/state.ts @@ -0,0 +1,169 @@ +import { createUsecaseActions } from "clean-architecture"; +import { assert } from "tsafe/assert"; +import { id } from "tsafe/id"; + +export type State = State.Closed | State.Opened; + +export namespace State { + export type Closed = { stateDescription: "closed" }; + + export type Opened = { + stateDescription: "opened"; + editedProviderId: string | undefined; + isAlreadyDefault: boolean; + formValues: FormValues; + connectionTest: ConnectionTest; + connectionTestRequestId: string | undefined; + doSetAsDefault: boolean; + isSubmitting: boolean; + submissionRequestId: string | undefined; + }; + + export type FormValues = { + name: string; + provider: string; + apiBase: string; + apiKey: string; + selectedModelId: string; + }; + + export type AiModel = { id: string; name: string }; + + export type ConnectionTest = + | { stateDescription: "idle" } + | { stateDescription: "testing" } + | { stateDescription: "success"; models: AiModel[] } + | { stateDescription: "error" }; +} + +export type ChangeValueParams = { + key: keyof State.FormValues; + value: string; +}; + +export const name = "aiCustomProviderFormUiController"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id({ stateDescription: "closed" }), + reducers: { + opened: (_, { payload }: { payload: Omit }) => + id({ stateDescription: "opened", ...payload }), + closed: () => id({ stateDescription: "closed" }), + formValueChanged: (state, { payload }: { payload: ChangeValueParams }) => { + assert(state.stateDescription === "opened"); + + state.formValues[payload.key] = payload.value; + + if (payload.key !== "apiBase" && payload.key !== "apiKey") { + return; + } + + state.formValues.selectedModelId = ""; + state.connectionTest = { stateDescription: "idle" }; + state.connectionTestRequestId = undefined; + }, + providerChanged: ( + state, + { payload }: { payload: { provider: string; apiBase: string } } + ) => { + assert(state.stateDescription === "opened"); + + state.formValues.provider = payload.provider; + state.formValues.apiBase = payload.apiBase; + state.formValues.selectedModelId = ""; + state.connectionTest = { stateDescription: "idle" }; + state.connectionTestRequestId = undefined; + }, + doSetAsDefaultChanged: ( + state, + { payload }: { payload: { doSetAsDefault: boolean } } + ) => { + assert(state.stateDescription === "opened"); + + if (state.isAlreadyDefault) { + return; + } + + state.doSetAsDefault = payload.doSetAsDefault; + }, + connectionTestStarted: ( + state, + { payload }: { payload: { requestId: string } } + ) => { + assert(state.stateDescription === "opened"); + state.connectionTest = { stateDescription: "testing" }; + state.connectionTestRequestId = payload.requestId; + }, + connectionTestSucceeded: ( + state, + { + payload + }: { + payload: { requestId: string; models: State.AiModel[] }; + } + ) => { + if ( + state.stateDescription !== "opened" || + state.connectionTestRequestId !== payload.requestId + ) { + return; + } + + if ( + !payload.models.some( + model => model.id === state.formValues.selectedModelId + ) + ) { + state.formValues.selectedModelId = ""; + } + + state.connectionTest = { + stateDescription: "success", + models: payload.models + }; + state.connectionTestRequestId = undefined; + }, + connectionTestFailed: ( + state, + { payload }: { payload: { requestId: string } } + ) => { + if ( + state.stateDescription !== "opened" || + state.connectionTestRequestId !== payload.requestId + ) { + return; + } + + state.formValues.selectedModelId = ""; + state.connectionTest = { stateDescription: "error" }; + state.connectionTestRequestId = undefined; + }, + submissionStarted: (state, { payload }: { payload: { requestId: string } }) => { + assert(state.stateDescription === "opened"); + state.isSubmitting = true; + state.submissionRequestId = payload.requestId; + }, + submissionFailed: (state, { payload }: { payload: { requestId: string } }) => { + if ( + state.stateDescription !== "opened" || + state.submissionRequestId !== payload.requestId + ) { + return; + } + + state.isSubmitting = false; + state.submissionRequestId = undefined; + }, + submissionSucceeded: (state, { payload }: { payload: { requestId: string } }) => { + if ( + state.stateDescription !== "opened" || + state.submissionRequestId !== payload.requestId + ) { + return; + } + + return id({ stateDescription: "closed" }); + } + } +}); diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/thunks.ts b/web/src/core/usecases/aiCustomProviderFormUiController/thunks.ts new file mode 100644 index 000000000..fd2e8ff32 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/thunks.ts @@ -0,0 +1,215 @@ +import type { Thunks } from "core/bootstrap"; +import * as ai from "core/usecases/ai"; +import { assert } from "tsafe/assert"; +import { + isCustomProviderProtocol, + resolveApiBaseOnProviderChange +} from "./decoupledLogic/resolveApiBaseOnProviderChange"; +import { privateSelectors } from "./selectors"; +import { actions, type ChangeValueParams } from "./state"; + +function createEmptyFormValues() { + return { + name: "", + provider: "", + apiBase: "", + apiKey: "", + selectedModelId: "" + }; +} + +export const thunks = { + open: + (params: { providerId: string | undefined }) => + (...args) => { + const { providerId } = params; + const [dispatch, getState] = args; + + if (providerId === undefined) { + dispatch( + actions.opened({ + editedProviderId: undefined, + isAlreadyDefault: false, + formValues: createEmptyFormValues(), + connectionTest: { stateDescription: "idle" }, + connectionTestRequestId: undefined, + doSetAsDefault: false, + isSubmitting: false, + submissionRequestId: undefined + }) + ); + return; + } + + const aiState = ai.selectors.main(getState()); + assert(aiState.stateDescription === "initialized"); + + const provider = aiState.customProviders.find( + provider => provider.id === providerId + ); + assert(provider !== undefined); + + const models = + provider.models?.stateDescription === "loaded" + ? provider.models.availableModels + : undefined; + + const selectedModelId = + models?.some(model => model.id === provider.selectedModelId) === true + ? provider.selectedModelId + : undefined; + + dispatch( + actions.opened({ + editedProviderId: provider.id, + isAlreadyDefault: provider.isDefault, + formValues: { + name: provider.name, + provider: provider.provider, + apiBase: provider.apiBase, + apiKey: provider.apiKey, + selectedModelId: selectedModelId ?? "" + }, + connectionTest: + models === undefined + ? { stateDescription: "idle" } + : { stateDescription: "success", models }, + connectionTestRequestId: undefined, + doSetAsDefault: provider.isDefault, + isSubmitting: false, + submissionRequestId: undefined + }) + ); + }, + close: + () => + (...args) => { + const [dispatch] = args; + dispatch(actions.closed()); + }, + changeValue: + (params: ChangeValueParams) => + (...args) => { + const [dispatch] = args; + dispatch(actions.formValueChanged(params)); + }, + changeProvider: + (params: { provider: string }) => + (...args) => { + const { provider } = params; + const [dispatch, getState] = args; + + assert(isCustomProviderProtocol(provider)); + + const state = privateSelectors.state(getState()); + assert(state.stateDescription === "opened"); + + dispatch( + actions.providerChanged({ + provider, + apiBase: resolveApiBaseOnProviderChange({ + currentApiBase: state.formValues.apiBase, + nextProvider: provider + }) + }) + ); + }, + changeDoSetAsDefault: + (params: { doSetAsDefault: boolean }) => + (...args) => { + const [dispatch] = args; + dispatch(actions.doSetAsDefaultChanged(params)); + }, + testConnection: + () => + async (...args) => { + const [dispatch, getState] = args; + + const state = privateSelectors.state(getState()); + assert(state.stateDescription === "opened"); + + if ( + !privateSelectors.canTestConnection(getState()) || + state.connectionTest.stateDescription === "testing" + ) { + return; + } + + const requestId = crypto.randomUUID(); + const { provider, apiBase, apiKey } = state.formValues; + + dispatch(actions.connectionTestStarted({ requestId })); + + try { + const { models } = await dispatch( + ai.thunks.testCustomProviderConnection({ + provider, + apiBase, + apiKey + }) + ); + + dispatch(actions.connectionTestSucceeded({ requestId, models })); + } catch { + dispatch(actions.connectionTestFailed({ requestId })); + } + }, + submit: + () => + async (...args) => { + const [dispatch, getState] = args; + + const form = privateSelectors.submittableForm(getState()); + + if (form === undefined) { + return; + } + + const requestId = crypto.randomUUID(); + dispatch(actions.submissionStarted({ requestId })); + + const { + editedProviderId, + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + } = form; + + try { + if (editedProviderId === undefined) { + await dispatch( + ai.thunks.addCustomProvider({ + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + }) + ); + } else { + await dispatch( + ai.thunks.editCustomProvider({ + providerId: editedProviderId, + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + }) + ); + } + + dispatch(actions.submissionSucceeded({ requestId })); + } catch { + dispatch(actions.submissionFailed({ requestId })); + } + } +} satisfies Thunks; diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index dbc4835df..632a9e6e0 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -1,4 +1,5 @@ import * as ai from "./ai"; +import * as aiCustomProviderFormUiController from "./aiCustomProviderFormUiController"; import * as autoLogoutCountdown from "./autoLogoutCountdown"; import * as catalog from "./catalog"; import * as clusterEventsMonitor from "./clusterEventsMonitor"; @@ -28,6 +29,7 @@ import * as s3ExplorerUiController from "./s3ExplorerUiController"; export const usecases = { ai, + aiCustomProviderFormUiController, autoLogoutCountdown, catalog, clusterEventsMonitor, diff --git a/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx index e437c696a..b3c6daed8 100644 --- a/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from "react"; +import { memo } from "react"; import { useTranslation } from "ui/i18n"; import openWebUiIconUrl from "ui/assets/img/openWebUiIcon.png"; import { LocalizedMarkdown } from "ui/shared/Markdown"; @@ -8,9 +8,11 @@ import { useConst } from "powerhooks/useConst"; import { copyToClipboard } from "ui/tools/copyToClipboard"; import { tss } from "tss"; import { alpha } from "@mui/material/styles"; +import ButtonBase from "@mui/material/ButtonBase"; import { declareComponentKeys } from "i18nifty"; import { Evt } from "evt"; import type { UnpackEvt } from "evt"; +import { Deferred } from "evt/tools/Deferred"; import { Icon } from "onyxia-ui/Icon"; import { CircularProgress } from "onyxia-ui/CircularProgress"; import { Button } from "onyxia-ui/Button"; @@ -19,12 +21,12 @@ import { useCoreState, getCoreSync } from "core"; import { getIconUrlByName } from "lazy-icons"; import { ProviderValueField } from "./ProviderValueField"; import { ModelsSection } from "./ModelsSection"; +import { CustomProviderFormDialog } from "./CustomProviderFormDialog"; import { - CustomProviderFormDialog, - type Props as CustomProviderFormDialogProps -} from "./CustomProviderFormDialog"; -import { assert } from "tsafe"; -import { ConfirmCustomProviderDeletionDialog } from "./ConfirmCustomProviderDeletionDialog"; + ConfirmCustomProviderDeletionDialog, + type Props as ConfirmCustomProviderDeletionDialogProps +} from "./ConfirmCustomProviderDeletionDialog"; +import { Divider } from "@mui/material"; export type Props = { className?: string; @@ -36,7 +38,7 @@ export const AccountAiTab = memo((props: Props) => { const { classes } = useStyles(); const { - functions: { ai } + functions: { ai, aiCustomProviderFormUiController } } = getCoreSync(); const { stateDescription, regionProviders, customProviders } = useCoreState( @@ -46,14 +48,10 @@ export const AccountAiTab = memo((props: Props) => { const { t } = useTranslation({ AccountAiGatewayTab: AccountAiTab }); - const evtCustomProviderFormDialogOpen = useConst(() => - Evt.create>() + const evtConfirmDeleteDialogOpen = useConst(() => + Evt.create>() ); - const [providerIdPendingDeletion, setProviderIdPendingDeletion] = useState< - string | undefined - >(undefined); - const onFieldRequestCopyFactory = useCallbackFactory(([text]: [string]) => copyToClipboard(text) ); @@ -68,42 +66,29 @@ export const AccountAiTab = memo((props: Props) => { }) ); - const onDeleteCustomProviderFactory = useCallbackFactory(([providerId]: [string]) => - setProviderIdPendingDeletion(providerId) - ); + const onDeleteCustomProviderFactory = useCallbackFactory( + async ([providerId]: [string]) => { + const dDoProceed = new Deferred(); - const onConfirmCustomProviderDeletion = useConstCallback(() => { - assert(providerIdPendingDeletion !== undefined); + evtConfirmDeleteDialogOpen.post({ + resolveDoProceed: dDoProceed.resolve + }); - const providerId = providerIdPendingDeletion; + if (!(await dDoProceed.pr)) { + return; + } - setProviderIdPendingDeletion(undefined); - void ai.deleteCustomProvider({ providerId }); - }); + await ai.deleteCustomProvider({ providerId }); + } + ); const onAddClick = useConstCallback(() => - evtCustomProviderFormDialogOpen.post({ editedProvider: undefined }) + aiCustomProviderFormUiController.open({ providerId: undefined }) ); - const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => { - const provider = customProviders.find(p => p.id === providerId); - assert(provider !== undefined); - evtCustomProviderFormDialogOpen.post({ - editedProvider: { - id: provider.id, - name: provider.name, - provider: provider.provider, - apiBase: provider.apiBase, - apiKey: provider.apiKey, - availableModels: - provider.models?.stateDescription === "loaded" - ? provider.models.availableModels - : undefined, - selectedModelId: provider.selectedModelId, - isDefault: provider.isDefault - } - }); - }); + const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => + aiCustomProviderFormUiController.open({ providerId }) + ); if (stateDescription !== "initialized") { return stateDescription === "error" ? ( @@ -274,9 +259,14 @@ export const AccountAiTab = memo((props: Props) => { isSensitiveInformation={true} /> + ai.setSelectedModel({ + providerId: regionProvider.id, + modelId + }) + } />
)} @@ -284,7 +274,7 @@ export const AccountAiTab = memo((props: Props) => { ))}
-
+
{t("custom providers section title")} @@ -336,40 +326,33 @@ export const AccountAiTab = memo((props: Props) => { isSensitiveInformation={true} /> + ai.setSelectedModel({ + providerId: provider.id, + modelId + }) + } />
))} - +
- - setProviderIdPendingDeletion(undefined)} - onConfirm={onConfirmCustomProviderDeletion} - /> + +
); }); @@ -426,8 +409,6 @@ const useStyles = tss flexDirection: "column" }, customProvidersDivider: { - height: 1, - backgroundColor: theme.colors.useCases.surfaces.surface2, marginBottom: theme.spacing(3) }, customProvidersHeader: { @@ -440,7 +421,7 @@ const useStyles = tss }, providerCard: { border: `1px solid ${theme.colors.useCases.typography.textDisabled}`, - borderRadius: theme.spacing(1), + borderRadius: theme.spacing(3), padding: theme.spacing(3), marginTop: theme.spacing(3) }, @@ -488,43 +469,26 @@ const useStyles = tss paddingRight: theme.spacing(3) }, addCustomProviderAction: { - width: "100%", - minHeight: 76, - display: "flex", - alignItems: "center", - gap: 24, - marginTop: 24, - padding: 24, - boxSizing: "border-box", - appearance: "none", - border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, - borderRadius: 16, - backgroundColor: "transparent", - color: theme.colors.useCases.typography.textPrimary, - font: "inherit", + justifyContent: "flex-start", + gap: theme.spacing(3), + marginTop: theme.spacing(3), + padding: theme.spacing(3), + borderWidth: theme.spacing(0.5), + borderStyle: "solid", + borderColor: theme.colors.useCases.surfaces.surface2, + borderRadius: theme.spacing(2), textAlign: "left", - cursor: "pointer", - transition: "background-color 120ms ease, border-color 120ms ease", + transition: theme.muiTheme.transitions.create("background-color"), "&:hover": { backgroundColor: alpha(theme.colors.useCases.surfaces.surface2, 0.4) }, - "&:active": { - backgroundColor: theme.colors.useCases.surfaces.surface2 - }, "&:focus-visible": { - outline: `2px solid ${theme.colors.useCases.typography.textFocus}`, - outlineOffset: 2 + outlineWidth: theme.spacing(0.5), + outlineStyle: "solid", + outlineColor: theme.colors.useCases.typography.textFocus, + outlineOffset: theme.spacing(0.5) } }, - addCustomProviderIcon: { - flexShrink: 0, - width: 24, - height: 24 - }, - addCustomProviderLabel: { - flex: 1, - minWidth: 0 - }, noAccountCard: { maxWidth: 759, display: "flex", diff --git a/web/src/ui/pages/account/AccountAiTab/ConfirmCustomProviderDeletionDialog.tsx b/web/src/ui/pages/account/AccountAiTab/ConfirmCustomProviderDeletionDialog.tsx index e672d06fb..4de8df074 100644 --- a/web/src/ui/pages/account/AccountAiTab/ConfirmCustomProviderDeletionDialog.tsx +++ b/web/src/ui/pages/account/AccountAiTab/ConfirmCustomProviderDeletionDialog.tsx @@ -1,39 +1,59 @@ -import { memo } from "react"; +import { memo, useState } from "react"; import { Dialog } from "onyxia-ui/Dialog"; import { Button } from "onyxia-ui/Button"; import { getIconUrlByName } from "lazy-icons"; import { declareComponentKeys } from "i18nifty"; import { useTranslation } from "ui/i18n"; import { tss } from "tss"; +import type { NonPostableEvt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { assert } from "tsafe/assert"; export type Props = { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; + evtOpen: NonPostableEvt<{ + resolveDoProceed: (doProceed: boolean) => void; + }>; }; export const ConfirmCustomProviderDeletionDialog = memo((props: Props) => { - const { isOpen, onClose, onConfirm } = props; + const { evtOpen } = props; const { t } = useTranslation({ ConfirmCustomProviderDeletionDialog }); const { classes } = useStyles(); + + const [state, setState] = useState | undefined>(undefined); + + useEvt(ctx => evtOpen.attach(ctx, setState), [evtOpen]); + + const onCloseFactory = useCallbackFactory(([doProceed]: [boolean]) => { + assert(state !== undefined); + + state.resolveDoProceed(doProceed); + setState(undefined); + }); + return ( - diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx index 75a32c649..f69472016 100644 --- a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx @@ -1,53 +1,81 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { Evt, type UnpackEvt } from "evt"; -import { CustomProviderFormDialog, type Props } from "./CustomProviderFormDialog"; +import { CustomProviderFormDialogView, type ViewProps } from "./CustomProviderFormDialog"; const meta = { title: "Pages/Account/CustomProviderFormDialog", - component: CustomProviderFormDialog, + component: CustomProviderFormDialogView, parameters: { layout: "fullscreen" } -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; -const evtOpenDefault = Evt.create>(); +const doNothing = () => {}; + +const commonArgs: Pick< + ViewProps, + | "supportedProtocols" + | "onClose" + | "onFieldChange" + | "onProviderChange" + | "onTest" + | "onSave" + | "onDoSetAsDefaultChange" +> = { + supportedProtocols: ["openai", "openai-compatible", "mistral", "anthropic"], + onClose: doNothing, + onFieldChange: doNothing, + onProviderChange: doNothing, + onTest: doNothing, + onSave: doNothing, + onDoSetAsDefaultChange: doNothing +}; export const Default: Story = { args: { - evtOpen: evtOpenDefault - }, - play: () => { - evtOpenDefault.post({ editedProvider: undefined }); + ...commonArgs, + isEditing: false, + isAlreadyDefault: false, + values: { + name: "", + provider: "", + apiBase: "", + apiKey: "", + selectedModelId: "" + }, + test: { stateDescription: "idle" }, + doSetAsDefault: false, + canSave: false, + canTest: false } }; -const evtOpenFilled = Evt.create>(); - export const Filled: Story = { args: { - evtOpen: evtOpenFilled - }, - play: () => { - evtOpenFilled.post({ - editedProvider: { - id: "custom-provider-1", - name: "Custom Provider 1", - provider: "openai", - apiBase: "https://llm.example.test/api", - apiKey: "storybook-api-key", - availableModels: [ - { id: "gemma4-26b-moe", name: "gemma4-26b-moe" }, - { id: "qwen3-6-35b-mo", name: "qwen3-6-35b-mo" }, - { id: "qwen3-embedding-8b", name: "qwen3-embedding-8b" }, - { id: "qwen3-vl", name: "qwen3-vl" } - ], - selectedModelId: "qwen3-vl", - isDefault: false - } - }); + ...commonArgs, + isEditing: true, + isAlreadyDefault: false, + values: { + name: "Custom Provider 1", + provider: "openai", + apiBase: "https://llm.example.test/api", + apiKey: "storybook-api-key", + selectedModelId: "qwen3-vl" + }, + test: { + stateDescription: "success", + models: [ + { id: "gemma4-26b-moe", name: "gemma4-26b-moe" }, + { id: "qwen3-6-35b-mo", name: "qwen3-6-35b-mo" }, + { id: "qwen3-embedding-8b", name: "qwen3-embedding-8b" }, + { id: "qwen3-vl", name: "qwen3-vl" } + ] + }, + doSetAsDefault: false, + canSave: true, + canTest: true } }; diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx index 156b538c4..53faf2671 100644 --- a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx @@ -4,8 +4,6 @@ import MenuItem from "@mui/material/MenuItem"; import Select from "@mui/material/Select"; import Checkbox from "@mui/material/Checkbox"; import { alpha } from "@mui/material/styles"; -import type { NonPostableEvt, UnpackEvt } from "evt"; -import { useEvt } from "evt/hooks"; import { declareComponentKeys } from "i18nifty"; import { getIconUrlByName } from "lazy-icons"; import { Button } from "onyxia-ui/Button"; @@ -13,43 +11,14 @@ import { CircularProgress } from "onyxia-ui/CircularProgress"; import { Icon } from "onyxia-ui/Icon"; import { IconButton } from "onyxia-ui/IconButton"; import { Text } from "onyxia-ui/Text"; -import { useConstCallback } from "powerhooks/useConstCallback"; -import { - memo, - useEffect, - useId, - useRef, - useState, - type MouseEvent, - type ReactNode -} from "react"; -import { assert } from "tsafe/assert"; +import { memo, useEffect, useId, type MouseEvent, type ReactNode } from "react"; import { keyframes } from "tss-react"; -import { getCoreSync } from "core"; +import { getCoreSync, useCoreState } from "core"; import { tss } from "tss"; import { useTranslation } from "ui/i18n"; type AiModel = { id: string; name: string }; -export type Props = { - evtOpen: NonPostableEvt<{ - editedProvider: - | { - id: string; - name: string; - provider: string; - apiBase: string; - apiKey: string; - availableModels: AiModel[] | undefined; - selectedModelId: string | undefined; - isDefault: boolean; - } - | undefined; - }>; -}; - -type OpenParams = UnpackEvt; - type FormValues = { name: string; provider: string; @@ -58,229 +27,90 @@ type FormValues = { selectedModelId: string; }; -const providerProtocolDefaultApiBase = { - openai: "https://api.openai.com/v1", - "openai-compatible": "", - mistral: "https://api.mistral.ai/v1", - anthropic: "https://api.anthropic.com/v1" -} as const; - -type ProviderProtocol = keyof typeof providerProtocolDefaultApiBase; - type FormTest = | { stateDescription: "idle" } | { stateDescription: "testing" } | { stateDescription: "success"; models: AiModel[] } | { stateDescription: "error" }; -const defaultFormValues: FormValues = { - name: "", - provider: "", - apiBase: "", - apiKey: "", - selectedModelId: "" +export type ViewProps = { + isEditing: boolean; + isAlreadyDefault: boolean; + values: FormValues; + test: FormTest; + doSetAsDefault: boolean; + canSave: boolean; + canTest: boolean; + supportedProtocols: readonly string[]; + onClose: () => void; + onFieldChange: (key: keyof FormValues, value: string) => void; + onProviderChange: (provider: string) => void; + onTest: () => void; + onSave: () => void; + onDoSetAsDefaultChange: (doSetAsDefault: boolean) => void; }; -export const CustomProviderFormDialog = memo((props: Props) => { - const { evtOpen } = props; - - const { classes } = useStyles(); - const { t } = useTranslation({ CustomProviderFormDialog }); - - const [openState, setOpenState] = useState< - | { - editedProviderId: string | undefined; - isAlreadyDefault: boolean; - } - | undefined - >(undefined); - const [values, setValues] = useState(defaultFormValues); - const [test, setTest] = useState({ stateDescription: "idle" }); - const [doSetAsDefault, setDoSetAsDefault] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const testRequestIdRef = useRef(0); - - useEvt( - ctx => { - evtOpen.attach(ctx, ({ editedProvider }: OpenParams) => { - testRequestIdRef.current++; - - if (editedProvider === undefined) { - setValues(defaultFormValues); - setTest({ stateDescription: "idle" }); - setDoSetAsDefault(false); - setOpenState({ - editedProviderId: undefined, - isAlreadyDefault: false - }); - } else { - const selectedModelId = - editedProvider.availableModels?.some( - model => model.id === editedProvider.selectedModelId - ) === true - ? editedProvider.selectedModelId - : undefined; - - setValues({ - name: editedProvider.name, - provider: editedProvider.provider, - apiBase: editedProvider.apiBase, - apiKey: editedProvider.apiKey, - selectedModelId: selectedModelId ?? "" - }); - setTest( - editedProvider.availableModels === undefined - ? { stateDescription: "idle" } - : { - stateDescription: "success", - models: editedProvider.availableModels - } - ); - setDoSetAsDefault(editedProvider.isDefault); - setOpenState({ - editedProviderId: editedProvider.id, - isAlreadyDefault: editedProvider.isDefault - }); - } - - setIsSaving(false); - }); - }, - [evtOpen] - ); - - const isEditing = openState?.editedProviderId !== undefined; - const testedModels = test.stateDescription === "success" ? test.models : undefined; - const canSave = - values.name.trim() !== "" && - values.provider !== "" && - values.apiBase.trim() !== "" && - values.apiKey.trim() !== "" && - values.selectedModelId !== "" && - testedModels?.some(model => model.id === values.selectedModelId) === true && - !isSaving; - const canTest = - values.provider !== "" && - values.apiBase.trim() !== "" && - values.apiKey.trim() !== "" && - !isSaving; - - const onClose = useConstCallback(() => { - testRequestIdRef.current++; - setOpenState(undefined); - }); +export const CustomProviderFormDialog = memo(() => { + const form = useCoreState("aiCustomProviderFormUiController", "main"); - const onFieldChange = useConstCallback((key: keyof FormValues, value: string) => { - setValues(values => ({ ...values, [key]: value })); - - if (key === "apiBase" || key === "apiKey") { - testRequestIdRef.current++; - setValues(values => ({ ...values, selectedModelId: "" })); - setTest({ stateDescription: "idle" }); - } - }); - - const onProviderChange = useConstCallback((provider: ProviderProtocol) => { - testRequestIdRef.current++; - setValues(values => { - const apiBase = values.apiBase.trim(); - const isUsingProviderDefaultApiBase = Object.values( - providerProtocolDefaultApiBase - ).some(defaultApiBase => defaultApiBase === apiBase); - - return { - ...values, - provider, - apiBase: - apiBase === "" || isUsingProviderDefaultApiBase - ? providerProtocolDefaultApiBase[provider] - : values.apiBase, - selectedModelId: "" - }; - }); - setTest({ stateDescription: "idle" }); - }); - - const onTest = useConstCallback(async () => { - if (!canTest || test.stateDescription === "testing") { - return; - } + const { + functions: { aiCustomProviderFormUiController } + } = getCoreSync(); - const { - functions: { ai } - } = getCoreSync(); - - const testRequestId = ++testRequestIdRef.current; - setTest({ stateDescription: "testing" }); - - try { - const { models } = await ai.testCustomProviderConnection({ - provider: values.provider, - apiBase: values.apiBase, - apiKey: values.apiKey - }); + if (!form.isOpen) { + return null; + } - if (testRequestId !== testRequestIdRef.current) { - return; + return ( + aiCustomProviderFormUiController.close()} + onFieldChange={(key, value) => + aiCustomProviderFormUiController.changeValue({ key, value }) } - - setValues(values => ({ - ...values, - selectedModelId: models.some(model => model.id === values.selectedModelId) - ? values.selectedModelId - : "" - })); - setTest({ stateDescription: "success", models }); - } catch { - if (testRequestId !== testRequestIdRef.current) { - return; + onProviderChange={provider => + aiCustomProviderFormUiController.changeProvider({ provider }) } - - setValues(values => ({ ...values, selectedModelId: "" })); - setTest({ stateDescription: "error" }); - } - }); - - const onSave = useConstCallback(async () => { - assert(openState !== undefined); - assert(test.stateDescription === "success"); - assert(values.selectedModelId !== ""); - - const { - functions: { ai } - } = getCoreSync(); - - setIsSaving(true); - - const params = { - name: values.name.trim(), - provider: values.provider, - apiBase: values.apiBase.trim(), - apiKey: values.apiKey.trim(), - models: test.models, - selectedModelId: values.selectedModelId, - doSetAsDefault - }; - - try { - if (openState.editedProviderId === undefined) { - await ai.addCustomProvider(params); - } else { - await ai.editCustomProvider({ - providerId: openState.editedProviderId, - ...params - }); + onTest={() => void aiCustomProviderFormUiController.testConnection()} + onSave={() => void aiCustomProviderFormUiController.submit()} + onDoSetAsDefaultChange={doSetAsDefault => + aiCustomProviderFormUiController.changeDoSetAsDefault({ + doSetAsDefault + }) } + /> + ); +}); - onClose(); - } catch { - setIsSaving(false); - } - }); +export const CustomProviderFormDialogView = memo((props: ViewProps) => { + const { + isEditing, + isAlreadyDefault, + values, + test, + doSetAsDefault, + canSave, + canTest, + supportedProtocols, + onClose, + onFieldChange, + onProviderChange, + onTest, + onSave, + onDoSetAsDefaultChange + } = props; - if (openState === undefined) { - return null; - } + const { classes } = useStyles(); + const { t } = useTranslation({ CustomProviderFormDialog }); + + const testedModels = test.stateDescription === "success" ? test.models : undefined; return ( { return; } - void onSave(); + onSave(); }} noValidate={true} > @@ -319,9 +149,7 @@ export const CustomProviderFormDialog = memo((props: Props) => { - onProviderChange(value as ProviderProtocol) - } + onChange={onProviderChange} options={[ { value: "openai", @@ -339,7 +167,9 @@ export const CustomProviderFormDialog = memo((props: Props) => { value: "anthropic", label: t("anthropic provider option") } - ]} + ].filter(({ value }) => + supportedProtocols.includes(value) + )} />
@@ -378,7 +208,7 @@ export const CustomProviderFormDialog = memo((props: Props) => { className={classes.testButton} startIcon={getIconUrlByName("SatelliteAlt")} disabled={!canTest} - onClick={() => void onTest()} + onClick={onTest} > {t("provider test")} @@ -418,8 +248,10 @@ export const CustomProviderFormDialog = memo((props: Props) => { setDoSetAsDefault(event.target.checked)} + disabled={isAlreadyDefault} + onChange={event => + onDoSetAsDefaultChange(event.target.checked) + } size="small" /> {t("set as default provider")} diff --git a/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx index 6a0a04ea2..4be6b2914 100644 --- a/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx +++ b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx @@ -2,10 +2,8 @@ import { memo } from "react"; import { useTranslation } from "ui/i18n"; import { tss } from "tss"; import { declareComponentKeys } from "i18nifty"; -import { useConstCallback } from "powerhooks/useConstCallback"; import { CircularProgress } from "onyxia-ui/CircularProgress"; import { Text } from "onyxia-ui/Text"; -import { getCoreSync } from "core"; import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; @@ -18,23 +16,16 @@ export type Models = | undefined; export type Props = { - providerId: string; models: Models; selectedModel: string | undefined; + onSelectedModelChange: (modelId: string) => void | Promise; }; export const ModelsSection = memo((props: Props) => { - const { providerId, models, selectedModel } = props; + const { models, selectedModel, onSelectedModelChange } = props; const { classes } = useStyles(); const { t } = useTranslation({ ModelsSection }); - const { - functions: { ai } - } = getCoreSync(); - - const onModelChange = useConstCallback((event: { target: { value: string } }) => - ai.setSelectedModel({ providerId, modelId: event.target.value }) - ); if (models === undefined) { return null; @@ -56,7 +47,7 @@ export const ModelsSection = memo((props: Props) => {
onChange(event.target.value)} - type={isSensitive ? "password" : "text"} - fullWidth={true} - disableUnderline={true} - autoComplete={autoComplete} - inputProps={{ "aria-label": label }} - /> - ); -} - -function FormSelectField(props: { - label: string; - value: string; - onChange: (value: string) => void; - options: { value: string; label: string }[]; -}) { - const { label, value, onChange, options } = props; - const { classes } = useStyles_FormField(); - - return ( - - - value={value} - displayEmpty={true} - onChange={event => onChange(event.target.value)} - inputProps={{ "aria-label": label }} - renderValue={selectedValue => - selectedValue === "" - ? label - : (options.find(option => option.value === selectedValue) - ?.label ?? selectedValue) - } - > - {options.map(option => ( - - {option.label} - - ))} - - - ); -} - -function ModelSelectField(props: { - label: string; - value: string; - onChange: (value: string) => void; - models: AiModel[]; - disabled: boolean; -}) { - const { label, value, onChange, models, disabled } = props; - const { classes } = useStyles_FormField(); - - const renderModel = (model: AiModel) => ( - - - {model.name} - - ); - - return ( - - - value={value} - displayEmpty={true} - onChange={event => onChange(event.target.value)} - inputProps={{ "aria-label": label }} - renderValue={selectedValue => { - if (selectedValue === "") { - return label; - } - - const model = models.find(model => model.id === selectedValue); - - return model === undefined ? selectedValue : renderModel(model); - }} - MenuProps={{ - PaperProps: { className: classes.modelMenu } - }} - > - {models.map(model => ( - - {renderModel(model)} - - ))} - - - ); -} - -function StatusMessage(props: { severity: "success" | "error"; children: ReactNode }) { - const { severity, children } = props; - const { classes, cx } = useStyles_StatusMessage(); - - return ( -
- - {children} -
- ); -} - const { i18n } = declareComponentKeys< | "add custom provider title" | "edit custom provider title" @@ -520,339 +73,5 @@ const { i18n } = declareComponentKeys< | "provider cancel" | "close aria label" >()({ CustomProviderFormDialog }); -export type I18n = typeof i18n; - -const useStyles = tss.withName({ CustomProviderFormDialog }).create(({ theme }) => ({ - root: { - height: "100%", - minHeight: 0, - display: "flex", - flexDirection: "column", - color: theme.colors.useCases.typography.textPrimary - }, - body: { - flex: 1, - minHeight: 0, - overflowY: "auto", - paddingRight: theme.spacing(0.5) - }, - section: { - minWidth: 0, - paddingBottom: theme.spacing(4), - borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}`, - "& + &": { - marginTop: theme.spacing(4) - }, - "&:last-child": { - borderBottom: "none", - paddingBottom: 0 - } - }, - fields: { - display: "flex", - flexDirection: "column", - gap: theme.spacing(3), - marginTop: theme.spacing(4) + 6 - }, - verificationHeadingRow: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing(2), - marginBottom: theme.spacing(5) - }, - testButton: { - flex: "none", - "&&": { - minHeight: 40, - borderColor: "transparent", - backgroundColor: theme.colors.useCases.typography.textPrimary, - color: theme.colors.getUseCases({ isDarkModeEnabled: false }).surfaces - .surface1 - }, - "&&:hover": { - borderColor: "transparent", - backgroundColor: theme.colors.useCases.typography.textSecondary, - color: theme.colors.getUseCases({ isDarkModeEnabled: false }).surfaces - .surface1 - }, - "&&.Mui-disabled": { - borderColor: "transparent", - backgroundColor: theme.colors.useCases.buttons.actionDisabledBackground, - color: theme.colors.useCases.typography.textDisabled - } - }, - testingMessage: { - display: "flex", - alignItems: "center", - gap: theme.spacing(1.5), - minHeight: 40, - marginTop: theme.spacing(2), - paddingLeft: theme.spacing(2) - }, - footer: { - flex: "none", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing(2), - paddingTop: theme.spacing(3), - marginTop: theme.spacing(2) - }, - defaultProviderControl: { - display: "flex", - alignItems: "center", - gap: theme.spacing(1.5), - cursor: "pointer" - }, - checkbox: { - padding: 0, - color: theme.colors.useCases.typography.textPrimary, - "&.Mui-checked": { - color: theme.colors.useCases.buttons.actionActive - } - }, - actions: { - display: "flex", - alignItems: "center", - justifyContent: "flex-end", - gap: theme.spacing(1) - }, - cancelButton: { - "&&": { - borderColor: "transparent", - backgroundColor: theme.colors.useCases.surfaces.surface2 - }, - "&&:hover": { - borderColor: "transparent", - backgroundColor: theme.colors.useCases.buttons.actionHoverSecondary - } - }, - submitButton: { - "&&": { - borderColor: "transparent", - backgroundColor: theme.colors.useCases.buttons.actionHoverPrimary, - color: theme.colors.getUseCases({ isDarkModeEnabled: true }).typography - .textPrimary - }, - "&&:hover": { - borderColor: "transparent", - backgroundColor: theme.colors.useCases.buttons.actionActive, - color: theme.colors.getUseCases({ isDarkModeEnabled: true }).typography - .textPrimary - }, - "&&.Mui-disabled": { - borderColor: "transparent", - backgroundColor: alpha( - theme.colors.useCases.buttons.actionHoverPrimary, - 0.35 - ), - color: theme.colors.getUseCases({ isDarkModeEnabled: true }).typography - .textPrimary - } - } -})); -const useStyles_SideDialog = tss.withName({ SideDialog }).create(({ theme }) => ({ - root: { - position: "fixed", - inset: 0, - zIndex: theme.muiTheme.zIndex.modal, - marginRight: theme.spacing(3), - display: "flex", - justifyContent: "flex-end", - alignItems: "stretch", - padding: `64px ${theme.spacing(2)}px 32px`, - boxSizing: "border-box", - backgroundColor: alpha(theme.colors.useCases.surfaces.background, 0.72), - backdropFilter: "blur(1px)", - "@media (max-width: 720px)": { - marginRight: 0, - padding: 0 - } - }, - panel: { - width: 657, - maxWidth: "100%", - minHeight: 0, - display: "flex", - flexDirection: "column", - overflow: "hidden", - borderRadius: 16, - border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, - backgroundColor: theme.colors.useCases.surfaces.surface1, - boxShadow: theme.shadows[4], - animation: `${keyframes` - from { - opacity: 0; - transform: translateX(28px); - } - to { - opacity: 1; - transform: translateX(0); - } - `} 340ms cubic-bezier(0.2, 0, 0, 1)`, - "@media (max-width: 720px)": { - borderRadius: 0, - borderTop: "none", - borderBottom: "none" - } - }, - headingWrapper: { - flex: "none", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing(2), - margin: `0 ${theme.spacing(5)}px`, - padding: `${theme.spacing(5)}px 0 ${theme.spacing(2)}px`, - borderBottom: `1px solid ${theme.colors.useCases.typography.textSecondary}` - }, - title: { - minWidth: 0, - color: theme.colors.useCases.typography.textPrimary - }, - closeButton: { - flex: "none" - }, - childrenWrapper: { - flex: 1, - minHeight: 0, - overflow: "hidden", - padding: `${theme.spacing(5)}px ${theme.spacing(5)}px ${theme.spacing(4)}px`, - boxSizing: "border-box" - } -})); - -const useStyles_SectionHeading = tss.withName({ SectionHeading }).create(({ theme }) => ({ - root: { - minWidth: 0, - display: "flex", - flexDirection: "column", - gap: theme.spacing(0.5) - }, - title: { - color: theme.colors.useCases.typography.textPrimary - }, - subtitle: { - color: theme.colors.useCases.typography.textSecondary - } -})); - -const useStyles_FormField = tss.withName({ FormTextField }).create(({ theme }) => { - const fieldBackground = theme.colors.useCases.surfaces.background; - - return { - input: { - minHeight: 45, - borderRadius: 8, - border: "2px solid transparent", - backgroundColor: fieldBackground, - transition: "border-color 160ms ease, background-color 160ms ease", - "&:hover": { - backgroundColor: theme.colors.useCases.surfaces.surface2 - }, - "&.Mui-focused": { - borderColor: theme.colors.useCases.buttons.actionActive - }, - "& .MuiInputBase-input": { - ...theme.typography.variants["body 2"].style, - padding: `${theme.spacing(1.25)}px ${theme.spacing(1.5)}px`, - color: theme.colors.useCases.typography.textPrimary, - "&::placeholder": { - color: theme.colors.useCases.typography.textSecondary, - opacity: 1 - } - } - }, - selectControl: { - "& .MuiInputBase-root": { - minHeight: 45, - borderRadius: 8, - backgroundColor: fieldBackground, - color: theme.colors.useCases.typography.textPrimary - }, - "& .MuiOutlinedInput-notchedOutline": { - border: "2px solid transparent" - }, - "& .MuiInputBase-root:hover": { - backgroundColor: theme.colors.useCases.surfaces.surface2 - }, - "& .Mui-focused .MuiOutlinedInput-notchedOutline": { - borderColor: theme.colors.useCases.buttons.actionActive - }, - "& .MuiSelect-select": { - ...theme.typography.variants["body 2"].style, - display: "flex", - alignItems: "center", - minHeight: "unset", - padding: `${theme.spacing(1.25)}px ${theme.spacing(5)}px ${theme.spacing( - 1.25 - )}px ${theme.spacing(1.5)}px` - }, - "& .MuiSelect-icon": { - color: theme.colors.useCases.typography.textPrimary, - right: theme.spacing(1.5) - }, - "& .Mui-disabled": { - color: theme.colors.useCases.typography.textDisabled, - WebkitTextFillColor: theme.colors.useCases.typography.textDisabled - } - }, - modelOption: { - minWidth: 0, - display: "flex", - alignItems: "center", - gap: theme.spacing(1) - }, - modelIcon: { - flex: "none", - color: theme.colors.useCases.buttons.actionActive - }, - modelMenu: { - marginTop: theme.spacing(0.5), - padding: theme.spacing(0.5), - borderRadius: 8, - backgroundColor: theme.colors.useCases.surfaces.surface1 - }, - modelMenuItem: { - minHeight: 32, - borderRadius: 6, - "&.Mui-selected": { - backgroundColor: theme.colors.useCases.surfaces.surface2 - } - } - }; -}); - -const useStyles_StatusMessage = tss.withName({ StatusMessage }).create(({ theme }) => ({ - root: { - minHeight: 40, - display: "flex", - alignItems: "center", - gap: theme.spacing(1.5), - marginTop: theme.spacing(2), - padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, - borderRadius: 8, - boxSizing: "border-box", - color: theme.colors.useCases.typography.textPrimary - }, - success: { - backgroundColor: alpha(theme.colors.useCases.alertSeverity.success.main, 0.2) - }, - error: { - backgroundColor: alpha(theme.colors.useCases.alertSeverity.error.main, 0.2) - }, - dot: { - flex: "none", - width: 16, - height: 16, - borderRadius: "50%" - }, - dotSuccess: { - backgroundColor: theme.colors.useCases.alertSeverity.success.main - }, - dotError: { - backgroundColor: theme.colors.useCases.alertSeverity.error.main - } -})); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/CustomProviderFormDialogView.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/CustomProviderFormDialogView.tsx new file mode 100644 index 000000000..6f4a933c6 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/CustomProviderFormDialogView.tsx @@ -0,0 +1,136 @@ +import Checkbox from "@mui/material/Checkbox"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { memo, type FormEventHandler } from "react"; +import { tss } from "tss"; +import { useTranslation } from "ui/i18n"; +import { CredentialsSection, ProviderSection, VerificationSection } from "./FormSections"; +import { SideDialog } from "./SideDialog"; +import type { ViewProps } from "./types"; + +export const CustomProviderFormDialogView = memo((props: ViewProps) => { + const { + isEditing, + isAlreadyDefault, + values, + test, + doSetAsDefault, + canSave, + canTest, + supportedProtocols, + onClose, + onFieldChange, + onProviderChange, + onTest, + onSave, + onDoSetAsDefaultChange + } = props; + + const { classes } = useStyles(); + const { t } = useTranslation("CustomProviderFormDialog"); + + const onSubmit: FormEventHandler = event => { + event.preventDefault(); + + if (canSave) { + onSave(); + } + }; + + return ( + +
+
+ onFieldChange("name", value)} + onProviderChange={onProviderChange} + /> + + + + + onFieldChange("selectedModelId", value) + } + onTest={onTest} + /> +
+ +
+ + +
+ + +
+
+
+
+ ); +}); + +const useStyles = tss.withName({ CustomProviderFormDialogView }).create(({ theme }) => ({ + root: { + height: "100%", + minHeight: 0, + display: "flex", + flexDirection: "column", + color: theme.colors.useCases.typography.textPrimary + }, + body: { + flex: 1, + minHeight: 0, + overflowY: "auto", + paddingRight: theme.spacing(0.5) + }, + footer: { + flex: "none", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + paddingTop: theme.spacing(3), + marginTop: theme.spacing(2) + }, + defaultProviderControl: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + cursor: "pointer" + }, + actions: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2) + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx new file mode 100644 index 000000000..eb20f9fbb --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx @@ -0,0 +1,193 @@ +import FormControl from "@mui/material/FormControl"; +import Input from "@mui/material/Input"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import { useId } from "react"; +import { tss } from "tss"; +import type { AiModel } from "./types"; + +export function FormTextField(props: { + label: string; + value: string; + onChange: (value: string) => void; + autoComplete: string; + isSensitive?: boolean; +}) { + const { label, value, onChange, autoComplete, isSensitive = false } = props; + const inputId = useId(); + const { classes } = useStyles(); + + return ( + onChange(event.target.value)} + type={isSensitive ? "password" : "text"} + fullWidth={true} + disableUnderline={true} + autoComplete={autoComplete} + inputProps={{ "aria-label": label }} + /> + ); +} + +export function FormSelectField(props: { + label: string; + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; +}) { + const { label, value, onChange, options } = props; + const { classes } = useStyles(); + + return ( + + + value={value} + displayEmpty={true} + onChange={event => onChange(event.target.value)} + inputProps={{ "aria-label": label }} + renderValue={selectedValue => + selectedValue === "" + ? label + : (options.find(option => option.value === selectedValue) + ?.label ?? selectedValue) + } + > + {options.map(option => ( + + {option.label} + + ))} + + + ); +} + +export function ModelSelectField(props: { + label: string; + value: string; + onChange: (value: string) => void; + models: AiModel[]; + disabled: boolean; +}) { + const { label, value, onChange, models, disabled } = props; + const { classes } = useStyles(); + + return ( + + + value={value} + displayEmpty={true} + onChange={event => onChange(event.target.value)} + inputProps={{ "aria-label": label }} + renderValue={selectedValue => { + if (selectedValue === "") { + return label; + } + + const model = models.find(model => model.id === selectedValue); + + return model?.name ?? selectedValue; + }} + MenuProps={{ + PaperProps: { className: classes.modelMenu } + }} + > + {models.map(model => ( + + {model.name} + + ))} + + + ); +} + +const useStyles = tss + .withName({ CustomProviderFormFields: FormTextField }) + .create(({ theme }) => { + const fieldBackground = theme.colors.useCases.surfaces.background; + + return { + input: { + minHeight: 45, + borderRadius: 8, + border: "2px solid transparent", + backgroundColor: fieldBackground, + transition: "border-color 160ms ease, background-color 160ms ease", + "&:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "&.Mui-focused": { + borderColor: theme.colors.useCases.buttons.actionActive + }, + "& .MuiInputBase-input": { + ...theme.typography.variants["body 2"].style, + padding: `${theme.spacing(1.25)}px ${theme.spacing(1.5)}px`, + color: theme.colors.useCases.typography.textPrimary, + "&::placeholder": { + color: theme.colors.useCases.typography.textSecondary, + opacity: 1 + } + } + }, + selectControl: { + "& .MuiInputBase-root": { + minHeight: 45, + borderRadius: 8, + backgroundColor: fieldBackground, + color: theme.colors.useCases.typography.textPrimary + }, + "& .MuiOutlinedInput-notchedOutline": { + border: "2px solid transparent" + }, + "& .MuiInputBase-root:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "& .Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: theme.colors.useCases.buttons.actionActive + }, + "& .MuiSelect-select": { + ...theme.typography.variants["body 2"].style, + display: "flex", + alignItems: "center", + minHeight: "unset", + padding: `${theme.spacing(1.25)}px ${theme.spacing( + 5 + )}px ${theme.spacing(1.25)}px ${theme.spacing(1.5)}px` + }, + "& .MuiSelect-icon": { + color: theme.colors.useCases.typography.textPrimary, + right: theme.spacing(1.5) + }, + "& .Mui-disabled": { + color: theme.colors.useCases.typography.textDisabled, + WebkitTextFillColor: theme.colors.useCases.typography.textDisabled + } + }, + modelMenu: { + marginTop: theme.spacing(0.5), + padding: theme.spacing(0.5), + borderRadius: 8, + backgroundColor: theme.colors.useCases.surfaces.surface1 + }, + modelMenuItem: { + minHeight: 32, + borderRadius: 6, + "&.Mui-selected": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + } + } + }; + }); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormSections.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormSections.tsx new file mode 100644 index 000000000..f7bd502f4 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormSections.tsx @@ -0,0 +1,285 @@ +import { alpha } from "@mui/material/styles"; +import { getIconUrlByName } from "lazy-icons"; +import { Button } from "onyxia-ui/Button"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import type { ReactNode } from "react"; +import { tss } from "tss"; +import { useTranslation } from "ui/i18n"; +import { FormSelectField, FormTextField, ModelSelectField } from "./FormFields"; +import type { FormTest, FormValues } from "./types"; + +export function ProviderSection(props: { + name: string; + provider: string; + supportedProtocols: readonly string[]; + onNameChange: (value: string) => void; + onProviderChange: (value: string) => void; +}) { + const { name, provider, supportedProtocols, onNameChange, onProviderChange } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + + const providerOptions = [ + { value: "openai", label: t("openai provider option") }, + { + value: "openai-compatible", + label: t("openai compatible provider option") + }, + { value: "mistral", label: t("mistral provider option") }, + { value: "anthropic", label: t("anthropic provider option") } + ].filter(({ value }) => supportedProtocols.includes(value)); + + return ( + + + + + ); +} + +export function CredentialsSection(props: { + apiBase: string; + apiKey: string; + onFieldChange: (key: "apiBase" | "apiKey", value: string) => void; +}) { + const { apiBase, apiKey, onFieldChange } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + + return ( + + onFieldChange("apiBase", value)} + autoComplete="url" + /> + onFieldChange("apiKey", value)} + autoComplete="off" + isSensitive={true} + /> + + ); +} + +export function VerificationSection(props: { + selectedModelId: FormValues["selectedModelId"]; + test: FormTest; + canTest: boolean; + onSelectedModelIdChange: (value: string) => void; + onTest: () => void; +}) { + const { selectedModelId, test, canTest, onSelectedModelIdChange, onTest } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + const { classes } = useStyles(); + + const testedModels = test.stateDescription === "success" ? test.models : undefined; + + return ( + + {t("provider test")} + + } + > + + + {test.stateDescription === "testing" && ( +
+ + {t("provider testing")} +
+ )} + + {test.stateDescription === "success" && ( + + {t("provider test success")} + + )} + + {test.stateDescription === "error" && ( + {t("provider test error")} + )} +
+ ); +} + +function FormSection(props: { + title: string; + subtitle: string; + action?: ReactNode; + children: ReactNode; +}) { + const { title, subtitle, action, children } = props; + const { classes } = useStyles(); + + return ( +
+ {action === undefined ? ( + <> + +
{children}
+ + ) : ( + <> +
+ + {action} +
+ {children} + + )} +
+ ); +} + +function SectionHeading(props: { title: string; subtitle: string }) { + const { title, subtitle } = props; + const { classes } = useStyles_SectionHeading(); + + return ( +
+ {title} + + {subtitle} + +
+ ); +} + +function StatusMessage(props: { severity: "success" | "error"; children: ReactNode }) { + const { severity, children } = props; + const { classes, cx } = useStyles_StatusMessage(); + + return ( +
+ + {children} +
+ ); +} + +const useStyles = tss + .withName({ CustomProviderFormSections: FormSection }) + .create(({ theme }) => ({ + section: { + paddingBottom: theme.spacing(4), + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + "& + &": { + marginTop: theme.spacing(4) + }, + "&:last-child": { + borderBottom: "none", + paddingBottom: 0 + } + }, + fields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), + marginTop: theme.spacing(4) + }, + verificationHeadingRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + marginBottom: theme.spacing(5) + }, + testButton: { + flex: "none" + }, + testingMessage: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + minHeight: 40, + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(2) + } + })); + +const useStyles_SectionHeading = tss.withName({ SectionHeading }).create(({ theme }) => ({ + root: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + } +})); + +const useStyles_StatusMessage = tss.withName({ StatusMessage }).create(({ theme }) => ({ + root: { + minHeight: 40, + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + marginTop: theme.spacing(2), + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + borderRadius: 8, + boxSizing: "border-box", + color: theme.colors.useCases.typography.textPrimary + }, + success: { + backgroundColor: alpha(theme.colors.useCases.alertSeverity.success.main, 0.2) + }, + error: { + backgroundColor: alpha(theme.colors.useCases.alertSeverity.error.main, 0.2) + }, + dot: { + flex: "none", + width: 16, + height: 16, + borderRadius: "50%" + }, + dotSuccess: { + backgroundColor: theme.colors.useCases.alertSeverity.success.main + }, + dotError: { + backgroundColor: theme.colors.useCases.alertSeverity.error.main + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/SideDialog.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/SideDialog.tsx new file mode 100644 index 000000000..61ae4d6cd --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/SideDialog.tsx @@ -0,0 +1,132 @@ +import { alpha } from "@mui/material/styles"; +import { getIconUrlByName } from "lazy-icons"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Text } from "onyxia-ui/Text"; +import { useEffect, type MouseEvent, type ReactNode } from "react"; +import { keyframes } from "tss-react"; +import { tss } from "tss"; + +export function SideDialog(props: { + title: ReactNode; + closeLabel: string; + onClose: () => void; + children: ReactNode; +}) { + const { children, title, closeLabel, onClose } = props; + const { classes } = useStyles(); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", onKeyDown); + + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const onRootClick = (event: MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + return ( +
+
+
+ + {title} + + +
+ +
{children}
+
+
+ ); +} + +const useStyles = tss.withName({ SideDialog }).create(({ theme }) => ({ + root: { + position: "fixed", + inset: 0, + zIndex: theme.muiTheme.zIndex.modal, + marginRight: theme.spacing(3), + display: "flex", + justifyContent: "flex-end", + alignItems: "stretch", + padding: `64px ${theme.spacing(2)}px 32px`, + boxSizing: "border-box", + backgroundColor: alpha(theme.colors.useCases.surfaces.background, 0.72), + backdropFilter: "blur(1px)", + "@media (max-width: 720px)": { + marginRight: 0, + padding: 0 + } + }, + panel: { + width: 657, + maxWidth: "100%", + minHeight: 0, + display: "flex", + flexDirection: "column", + overflow: "hidden", + borderRadius: 16, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[4], + animation: `${keyframes` + from { + opacity: 0; + transform: translateX(28px); + } + to { + opacity: 1; + transform: translateX(0); + } + `} 340ms cubic-bezier(0.2, 0, 0, 1)`, + "@media (max-width: 720px)": { + borderRadius: 0, + borderTop: "none", + borderBottom: "none" + } + }, + headingWrapper: { + flex: "none", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + margin: `0 ${theme.spacing(5)}px`, + padding: `${theme.spacing(5)}px 0 ${theme.spacing(2)}px`, + borderBottom: `1px solid ${theme.colors.useCases.typography.textSecondary}` + }, + title: { + minWidth: 0, + color: theme.colors.useCases.typography.textPrimary + }, + closeButton: { + flex: "none" + }, + childrenWrapper: { + flex: 1, + minHeight: 0, + overflow: "hidden", + padding: `${theme.spacing(5)}px ${theme.spacing(5)}px ${theme.spacing(4)}px`, + boxSizing: "border-box" + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/types.ts b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/types.ts new file mode 100644 index 000000000..a6e83ce9c --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/types.ts @@ -0,0 +1,35 @@ +export type AiModel = { + id: string; + name: string; +}; + +export type FormValues = { + name: string; + provider: string; + apiBase: string; + apiKey: string; + selectedModelId: string; +}; + +export type FormTest = + | { stateDescription: "idle" } + | { stateDescription: "testing" } + | { stateDescription: "success"; models: AiModel[] } + | { stateDescription: "error" }; + +export type ViewProps = { + isEditing: boolean; + isAlreadyDefault: boolean; + values: FormValues; + test: FormTest; + doSetAsDefault: boolean; + canSave: boolean; + canTest: boolean; + supportedProtocols: readonly string[]; + onClose: () => void; + onFieldChange: (key: keyof FormValues, value: string) => void; + onProviderChange: (provider: string) => void; + onTest: () => void; + onSave: () => void; + onDoSetAsDefaultChange: (doSetAsDefault: boolean) => void; +}; From f92c895e870e8139158c283092556d88077cc368 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:45:09 +0200 Subject: [PATCH 25/25] improve css --- .../account/AccountAiTab/AccountAiTab.tsx | 21 +++++++++++++------ .../CustomProviderFormDialog/FormFields.tsx | 8 ++----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx index b3c6daed8..7df126f9a 100644 --- a/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx +++ b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx @@ -439,18 +439,27 @@ const useStyles = tss justifyContent: "flex-end" }, compactActionButton: { - minHeight: 28, - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5) + ...theme.typography.variants["label 2"].style, + boxSizing: "border-box", + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(2.5), + paddingRight: theme.spacing(2.5), + "& .MuiButton-startIcon": { + marginLeft: 0, + marginRight: theme.spacing(1) + }, + "& .MuiButton-startIcon > *": { + fontSize: theme.iconSizesInPxByName["extra small"] + } }, defaultProviderBadge: { display: "flex", alignItems: "center", justifyContent: "center", gap: theme.spacing(1), - minHeight: 28, - paddingLeft: theme.spacing(1.5), - paddingRight: theme.spacing(1.5), + boxSizing: "border-box", + padding: `${theme.spacing(1)}px ${theme.spacing(2.5)}px`, borderRadius: 9999, backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1) }, diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx index eb20f9fbb..fce4edb2a 100644 --- a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx @@ -179,15 +179,11 @@ const useStyles = tss modelMenu: { marginTop: theme.spacing(0.5), padding: theme.spacing(0.5), - borderRadius: 8, - backgroundColor: theme.colors.useCases.surfaces.surface1 + borderRadius: 8 }, modelMenuItem: { minHeight: 32, - borderRadius: 6, - "&.Mui-selected": { - backgroundColor: theme.colors.useCases.surfaces.surface2 - } + borderRadius: 6 } }; });