From 414d8b434d0fed66dd4f3f8b334eff4abcf2e623 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 1/2] 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 --- 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 | 34 +++- 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/i18n/types.ts | 1 + 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, 835 insertions(+), 17 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/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 1e1464a2d..ea16deda8 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 02c9892c2..9028af754 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -348,6 +348,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 7dd134067..a5a470f7c 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: s3Config.region }; } - }) + }), + ai: undefined }; const { core, dispatch, getState } = createCore({ @@ -275,6 +275,49 @@ export async function bootstrapCore( await dispatch(usecases.s3ConfigManagement.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 8d8453a3b..e9507d581 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -45,6 +45,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 275a85090..d0c725e69 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -193,6 +193,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 bd292355c..3c6feb0ac 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 viewQuotas from "./viewQuotas"; import * as dataCollection from "./dataCollection"; 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 bc5da3e6f..89784d30a 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 s3ConfigManagement from "core/usecases/s3ConfigManagement"; @@ -773,6 +774,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 3e03b5441..633adb06c 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -18,7 +18,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", @@ -108,6 +109,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 34f99327b..936e74f65 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -18,7 +18,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", @@ -107,6 +108,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 c414b392c..f068d0525 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -19,7 +19,8 @@ export const translations: Translations<"en"> = { 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", @@ -109,6 +110,33 @@ export const translations: Translations<"en"> = { "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 ddc6cf6b1..5b2e8d883 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -19,7 +19,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", @@ -108,6 +109,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 1fecc74c1..1aaed00fd 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -18,7 +18,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", @@ -110,6 +111,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 61d74300e..fe4aba28d 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -18,7 +18,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", @@ -107,6 +108,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 }) => ( @@ -384,9 +411,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 ae8f28ff7..866a413a2 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -18,7 +18,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", @@ -107,6 +108,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 5dc6a75fc..e28a0f2fb 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -19,7 +19,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", @@ -108,6 +109,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 f13366950..115de1f9f 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -18,7 +18,8 @@ export const translations: Translations<"zh-CN"> = { text2: "访问我的账号信息", text3: "设置您的用户名, 电子邮件, 密码和访问令牌", "personal tokens tooltip": "服务的访问令牌", - vault: "Vault" + vault: "Vault", + ai: "AI" }, AccountProfileTab: { "account id": "账户标识符", @@ -97,6 +98,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/i18n/types.ts b/web/src/ui/i18n/types.ts index e8f8b5498..1920a3e85 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -47,6 +47,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/pages/projectSettings/Page").I18n | import("ui/pages/projectSettings/ProjectSettingsS3ConfigTab/ProjectSettingsS3ConfigTab").I18n | import("ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigCard").I18n 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 a8192084b..57778ceae 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")); @@ -35,7 +36,7 @@ function Account() { const { t } = useTranslation({ Account }); const { - functions: { s3CodeSnippets, k8sCodeSnippets, vaultCredentials } + functions: { s3CodeSnippets, k8sCodeSnippets, vaultCredentials, ai } } = getCoreSync(); const tabs = useMemo( @@ -52,6 +53,7 @@ function Account() { .filter(accountTabId => accountTabId !== "vault" ? true : vaultCredentials.isAvailable() ) + .filter(accountTabId => (accountTabId !== "ai" ? true : ai.isAvailable())) .map(id => ({ id, title: t(id) })), [t] ); @@ -94,6 +96,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 093e9b56f..383cf8935 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", "storage", "k8sCodeSnippets", "vault", From 38ba553fbba622eebc6235588d53f5ddd9439812 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 2/2] 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 | 22 +- 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, 757 insertions(+), 60 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 633adb06c..221033fb2 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -121,10 +121,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 936e74f65..0a68f3c32 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -120,10 +120,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 f068d0525..a3b5f7611 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -122,10 +122,20 @@ export const translations: Translations<"en"> = { ), "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 5b2e8d883..4fbea0774 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -121,10 +121,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 1aaed00fd..0ae2b1d07 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -123,10 +123,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 fe4aba28d..49c9a5e51 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -120,10 +120,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{" "} @@ -411,8 +422,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 866a413a2..b5954c21d 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -120,10 +120,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 e28a0f2fb..d5eb0d0a2 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -121,10 +121,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 115de1f9f..2377dfb54 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -110,9 +110,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 } }));