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/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/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..ba53e9c0d --- /dev/null +++ b/web/src/core/adapters/ai/mock.ts @@ -0,0 +1,18 @@ +import type { Ai } from "core/ports/Ai"; + +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" }), + 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 new file mode 100644 index 000000000..19fc7c814 --- /dev/null +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -0,0 +1,52 @@ +import type { Ai, GetTokenResult } from "core/ports/Ai"; +import { oidcTokenExchange, OidcTokenExchangeError } from "core/tools/oidcTokenExchange"; +import { z } from "zod"; + +export function createAi(params: { + id: string; + name: string; + webUiUrl: string; + oauthProvider: string; + getOidcAccessToken: () => Promise; +}): Ai { + const { id, name, webUiUrl, oauthProvider, getOidcAccessToken } = params; + + const apiBase = `${webUiUrl}/api`; + + return { + id, + name, + 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 } = z + .object({ data: z.array(z.object({ id: z.string(), name: z.string() })) }) + .parse(await response.json()); + + return data.map(({ id, name }) => ({ id, name })); + } + }; +} diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 26b421d6d..d7ee21217 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -82,6 +82,13 @@ export type ApiTypes = { }; }; data?: { + 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 390a3a020..9a28aff24 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -435,6 +435,27 @@ export function createOnyxiaApi(params: { apiRegion.vault.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 c07bf863f..1a0b69fcb 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[]; }; 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: [] }; const { core, dispatch, getState } = createCore({ @@ -275,6 +275,72 @@ 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.length === 0) { + break init_ai; + } + + const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] = + await Promise.all([ + import("core/adapters/ai"), + import("core/adapters/oidc"), + onyxiaApi.getAvailableRegionsAndOidcParams() + ]); + + 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>(); + + 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 + }); + + 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()); + } + 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..267f9e369 --- /dev/null +++ b/web/src/core/ports/Ai.ts @@ -0,0 +1,13 @@ +export type Ai = { + id: string; + name: string; + webUiUrl: string; + apiBase: string; + getToken: () => Promise; + listModels: (token: string) => Promise<{ id: string; name: string }[]>; +}; + +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..7120e98bd 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: { + 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 ed76d4d3a..9ee1a815a 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -182,6 +182,16 @@ export type XOnyxiaContext = { useCertManager: boolean; certManagerClusterIssuer: string | undefined; }; + ai: + | { + enabled: boolean; + apiKey: string; + apiBase: string; + model: string; + provider: string; + embeddingsModel: 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/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/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..03bc364a3 --- /dev/null +++ b/web/src/core/usecases/ai/selectors.ts @@ -0,0 +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 type { Provider } from "./state"; + +const state = (rootState: RootState) => rootState[name]; + +const main = createSelector(state, state => { + if (!state.isInitialized) { + return { + isInitialized: false as const, + isInitializing: state.isInitializing + }; + } + + 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 new file mode 100644 index 000000000..ccb7e68b8 --- /dev/null +++ b/web/src/core/usecases/ai/state.ts @@ -0,0 +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 }; + +/** + * 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; +}; + +/** Lifecycle of fetching the provider's `/models` list. */ +export type ModelCatalog = + | { stateDescription: "not fetched" } + | { stateDescription: "fetching" } + | { stateDescription: "error" } + | { stateDescription: "loaded"; availableModels: AiModel[] }; + +export type Provider = Provider.Region | Provider.Custom; + +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; + }; + + /** 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({ isInitialized: false, isInitializing: false }) + ), + reducers: { + initializeStarted: () => + id({ isInitialized: false, isInitializing: true }), + initialized: ( + _, + { + payload + }: { + payload: { providers: Provider[]; activeProvider: ActiveProvider }; + } + ) => + id({ + isInitialized: true, + providers: payload.providers, + activeProvider: payload.activeProvider + }), + activeProviderChanged: ( + state, + { payload }: { payload: { activeProvider: ActiveProvider } } + ) => { + if (!state.isInitialized) return; + state.activeProvider = payload.activeProvider; + }, + 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; + }, + 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" }; + }, + 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; + } + }, + 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" }; + }, + 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; + }, + 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 } } + ) => { + if (!state.isInitialized) return; + state.providers.push(payload.provider); + }, + customProviderEdited: ( + state, + { + payload + }: { + payload: { + providerId: string; + label: string; + apiBase: string; + apiKey: string; + }; + } + ) => { + 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 new file mode 100644 index 000000000..398b25943 --- /dev/null +++ b/web/src/core/usecases/ai/thunks.ts @@ -0,0 +1,340 @@ +import type { Thunks } from "core/bootstrap"; +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 * 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, + embeddingsModelId: selection.embeddingsModelId ?? null + }; +} + +function fromPersistedSelection( + selection: PersistedModelSelection | undefined +): ModelSelection { + return { + modelId: selection?.modelId ?? undefined, + embeddingsModelId: selection?.embeddingsModelId ?? undefined + }; +} + +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 })); +} + +export const thunks = { + isAvailable: + () => + (...args): boolean => { + const [, getState] = args; + const region = + deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); + return region.ai.length > 0; + }, + refreshToken: + (params: { providerId: string }) => + async (...args) => { + const { providerId } = params; + const [dispatch, , { ai }] = args; + + const aiProvider = ai.find(aiProvider => aiProvider.id === providerId); + + assert(aiProvider !== undefined); + + const result = await aiProvider.getToken(); + + dispatch( + actions.regionTokenRefreshed({ + providerId, + token: result.status === "success" ? result.token : undefined + }) + ); + }, + setActiveProvider: + (params: { activeProvider: ActiveProvider }) => + async (...args) => { + const { activeProvider } = params; + const [dispatch] = args; + + dispatch(actions.activeProviderChanged({ activeProvider })); + await dispatch(privateThunks.persistConfig()); + }, + setSelectedModel: + (params: { providerId: string; modelId: string }) => + async (...args) => { + const { providerId, modelId } = params; + const [dispatch] = args; + + 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) => { + const { label, apiBase, apiKey } = params; + const [dispatch] = args; + + const providerId = crypto.randomUUID(); + + dispatch( + actions.customProviderAdded({ + provider: { + kind: "custom", + id: providerId, + label, + apiBase, + apiKey, + modelCatalog: { stateDescription: "fetching" }, + selection: { modelId: undefined, embeddingsModelId: undefined } + } + }) + ); + dispatch( + actions.activeProviderChanged({ + activeProvider: { kind: "provider", providerId } + }) + ); + await dispatch(privateThunks.persistConfig()); + + await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); + }, + editCustomProvider: + (params: { + providerId: string; + label: string; + apiBase: string; + apiKey: string; + }) => + async (...args) => { + const { providerId, label, apiBase, apiKey } = params; + const [dispatch] = args; + + dispatch( + actions.customProviderEdited({ providerId, label, apiBase, apiKey }) + ); + await dispatch(privateThunks.persistConfig()); + + 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()); + }, + testCustomProvider: + (params: { apiBase: string; apiKey: string }) => + async (..._args): Promise => { + const { apiBase, apiKey } = params; + return fetchModels(apiBase, apiKey); + } +} satisfies Thunks; + +const privateThunks = { + persistConfig: + () => + async (...args) => { + const [dispatch, getState] = args; + + const state = getState()[name]; + + 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; + +export const protectedThunks = { + initialize: + () => + async (...args) => { + const [dispatch, getState, { ai }] = args; + + if (ai.length === 0) { + 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: + tokenResult.status === "success" + ? "fetching" + : "not fetched" + }, + selection: fromPersistedSelection( + persisted?.selections[aiProvider.id] + ) + }; + + return { provider, aiProvider, tokenResult }; + }) + ); + + 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 aiProvider.listModels(tokenResult.token); + dispatch( + actions.modelCatalogLoaded({ + providerId: provider.id, + models + }) + ); + } catch { + 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/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..0da62d68b 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,7 @@ export const protectedThunks = { useCertManager: region.certManager?.useCertManager, certManagerClusterIssuer: region.certManager?.certManagerClusterIssuer }, + 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 e5345a6c7..f8ec874f7 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,49 @@ 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 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 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", + "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 + 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..86e222248 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,46 @@ 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 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 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", + "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{" "} + + {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..1ba59f4c2 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,48 @@ 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 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 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", + "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 + 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..55c00b332 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,47 @@ 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 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ää 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", + "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 + 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..68086d33f 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,49 @@ 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 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 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", + "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 + 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..aaa1b0d7c 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,48 @@ 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 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 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", + "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{" "} + + {webUiUrl} + {" "} + per creare il tuo account. + + ) + }, AccountVaultTab: { "credentials section title": "Credenziali Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( @@ -509,9 +552,9 @@ export const translations: Translations<"it"> = { la nostra documentazione - {". \u00a0"} + .   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..bc0de2b3c 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,48 @@ 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 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 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", + "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{" "} + + {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..9b115f659 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,46 @@ 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 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 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", + "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å{" "} + + {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..330ce809f 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,46 @@ 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 label": "模型", + "embeddings model label": "嵌入模型", + "gateway error": "无法初始化 AI 网关。", + "use in services": "在服务中使用", + "custom providers section title": "自定义 AI 提供商", + "custom providers section helper": + "添加您自己的兼容 OpenAI 的 AI 提供商。凭据存储在您的浏览器中。", + "edit custom provider title": "编辑 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 update": "保存", + "provider cancel": "取消", + "models fetch error": "无法获取模型 — 请检查您的 URL 和 API 密钥。", + "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 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 diff --git a/web/src/ui/pages/account/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab.tsx new file mode 100644 index 000000000..ad2337b6b --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab.tsx @@ -0,0 +1,616 @@ +import { useEffect, 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 { 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"; +import type { + AiModel, + ModelCatalog, + ModelSelection, + Provider +} from "core/usecases/ai/state"; + +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.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 }); + + const onFieldRequestCopyFactory = useCallbackFactory(([text]: [string]) => + copyToClipboard(text) + ); + + const onRefreshClickFactory = useCallbackFactory(([providerId]: [string]) => + ai.refreshToken({ providerId }) + ); + + const onToggleProviderFactory = useCallbackFactory( + ([providerId]: [string], [, checked]: [unknown, boolean]) => + ai.setActiveProvider({ + activeProvider: checked + ? { kind: "provider", providerId } + : { kind: "none" } + }) + ); + + 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(""); + const [testStatus, setTestStatus] = useState< + "idle" | "testing" | "success" | "error" + >("idle"); + const [testModelCount, setTestModelCount] = useState(0); + + 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(""); + 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 () => { + 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(""); + setTestStatus("idle"); + setTestModelCount(0); + }); + + if (!uiState.isInitialized) { + return uiState.isInitializing ? : null; + } + + 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 => ( +
+
+ {regionProvider.name} + + } + label={{t("use in services")}} + /> +
+ + {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")}} + /> + + +
+
+ + + +
+ ))} + + + 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={ + <> + + + + } + /> + + ); +}); + +type ModelCatalogSectionProps = { + providerId: string; + modelCatalog: ModelCatalog; + selection: ModelSelection; +}; + +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 ( +
+
+ {label} +
+
+ +
+
+ ); +}); + +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" + | "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 }) => ({ + 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), + 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(2) + }, + 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/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"