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/.env b/web/.env index 88aacc4f5..1a15d3d27 100644 --- a/web/.env +++ b/web/.env @@ -861,6 +861,15 @@ ONYXIA_API_URL=/api DISABLE_DISPLAY_ALL_CATALOG=false +# Master switch for the AI feature (Account > AI tab: region-provided AI gateways +# and user-added custom OpenAI-compatible providers). +# +# When set to "false" (the default), the AI feature is entirely hidden, even if a +# deployment region declares AI gateways. Set to "true" to enable it; region +# gateways are then listed when present, and users can always add custom providers. +ENABLED_AI=false + + # ================================================================================== # Private parameters - Not expected to be configured by the instance administrator # ================================================================================== diff --git a/web/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/openWebUi.ts b/web/src/core/adapters/ai/openWebUi.ts new file mode 100644 index 000000000..51dbe9a11 --- /dev/null +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -0,0 +1,53 @@ +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, + provider: "openai", + 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/oidc/oidc.ts b/web/src/core/adapters/oidc/oidc.ts index d959bc4ed..cfd4dc564 100644 --- a/web/src/core/adapters/oidc/oidc.ts +++ b/web/src/core/adapters/oidc/oidc.ts @@ -18,6 +18,13 @@ export async function createOidc( getCurrentLang: () => Language; autoLogin: AutoLogin; enableDebugLogs: boolean; + /** + * Opt this specific OIDC client instance out of DPoP. + * Use it when the access token has to be handed over to a third party that + * will use it on the user's behalf (e.g. an OpenWebUI token exchange): such + * a party cannot present a DPoP proof, so the token must not be sender-constrained. + */ + disableDPoP?: true; } ): Promise { const { @@ -29,7 +36,8 @@ export async function createOidc( extraQueryParams_raw, idleSessionLifetimeInSeconds, autoLogin, - enableDebugLogs + enableDebugLogs, + disableDPoP } = params; const extraQueryParams_raw_normalized = extraQueryParams_raw @@ -99,7 +107,8 @@ export async function createOidc( extraTokenParams, idleSessionLifetimeInSeconds, debugLogs: enableDebugLogs, - autoLogin + autoLogin, + ...(disableDPoP ? { disableDPoP } : {}) }); return oidc; diff --git a/web/src/core/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..04128b32f 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, i) => ({ + id: aiConfig_api.id ?? `onyxia-${i}`, + 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..41a2e5b3a 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"; @@ -29,6 +30,7 @@ export type ParamsOfBootstrapCore = { isAuthGloballyRequired: boolean; enableOidcDebugLogs: boolean; disableDisplayAllCatalog: boolean; + isAiEnabled: boolean; getIsDarkModeEnabled: () => boolean; }; @@ -38,6 +40,7 @@ export type Context = { onyxiaApi: OnyxiaApi; secretsManager: SecretsManager; sqlOlap: SqlOlap; + ai: Ai[]; }; export type Core = GenericCore; @@ -50,7 +53,8 @@ export async function bootstrapCore( transformBeforeRedirectForKeycloakTheme, getCurrentLang, isAuthGloballyRequired, - enableOidcDebugLogs + enableOidcDebugLogs, + isAiEnabled } = params; let isCoreCreated = false; @@ -83,7 +87,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 +108,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 +139,6 @@ export async function bootstrapCore( if (isAuthGloballyRequired && !oidc.isUserLoggedIn) { await oidc.login({ doesCurrentHrefRequiresAuth: true }); - // NOTE: Never reached } const context: Context = { @@ -177,7 +178,8 @@ export async function bootstrapCore( s3_region: s3Profile.paramsOfCreateS3Client.region }; } - }) + }), + ai: [] }; const { core, dispatch, getState } = createCore({ @@ -275,6 +277,89 @@ export async function bootstrapCore( await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize()); } + init_ai: { + if (!isAiEnabled) { + break init_ai; + } + + if (!oidc.isUserLoggedIn) { + break init_ai; + } + + const deploymentRegion = + usecases.deploymentRegionManagement.selectors.currentDeploymentRegion( + getState() + ); + + // Wire one Ai adapter per region-provided gateway into `context.ai` (none if + // the region exposes no AI: only custom providers will then be loaded). + region_ai: { + if (deploymentRegion.ai.length === 0) { + break region_ai; + } + + const [{ createAi }, { createOidc, mergeOidcParams }, { oidcParams }] = + await Promise.all([ + import("core/adapters/ai"), + import("core/adapters/oidc"), + onyxiaApi.getAvailableRegionsAndOidcParams() + ]); + + assert(oidcParams !== undefined); + + // Providers may share the same OIDC client: oidc-spa identifies a client by + // issuerUri + clientId, so creating it twice would collide. Create each + // distinct client only once. + const getOidcAccessTokenByOidcKey = new Map Promise>(); + + for (const aiConfig of deploymentRegion.ai) { + const oidcParams_ai = mergeOidcParams({ + oidcParams, + oidcParams_partial: aiConfig.oidcParams + }); + + const oidcKey = `${oidcParams_ai.issuerUri}${oidcParams_ai.clientId}`; + + let getOidcAccessToken = getOidcAccessTokenByOidcKey.get(oidcKey); + + if (getOidcAccessToken === undefined) { + const oidc_ai = await createOidc({ + ...oidcParams_ai, + transformBeforeRedirectForKeycloakTheme, + getCurrentLang, + autoLogin: true, + enableDebugLogs: enableOidcDebugLogs, + // The access token is handed over to OpenWebUI's token exchange + // endpoint, which validates it server-side and cannot present a + // DPoP proof. It must therefore be a plain bearer token, never + // sender-constrained, even when DPoP is globally enabled. + disableDPoP: true + }); + + getOidcAccessToken = async () => + (await oidc_ai.getTokens()).accessToken; + + getOidcAccessTokenByOidcKey.set(oidcKey, getOidcAccessToken); + } + + context.ai.push( + createAi({ + id: aiConfig.id, + name: aiConfig.name ?? new URL(aiConfig.url).hostname, + webUiUrl: aiConfig.url, + oauthProvider: aiConfig.oauthProvider, + getOidcAccessToken + }) + ); + } + } + + // Sole initiator of the AI use-case, dispatched only now that any region + // adapters are wired into `context.ai`. Fire-and-forget so app start isn't + // blocked; consumers await readiness via `ai...waitForInitialization`. + dispatch(usecases.ai.protectedThunks.initialize()); + } + 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..47b0c98c3 --- /dev/null +++ b/web/src/core/ports/Ai.ts @@ -0,0 +1,19 @@ +export type Ai = { + id: string; + name: string; + /** + * LLM provider family the endpoint speaks (e.g. "openai", "anthropic", "gemini"). + * Injected as `ai.provider` in the service launch context so charts know which + * API dialect to use. Defaults to "openai" (OpenAI-compatible) for region gateways. + */ + provider: string; + webUiUrl: string; + apiBase: string; + getToken: () => Promise; + 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..f1307c4cb 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -182,6 +182,11 @@ export type XOnyxiaContext = { useCertManager: boolean; certManagerClusterIssuer: string | undefined; }; + ai: { + enabled: boolean; + activeProvider: AiProvider | undefined; + providers: AiProvider[]; + }; proxyInjection: | { enabled: boolean | undefined; @@ -207,3 +212,14 @@ export type XOnyxiaContext = { }; assert>(); + +type AiProvider = { + id: string; + apiKey: string; + apiBase: string; + name: string; + selectedModel: string | undefined; + models: string[] | undefined; + // openai / anthropic / cohere / azure-openai / google-palm / ai21 ... + provider: string; +}; diff --git a/web/src/core/tools/fetchAiModels.ts b/web/src/core/tools/fetchAiModels.ts new file mode 100644 index 000000000..77ed7e038 --- /dev/null +++ b/web/src/core/tools/fetchAiModels.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export type AiModel = { id: string; name: string }; + +/** + * Lists the models exposed by a generic OpenAI-compatible `/models` endpoint, + * authenticated with a bearer API key. Used by user-added custom providers. + * + * `name` is optional on purpose: plain OpenAI-compatible APIs (incl. OpenAI + * itself) only return `id`. We fall back to `id` so those providers aren't + * rejected. (The OpenWebUI region adapter has its own listing, since OpenWebUI + * always returns `name`.) + */ +export async function fetchAiModels(params: { + apiBase: string; + token: string; +}): Promise { + const { apiBase, token } = params; + + const response = await fetch(`${apiBase}/models`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error(`Failed to list models (${response.status})`); + } + + const json = await response.json(); + + let data; + + try { + ({ data } = z + .object({ + data: z.array(z.object({ id: z.string(), name: z.string().optional() })) + }) + .parse(json)); + } catch { + throw new Error("Unexpected /models response shape"); + } + + return data.map(({ id, name }) => ({ id, name: name ?? id })); +} diff --git a/web/src/core/tools/oidcTokenExchange.ts b/web/src/core/tools/oidcTokenExchange.ts new file mode 100644 index 000000000..8d903d281 --- /dev/null +++ b/web/src/core/tools/oidcTokenExchange.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +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 json = await response.json(); + + let token: string | undefined; + + try { + const data = z + .object({ + token: z.string().optional(), + access_token: z.string().optional() + }) + .parse(json); + + token = data.token ?? data.access_token; + } catch { + throw new Error("Unexpected token exchange response shape"); + } + + if (token === undefined || token === "") { + throw new Error("Token exchange response contained no token"); + } + + return token; +} diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts new file mode 100644 index 000000000..9d2495f68 --- /dev/null +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { symToStr } from "tsafe/symToStr"; +import { + parseAiConfigStr, + serializeAiConfig, + type PersistedAiConfig +} from "./persistedAiConfig"; + +const sampleConfig: PersistedAiConfig = { + customProviders: [ + { + id: "p1", + name: "My provider", + provider: "openai", + apiBase: "https://api.openai.com/v1", + apiKey: "sk-secret" + } + ], + selections: { + p1: { modelId: "gpt-4" }, + region1: { modelId: null } + }, + activeProviderId: "p1" +}; + +describe(symToStr({ parseAiConfigStr }), () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null when nothing is stored", () => { + expect(parseAiConfigStr({ aiConfigStr: null })).toBeNull(); + }); + + it("returns null on invalid JSON", () => { + expect(parseAiConfigStr({ aiConfigStr: "{not json" })).toBeNull(); + }); + + it("returns null when the shape doesn't match", () => { + expect( + parseAiConfigStr({ aiConfigStr: JSON.stringify({ customProviders: 42 }) }) + ).toBeNull(); + }); + + it("parses a valid config", () => { + expect( + parseAiConfigStr({ aiConfigStr: JSON.stringify(sampleConfig) }) + ).toStrictEqual(sampleConfig); + }); + + it("preserves a null modelId selection", () => { + const parsed = parseAiConfigStr({ aiConfigStr: JSON.stringify(sampleConfig) }); + expect(parsed?.selections.region1).toStrictEqual({ modelId: null }); + }); +}); + +describe(symToStr({ serializeAiConfig }), () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("round-trips through parseAiConfigStr", () => { + const aiConfigStr = serializeAiConfig({ aiConfig: sampleConfig }); + expect(parseAiConfigStr({ aiConfigStr })).toStrictEqual(sampleConfig); + }); +}); diff --git a/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts new file mode 100644 index 000000000..a4299bfce --- /dev/null +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts @@ -0,0 +1,84 @@ +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; +}; + +export type PersistedCustomProvider = { + id: string; + name: string; + // Optional for backward compatibility with configs persisted before the field + // existed; defaulted to "openai" when read back. + provider?: string; + apiBase: string; + apiKey: 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 list, OIDC token) are recomputed on init. + */ +export type PersistedAiConfig = { + customProviders: PersistedCustomProvider[]; + /** Model selection per provider id (region providers included). */ + selections: Record; + // null (not undefined) because absent selections must round-trip through JSON. + activeProviderId: string | null; +}; + +const zPersistedAiConfig: z.ZodType = z.object({ + customProviders: z.array( + z.object({ + id: z.string(), + name: z.string(), + provider: z.string().optional(), + apiBase: z.string(), + apiKey: z.string() + }) + ), + selections: z.record( + z.string(), + z.object({ + modelId: z.string().nullable() + }) + ), + activeProviderId: z.string().nullable() +}); + +/** 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..80654dc0f --- /dev/null +++ b/web/src/core/usecases/ai/selectors.ts @@ -0,0 +1,94 @@ +import { createSelector } from "clean-architecture"; +import type { State as RootState } from "core/bootstrap"; +import { name } from "./state"; +import type { State } from "./state"; + +const state = (rootState: RootState) => rootState[name]; + +const providers = createSelector(state, state => + state.stateDescription === "initialized" ? state.providers : undefined +); + +const activeProviderId = createSelector(state, state => + state.stateDescription === "initialized" ? state.activeProviderId : undefined +); + +const main = createSelector( + state, + providers, + activeProviderId, + (state, providers, activeProviderId) => { + const toCommonView = (provider: State.Provider) => ({ + id: provider.id, + provider: provider.provider, + apiBase: provider.apiBase, + isDefault: provider.id === activeProviderId, + models: provider.models, + selectedModelId: provider.selectedModelId, + name: provider.name + }); + + return { + stateDescription: state.stateDescription, + regionProviders: (providers ?? []) + .filter((p): p is State.Provider.Region => p.kind === "region") + .map(p => ({ + ...toCommonView(p), + webUiUrl: p.webUiUrl, + auth: p.auth + })), + customProviders: (providers ?? []) + .filter((p): p is State.Provider.Custom => p.kind === "custom") + .map(p => ({ + ...toCommonView(p), + apiKey: p.apiKey + })) + }; + } +); + +const aiOnyxiaContext = createSelector( + providers, + activeProviderId, + (providers, activeProviderId) => { + const toProviderView = (provider: State.Provider) => { + const apiKey = (() => { + if (provider.kind === "custom") return provider.apiKey; + if (provider.auth.stateDescription !== "authenticated") return undefined; + return provider.auth.token; + })(); + + // A provider is only usable once authenticated. Its models may not be + // listed yet (fetch still pending or failed) — that's fine, `models` is + // left undefined in that case. + if (apiKey === undefined) return undefined; + + const models = + provider.models?.stateDescription === "loaded" + ? provider.models.availableModels.map(({ id }) => id) + : undefined; + + return { + id: provider.id, + name: provider.name, + provider: provider.provider, + apiBase: provider.apiBase, + apiKey, + models, + selectedModel: provider.selectedModelId + }; + }; + + const providerViews = (providers ?? []) + .map(toProviderView) + .filter(view => view !== undefined); + + return { + enabled: providerViews.length > 0, + activeProvider: providerViews.find(p => p.id === activeProviderId), + providers: providerViews.filter(p => p.id !== activeProviderId) + }; + } +); + +export const selectors = { main, aiOnyxiaContext }; diff --git a/web/src/core/usecases/ai/state.ts b/web/src/core/usecases/ai/state.ts new file mode 100644 index 000000000..dd1688fde --- /dev/null +++ b/web/src/core/usecases/ai/state.ts @@ -0,0 +1,189 @@ +import { createUsecaseActions } from "clean-architecture"; +import { assert } from "tsafe"; +import { id } from "tsafe/id"; + +export const name = "ai"; + +type State = State.NotInitialized | State.Error | State.Initialized; + +export declare namespace State { + export type NotInitialized = { stateDescription: "not initialized" }; + + export type Error = { stateDescription: "error" }; + + export type Initialized = { + stateDescription: "initialized"; + providers: Provider[]; + activeProviderId: string | undefined; + }; + + // --- Providers --- + + export type Provider = Provider.Region | Provider.Custom; + + export namespace Provider { + export type Common = { + id: string; + name: string; + apiBase: string; + /** + * LLM provider family (e.g. "openai", "anthropic", "gemini"), injected as + * `ai.provider` in the service launch context. For region providers it + * comes from the deployment region config; for custom ones the user sets it. + */ + provider: string; + models: Models | undefined; + selectedModelId: string | undefined; + }; + + /** Provisioned by the deployment region, authenticated via the OIDC token. */ + export type Region = Common & { + kind: "region"; + webUiUrl: string; + auth: + | { stateDescription: "no account" } + | { stateDescription: "error" } + | { stateDescription: "authenticated"; token: string }; + }; + + /** Added by the user, authenticated via a static API key. */ + export type Custom = Common & { + kind: "custom"; + apiKey: string; + }; + } + + /** Lifecycle of fetching a provider's `/models` list (undefined = not fetched). */ + export type Models = + | { stateDescription: "fetching" } + | { stateDescription: "error" } + | { stateDescription: "loaded"; availableModels: AiModel[] }; + + export type AiModel = { id: string; name: string }; +} + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id( + id({ stateDescription: "not initialized" }) + ), + reducers: { + initializationFailed: () => id({ stateDescription: "error" }), + initialized: ( + _, + { + payload + }: { + payload: { + providers: State.Provider[]; + activeProviderId: string | undefined; + }; + } + ) => + id({ + stateDescription: "initialized", + providers: payload.providers, + activeProviderId: payload.activeProviderId + }), + activeProviderChanged: ( + state, + { payload }: { payload: { activeProviderId: string | undefined } } + ) => { + if (state.stateDescription !== "initialized") return; + state.activeProviderId = payload.activeProviderId; + }, + regionAuthRefreshed: ( + state, + { + payload + }: { payload: { providerId: string; auth: State.Provider.Region["auth"] } } + ) => { + assert(state.stateDescription === "initialized"); + const provider = state.providers.find(p => p.id === payload.providerId); + if (provider === undefined || provider.kind !== "region") return; + provider.auth = payload.auth; + }, + modelsLoaded: ( + state, + { payload }: { payload: { providerId: string; models: State.AiModel[] } } + ) => { + assert(state.stateDescription === "initialized"); + const provider = state.providers.find(p => p.id === payload.providerId); + if (provider === undefined) return; + provider.models = { + stateDescription: "loaded", + availableModels: payload.models + }; + // Default the chat model to the first available one if none is set. + if (provider.selectedModelId === undefined && payload.models.length > 0) { + provider.selectedModelId = payload.models[0].id; + } + }, + modelsFetchFailed: (state, { payload }: { payload: { providerId: string } }) => { + assert(state.stateDescription === "initialized"); + const provider = state.providers.find(p => p.id === payload.providerId); + if (provider === undefined) return; + provider.models = { stateDescription: "error" }; + }, + modelSelected: ( + state, + { payload }: { payload: { providerId: string; modelId: string } } + ) => { + assert(state.stateDescription === "initialized"); + const provider = state.providers.find(p => p.id === payload.providerId); + // Synchronous user action on a displayed provider: it must exist. + assert(provider !== undefined); + provider.selectedModelId = payload.modelId; + }, + addCustomProvider: ( + state, + { payload }: { payload: { provider: State.Provider.Custom } } + ) => { + assert(state.stateDescription === "initialized"); + state.providers.push(payload.provider); + state.activeProviderId ??= payload.provider.id; + }, + editCustomProvider: ( + state, + { + payload + }: { + payload: { + providerId: string; + name: string; + provider: string; + apiBase: string; + apiKey: string; + }; + } + ) => { + assert(state.stateDescription === "initialized"); + const provider = state.providers.find(p => p.id === payload.providerId); + // Editing an existing custom provider from its dialog: it must exist. + assert(provider !== undefined); + assert(provider.kind === "custom"); + provider.name = payload.name; + provider.provider = payload.provider; + provider.apiBase = payload.apiBase; + provider.apiKey = payload.apiKey; + // Credentials changed → the previous models list no longer applies. + provider.models = { stateDescription: "fetching" }; + }, + deleteCustomProvider: ( + state, + { payload }: { payload: { providerId: string } } + ) => { + // Deleting is only reachable from the initialized UI. + assert(state.stateDescription === "initialized"); + + state.providers = state.providers.filter(p => p.id !== payload.providerId); + if (state.activeProviderId === payload.providerId) { + state.activeProviderId = state.providers.find( + provider => + provider.kind === "custom" || + provider.auth.stateDescription === "authenticated" + )?.id; + } + } + } +}); diff --git a/web/src/core/usecases/ai/thunks.ts b/web/src/core/usecases/ai/thunks.ts new file mode 100644 index 000000000..2267e89f7 --- /dev/null +++ b/web/src/core/usecases/ai/thunks.ts @@ -0,0 +1,373 @@ +import type { Thunks } from "core/bootstrap"; +import { createUsecaseContextApi } from "clean-architecture"; +import { actions, name } from "./state"; +import type { State } from "./state"; +import { + parseAiConfigStr, + serializeAiConfig, + type PersistedAiConfig, + type PersistedModelSelection +} from "./decoupledLogic/persistedAiConfig"; +import { fetchAiModels } from "core/tools/fetchAiModels"; +import type { GetTokenResult } from "core/ports/Ai"; +import * as userConfigs from "core/usecases/userConfigs"; +import { assert } from "tsafe"; + +function getTokenResultToAuth(result: GetTokenResult): State.Provider.Region["auth"] { + switch (result.status) { + case "no-account": + return { stateDescription: "no account" }; + case "error": + return { stateDescription: "error" }; + case "success": + return { stateDescription: "authenticated", token: result.token }; + } +} + +function toPersistedSelection( + selectedModel: string | undefined +): PersistedModelSelection { + return { + modelId: selectedModel ?? null + }; +} + +function fromPersistedSelection( + selection: PersistedModelSelection | undefined +): string | undefined { + return selection?.modelId ?? undefined; +} + +export const thunks = { + isAvailable: + () => + (...args): boolean => { + const [, , { paramsOfBootstrapCore }] = args; + + return paramsOfBootstrapCore.isAiEnabled; + }, + 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.regionAuthRefreshed({ + providerId, + auth: getTokenResultToAuth(result) + }) + ); + }, + setActiveProvider: + (params: { activeProviderId: string }) => + async (...args) => { + const { activeProviderId } = params; + const [dispatch] = args; + + dispatch(actions.activeProviderChanged({ activeProviderId })); + 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()); + }, + deleteCustomProvider: + (params: { providerId: string }) => + async (...args) => { + const { providerId } = params; + const [dispatch] = args; + + dispatch(actions.deleteCustomProvider({ providerId })); + await dispatch(privateThunks.persistConfig()); + }, + // The add/edit form (values, validation, connection-test result, open state) is + // owned by the UI. The core only exposes the resulting operations on the state. + addCustomProvider: + (params: { name: string; provider: string; apiBase: string; apiKey: string }) => + async (...args) => { + const { name, provider, apiBase, apiKey } = params; + const [dispatch] = args; + + const providerId = crypto.randomUUID(); + + dispatch( + actions.addCustomProvider({ + provider: { + kind: "custom", + id: providerId, + name, + provider, + apiBase, + apiKey, + models: { stateDescription: "fetching" }, + selectedModelId: undefined + } + }) + ); + + await dispatch(privateThunks.persistConfig()); + await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); + }, + editCustomProvider: + (params: { + providerId: string; + name: string; + provider: string; + apiBase: string; + apiKey: string; + }) => + async (...args) => { + const { providerId, name, provider, apiBase, apiKey } = params; + const [dispatch] = args; + + dispatch( + actions.editCustomProvider({ + providerId, + name: name, + provider, + apiBase, + apiKey + }) + ); + + await dispatch(privateThunks.persistConfig()); + await dispatchFetchedModels({ dispatch, providerId, apiBase, apiKey }); + }, + // Command-query thunk: the connection-test result is purely UI-local (it never + // touches the persisted state), so returning it here is intentional. + testCustomProviderConnection: + (params: { apiBase: string; apiKey: string }) => + async (): Promise<{ modelCount: number }> => { + const { apiBase, apiKey } = params; + const models = await fetchAiModels({ apiBase, token: apiKey }); + return { modelCount: models.length }; + } +} satisfies Thunks; + +const privateThunks = { + persistConfig: + () => + async (...args) => { + const [dispatch, getState] = args; + + const state = getState()[name]; + + if (state.stateDescription !== "initialized") return; + + const aiConfig: PersistedAiConfig = { + customProviders: state.providers + .filter((p): p is State.Provider.Custom => p.kind === "custom") + .map(({ id, name, provider, apiBase, apiKey }) => ({ + id, + name, + provider, + apiBase, + apiKey + })), + selections: Object.fromEntries( + state.providers.map(p => [ + p.id, + toPersistedSelection(p.selectedModelId) + ]) + ), + activeProviderId: state.activeProviderId ?? null + }; + + await dispatch( + userConfigs.thunks.changeValue({ + key: "aiConfigStr", + value: serializeAiConfig({ aiConfig }) + }) + ); + }, + initialize: + () => + async (...args) => { + const [dispatch, getState, { ai }] = args; + + // `ai` (region-provided adapters) may be empty: the feature can be enabled + // with no region gateway, in which case only custom providers are loaded. + // Build one region provider per region-provided endpoint, keeping a handle + // on its adapter + token result for the post-init model fetch. + let regionEntries; + let customProviders: State.Provider.Custom[]; + + try { + const persisted = parseAiConfigStr({ + aiConfigStr: userConfigs.selectors.userConfigs(getState()).aiConfigStr + }); + + regionEntries = await Promise.all( + ai.map(async aiProvider => { + const tokenResult = await aiProvider.getToken(); + + const provider: State.Provider.Region = { + kind: "region", + id: aiProvider.id, + name: aiProvider.name, + provider: aiProvider.provider, + webUiUrl: aiProvider.webUiUrl, + apiBase: aiProvider.apiBase, + auth: getTokenResultToAuth(tokenResult), + models: + tokenResult.status === "success" + ? { stateDescription: "fetching" } + : undefined, + selectedModelId: fromPersistedSelection( + persisted?.selections[aiProvider.id] + ) + }; + + return { provider, aiProvider, tokenResult }; + }) + ); + + const regionProviders = regionEntries.map(({ provider }) => provider); + + customProviders = (persisted?.customProviders ?? []).map(p => ({ + kind: "custom", + id: p.id, + name: p.name, + // Configs persisted before the field existed default to "openai". + provider: p.provider ?? "openai", + apiBase: p.apiBase, + apiKey: p.apiKey, + models: { stateDescription: "fetching" }, + selectedModelId: fromPersistedSelection(persisted?.selections[p.id]) + })); + + const providers = [...regionProviders, ...customProviders]; + const defaultableProviderIds = [ + ...regionEntries + .filter(({ tokenResult }) => tokenResult.status === "success") + .map(({ provider }) => provider.id), + ...customProviders.map(provider => provider.id) + ]; + + const activeProviderId = ((): string | undefined => { + // Never saved a preference → default to the first usable provider. + if (persisted === null) { + return defaultableProviderIds[0]; + } + + const stored = persisted.activeProviderId ?? undefined; + + if (stored !== undefined && defaultableProviderIds.includes(stored)) { + return stored; + } + + return defaultableProviderIds[0]; + })(); + + dispatch(actions.initialized({ providers, activeProviderId })); + } catch { + dispatch(actions.initializationFailed()); + return; + } + + // Awaited so the whole AI context (providers + their model lists) is + // ready before `initialize` resolves. Bootstrap awaits this thunk, and + // `getCoreSync` suspends until bootstrap resolves, so the launcher's + // one-shot read of `aiOnyxiaContext` always sees the loaded models. + await Promise.all([ + ...regionEntries.map(async ({ provider, aiProvider, tokenResult }) => { + if (tokenResult.status !== "success") return; + try { + const models = await aiProvider.listModels(tokenResult.token); + dispatch( + actions.modelsLoaded({ + providerId: provider.id, + models + }) + ); + } catch { + dispatch(actions.modelsFetchFailed({ providerId: provider.id })); + } + }), + ...customProviders.map(p => + dispatchFetchedModels({ + dispatch, + providerId: p.id, + apiBase: p.apiBase, + apiKey: p.apiKey + }) + ) + ]); + } +} satisfies Thunks; + +const { getContext, setContext, getIsContextSet } = createUsecaseContextApi<{ + prInitialized: Promise; +}>(); + +export const protectedThunks = { + // Initiates the AI use-case. Dispatched once by bootstrap, *after* the region AI + // adapters have been wired into `context.ai`. Idempotent: a second dispatch + // returns the same in-flight promise. This is the ONLY place that starts the + // work — consumers must use `waitForInitialization`, never call this, so they + // can't lock the context before `context.ai` is populated. + initialize: + () => + (...args): Promise => { + const [dispatch, , rootContext] = args; + + if (getIsContextSet(rootContext)) { + return getContext(rootContext).prInitialized; + } + + const prInitialized = dispatch(privateThunks.initialize()); + + setContext(rootContext, { prInitialized }); + + return prInitialized; + }, + // Awaits the in-flight initialization if it has started, otherwise resolves + // immediately. Crucially it never triggers the init itself: callers like the + // launcher's `getXOnyxiaContext` can run very early (restorable-config + // autocomplete, before bootstrap has wired up the region adapters), and a + // premature init would build the providers from an empty `context.ai` and + // freeze that wrong state. Early callers simply see the AI context as + // not-yet-available; the real init happens later in bootstrap. + waitForInitialization: + () => + async (...args): Promise => { + const [, , rootContext] = args; + + if (!getIsContextSet(rootContext)) { + return; + } + + await getContext(rootContext).prInitialized; + } +} satisfies Thunks; + +async function dispatchFetchedModels(params: { + dispatch: ( + action: + | ReturnType + | ReturnType + ) => void; + providerId: string; + apiBase: string; + apiKey: string; +}): Promise { + const { dispatch, providerId, apiBase, apiKey } = params; + try { + const models = await fetchAiModels({ apiBase, token: apiKey }); + dispatch(actions.modelsLoaded({ providerId, models })); + } catch { + dispatch(actions.modelsFetchFailed({ 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/decoupledLogic/computeHelmValues.test.ts b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts index dcf0621e6..0ad8059f5 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { computeHelmValues } from "./computeHelmValues"; +import { computeHelmValues, type JSONSchemaLike } from "./computeHelmValues"; import YAML from "yaml"; import { symToStr } from "tsafe/symToStr"; @@ -79,6 +79,71 @@ describe(symToStr({ computeHelmValues }), () => { expect(got).toStrictEqual(expected); }); + it("Use x-onyxia on object with properties", () => { + const activeProvider = { + id: "openai", + name: "OpenAI", + provider: "openai", + apiBase: "https://api.openai.com/v1", + apiKey: "sk-test", + selectedModel: "gpt-4.1", + models: ["gpt-4.1", "gpt-4.1-mini"] + }; + + const xOnyxiaContext = { + ai: { + activeProvider, + providers: [activeProvider] + }, + s3: undefined + }; + + const providerProperties: Record = { + id: { type: "string", default: "" }, + name: { type: "string", default: "" }, + provider: { type: "string", default: "" }, + apiBase: { type: "string", default: "" }, + apiKey: { type: "string", default: "" }, + selectedModel: { type: "string", default: "" }, + models: { type: "array", default: [], items: { type: "string" } } + }; + + const got = computeHelmValues({ + helmValuesSchema: { + type: "object", + properties: { + activeProvider: { + type: "object", + default: {}, + properties: providerProperties, + "x-onyxia": { + overwriteDefaultWith: "{{ai.activeProvider}}" + } + }, + providers: { + type: "array", + default: [], + items: { + type: "object", + properties: providerProperties + }, + "x-onyxia": { + overwriteDefaultWith: "{{ai.providers}}" + } + } + } + }, + helmValuesYaml: YAML.stringify({}), + xOnyxiaContext, + infoAmountInHelmValues: "user provided" + }); + + expect(got.helmValues).toStrictEqual({ + activeProvider, + providers: [activeProvider] + }); + }); + it("Use default", () => { const xOnyxiaContext = { a: { @@ -1011,4 +1076,91 @@ describe(symToStr({ computeHelmValues }), () => { expect(got).toStrictEqual(expected); }); + + it("array mapping with overwriteListEnumWith", () => { + const xOnyxiaContext = { + s3: {}, + a: { + b: [ + { p: "foo", q_x: "xxx_1", q_options: ["xxx_1", "yyy_1"] }, + { p: "bar", q_x: "xxx_2", q_options: ["xxx_2", "yyy_2"] }, + { p: "baz", q_x: "xxx_3", q_options: ["xxx_3", "yyy_3"] } + ] + } + }; + + const got = computeHelmValues({ + helmValuesSchema: { + type: "object", + properties: { + r: { + type: "array", + "x-onyxia": { + overwriteDefaultWith: "{{a.b}}" + }, + items: { + type: "object", + properties: { + p: { + type: "string" + }, + q: { + type: "string", + listEnum: [], + "x-onyxia": { + overwriteDefaultWith: "{{q_x}}", + overwriteListEnumWith: "{{q_options}}" + } + } + } + } + } + } + }, + helmValuesYaml: YAML.stringify({}), + xOnyxiaContext, + infoAmountInHelmValues: "user provided" + }); + + const expected = { + helmValues: { + r: [ + { p: "foo", q: "xxx_1" }, + { p: "bar", q: "xxx_2" }, + { p: "baz", q: "xxx_3" } + ] + }, + helmValuesSchema_forDataTextEditor: { + type: "object", + properties: { + r: { + type: "array", + default: [ + { p: "foo", q: "xxx_1" }, + { p: "bar", q: "xxx_2" }, + { p: "baz", q: "xxx_3" } + ], + items: { + type: "object", + properties: { + p: { + type: "string" + }, + q: { + type: "string" + } + }, + required: ["p", "q"], + additionalProperties: false + } + } + }, + required: ["r"], + additionalProperties: false + }, + isChartUsingS3: false + }; + + expect(got).toStrictEqual(expected); + }); }); diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.ts b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.ts index 97ee5596f..f99786f8f 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeHelmValues.ts @@ -190,49 +190,6 @@ export function computeHelmValues_rec(params: { return constValue; } - schema_is_object_with_known_properties: { - if (helmValuesSchemaType !== "object") { - break schema_is_object_with_known_properties; - } - - const { properties } = helmValuesSchema; - - if (properties === undefined) { - break schema_is_object_with_known_properties; - } - - return Object.fromEntries( - Object.entries(properties).map(([propertyName, propertySchema]) => [ - propertyName, - computeHelmValues_rec({ - helmValuesSchema: propertySchema, - helmValuesYaml_parsed: - helmValuesYaml_parsed instanceof Object && - !(helmValuesYaml_parsed instanceof Array) - ? helmValuesYaml_parsed[propertyName] - : undefined, - xOnyxiaContext, - helmValuesSchema_forDataTextEditor: (() => { - if (helmValuesSchema_forDataTextEditor === undefined) { - return undefined; - } - - const { properties: property_forDataTextEditor } = - helmValuesSchema_forDataTextEditor; - - assert(property_forDataTextEditor !== undefined); - - const out = property_forDataTextEditor[propertyName]; - - assert(out !== undefined, "crash"); - - return out; - })() - }) - ]) - ); - } - use_x_onyxia_overwriteDefaultWith: { const { overwriteDefaultWith } = helmValuesSchema["x-onyxia"] ?? {}; @@ -344,6 +301,49 @@ export function computeHelmValues_rec(params: { return resolvedValue; } + schema_is_object_with_known_properties: { + if (helmValuesSchemaType !== "object") { + break schema_is_object_with_known_properties; + } + + const { properties } = helmValuesSchema; + + if (properties === undefined) { + break schema_is_object_with_known_properties; + } + + return Object.fromEntries( + Object.entries(properties).map(([propertyName, propertySchema]) => [ + propertyName, + computeHelmValues_rec({ + helmValuesSchema: propertySchema, + helmValuesYaml_parsed: + helmValuesYaml_parsed instanceof Object && + !(helmValuesYaml_parsed instanceof Array) + ? helmValuesYaml_parsed[propertyName] + : undefined, + xOnyxiaContext, + helmValuesSchema_forDataTextEditor: (() => { + if (helmValuesSchema_forDataTextEditor === undefined) { + return undefined; + } + + const { properties: property_forDataTextEditor } = + helmValuesSchema_forDataTextEditor; + + assert(property_forDataTextEditor !== undefined); + + const out = property_forDataTextEditor[propertyName]; + + assert(out !== undefined, "crash"); + + return out; + })() + }) + ]) + ); + } + use_default: { const defaultValue = helmValuesSchema.default; diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts index 02a0fd014..03abbcc70 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.test.ts @@ -396,6 +396,89 @@ describe(symToStr({ computeRootFormFieldGroup }), () => { expect(got).toStrictEqual(expected); }); + it("overwriteListEnumWith with relative path in array items", () => { + const got = computeRootFormFieldGroup({ + helmValuesSchema: { + type: "object", + properties: { + providers: { + type: "array", + items: { + type: "object", + properties: { + selectedModel: { + type: "string", + "x-onyxia": { + overwriteListEnumWith: "{{models}}" + } + }, + models: { + type: "array", + items: { type: "string" }, + "x-onyxia": { + hidden: true + } + } + } + } + } + } + }, + helmValues: { + providers: [{ selectedModel: "model-b" }] + }, + xOnyxiaContext: { models: ["model-a", "model-b"] }, + autoInjectionDisabledFields: undefined, + autocompleteOptions: [] + }); + + const expected: FormFieldGroup = { + type: "group", + helmValuesPath: [], + title: "", + description: undefined, + nodes: [ + { + type: "group", + helmValuesPath: ["providers"], + title: "providers", + description: undefined, + nodes: [ + { + type: "group", + helmValuesPath: ["providers", 0], + title: "providers 1", + description: undefined, + nodes: [ + { + type: "field", + title: "selectedModel", + isReadonly: false, + fieldType: "select", + helmValuesPath: ["providers", 0, "selectedModel"], + description: undefined, + options: ["model-a", "model-b"], + selectedOptionIndex: 1 + } + ], + canAdd: false, + canRemove: false, + isAutoInjected: undefined + } + ], + canAdd: true, + canRemove: true, + isAutoInjected: undefined + } + ], + canAdd: false, + canRemove: false, + isAutoInjected: undefined + }; + + expect(got).toStrictEqual(expected); + }); + it("with autocomplete options", () => { const xOnyxiaContext = { r: [1, 2, 3] diff --git a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.ts b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.ts index cac0fa9fa..660833366 100644 --- a/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.ts +++ b/web/src/core/usecases/launcher/decoupledLogic/computeRootForm/computeRootFormFieldGroup.ts @@ -457,12 +457,32 @@ function computeRootFormFieldGroup_rec(params: { assert(values instanceof Array); const nodes = values - .map((...[, index]) => { + .map((value_i, index) => { const helmValuesPath_child = [...helmValuesPath, index]; + // Same item scoping as in computeHelmValues_rec (array_mapping): + // let x-onyxia relative expressions resolve against the + // current array item before falling back to the global context. + const xOnyxiaContext_child = (() => { + if (!(value_i instanceof Object) || value_i instanceof Array) { + return xOnyxiaContext; + } + + return new Proxy(xOnyxiaContext, { + get(...args) { + const [, prop] = args; + + if (typeof prop === "string" && prop in value_i) { + return value_i[prop]; + } + return Reflect.get(...args); + } + }); + })(); + return computeRootFormFieldGroup_rec({ helmValues, - xOnyxiaContext, + xOnyxiaContext: xOnyxiaContext_child, helmValuesSchema: itemSchema, autoInjectionDisabledFields, helmValuesPath: helmValuesPath_child, diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index 6f4b06399..6eb9e950c 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"; @@ -586,6 +587,14 @@ export const protectedThunks = { { paramsOfBootstrapCore, secretsManager, onyxiaApi } ] = args; + // `aiOnyxiaContext` is read synchronously below as a one-shot snapshot, so + // wait for the AI use-case's in-flight initialization (providers + their + // model lists) to finish first. This only awaits an init already started + // by bootstrap; it never triggers one (which would otherwise run before + // the region AI adapters are wired up, when called early for restorable- + // config autocomplete). + await dispatch(aiUsecase.protectedThunks.waitForInitialization()); + const { user } = await onyxiaApi.getUserAndProjects(); const userConfigs = userConfigsUsecase.selectors.userConfigs(getState()); @@ -768,6 +777,7 @@ export const protectedThunks = { useCertManager: region.certManager?.useCertManager, certManagerClusterIssuer: region.certManager?.certManagerClusterIssuer }, + ai: aiUsecase.selectors.aiOnyxiaContext(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/env.ts b/web/src/env.ts index 9ed568897..e06110a4f 100644 --- a/web/src/env.ts +++ b/web/src/env.ts @@ -1277,6 +1277,20 @@ export const { env, injectEnvsTransferableToKeycloakTheme } = createParsedEnvs([ return envValue === "true"; } }, + { + envName: "ENABLED_AI", + isUsedInKeycloakTheme: false, + validateAndParseOrGetDefault: ({ envValue, envName }) => { + const possibleValues = ["true", "false"]; + + assert( + possibleValues.indexOf(envValue) >= 0, + `${envName} should either be ${possibleValues.join(" or ")}` + ); + + return envValue === "true"; + } + }, { envName: "VAULT_DOCUMENTATION_LINK", isUsedInKeycloakTheme: false, diff --git a/web/src/ui/App/App.tsx b/web/src/ui/App/App.tsx index 3451434bf..67f843f22 100644 --- a/web/src/ui/App/App.tsx +++ b/web/src/ui/App/App.tsx @@ -37,6 +37,7 @@ triggerCoreBootstrap({ isAuthGloballyRequired: env.AUTHENTICATION_GLOBALLY_REQUIRED, enableOidcDebugLogs: env.OIDC_DEBUG_LOGS, disableDisplayAllCatalog: env.DISABLE_DISPLAY_ALL_CATALOG, + isAiEnabled: env.ENABLED_AI, getIsDarkModeEnabled: () => evtTheme.state.isDarkModeEnabled }); diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index e5345a6c7..eea9a1d5b 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,64 @@ 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", + "gateway error": "Das KI-Gateway konnte nicht initialisiert werden.", + "default provider": "Standardanbieter", + "set default provider": "Als Standard festlegen", + "refresh credentials": "Anmeldedaten aktualisieren", + "delete provider": "Löschen", + "edit provider": "Bearbeiten", + "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.", + "custom provider api base field": "API-Basis-URL", + "custom provider api key field": "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. + + ) + }, + ProviderValueField: { + copy: "Kopieren" + }, + ModelsSection: { + "model label": "Modell", + "not defined": "Nicht definiert", + "models fetch error": + "Modelle konnten nicht abgerufen werden — überprüfen Sie URL und API-Schlüssel." + }, + CustomProviderFormDialog: { + "add custom provider title": "Benutzerdefinierte KI-Anbieter", + "edit custom provider title": "KI-Anbieter bearbeiten", + "custom provider label field": "Name", + "custom provider type field": "Provider-Typ", + "custom provider api base field": "API-Basis-URL", + "custom provider api key field": "API-Schlüssel", + "provider test": "Verbindung testen", + "provider test success": "Verbindung erfolgreich", + "provider test error": + "Verbindung fehlgeschlagen — URL und API-Schlüssel prüfen.", + "provider save": "Hinzufügen", + "provider update": "Speichern", + "provider cancel": "Abbrechen" + }, AccountVaultTab: { "credentials section title": "Vault-Anmeldeinformationen", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index bf0d71b2e..7a4f35993 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,61 @@ 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", + "gateway error": "Unable to initialize the AI gateway.", + "default provider": "Default provider", + "set default provider": "Set default provider", + "refresh credentials": "Refresh credentials", + "delete provider": "Delete", + "edit provider": "Edit", + "custom providers section title": "Custom AI providers", + "custom providers section helper": + "Add your own OpenAI-compatible AI providers. Credentials are stored in your browser.", + "custom provider api base field": "API base URL", + "custom provider api key field": "API key", + "no account": ({ webUiUrl }) => ( + <> + You don't have an AI gateway account yet. Please log in to{" "} + + {webUiUrl} + {" "} + first to create your account. + + ) + }, + ProviderValueField: { + copy: "Copy" + }, + ModelsSection: { + "model label": "Model", + "not defined": "Not defined", + "models fetch error": "Unable to fetch models — check your URL and API key." + }, + CustomProviderFormDialog: { + "add custom provider title": "Custom AI providers", + "edit custom provider title": "Edit AI provider", + "custom provider label field": "Label", + "custom provider type field": "Provider type", + "custom provider api base field": "API base URL", + "custom provider api key field": "API key", + "provider test": "Test connection", + "provider test success": "Connection successful", + "provider test error": "Unable to connect — check URL and API key.", + "provider save": "Add", + "provider update": "Save changes", + "provider cancel": "Cancel" + }, AccountVaultTab: { "credentials section title": "Vault credentials", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index b60717f9d..a60d557ac 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,63 @@ 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", + "gateway error": "No se pudo inicializar la pasarela de IA.", + "default provider": "Proveedor predeterminado", + "set default provider": "Definir como predeterminado", + "refresh credentials": "Actualizar credenciales", + "delete provider": "Eliminar", + "edit provider": "Editar", + "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.", + "custom provider api base field": "URL base de la API", + "custom provider api key field": "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. + + ) + }, + ProviderValueField: { + copy: "Copiar" + }, + ModelsSection: { + "model label": "Modelo", + "not defined": "No definido", + "models fetch error": + "No se pueden obtener los modelos — compruebe la URL y la clave API." + }, + CustomProviderFormDialog: { + "add custom provider title": "Proveedores de IA personalizados", + "edit custom provider title": "Editar proveedor de IA", + "custom provider label field": "Etiqueta", + "custom provider type field": "Tipo de proveedor", + "custom provider api base field": "URL base de la API", + "custom provider api key field": "Clave API", + "provider test": "Probar conexión", + "provider test success": "Conexión exitosa", + "provider test error": "No se puede conectar — compruebe la URL y la clave API.", + "provider save": "Añadir", + "provider update": "Guardar", + "provider cancel": "Cancelar" + }, AccountVaultTab: { "credentials section title": "Credenciales de Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 2afa0521d..da533443d 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,62 @@ 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", + "gateway error": "Tekoäly-yhdyskäytävän alustus epäonnistui.", + "default provider": "Oletustarjoaja", + "set default provider": "Aseta oletukseksi", + "refresh credentials": "Päivitä tunnistetiedot", + "delete provider": "Poista", + "edit provider": "Muokkaa", + "custom providers section title": "Mukautetut tekoälyntarjoajat", + "custom providers section helper": + "Lisää omia OpenAI-yhteensopivia tekoälypalveluntarjoajia. Tunnukset tallennetaan selaimeesi.", + "custom provider api base field": "API-perus-URL", + "custom provider api key field": "API-avain", + "no account": ({ webUiUrl }) => ( + <> + Sinulla ei vielä ole tiliä tekoälyyhdyskäytävässä. Kirjaudu ensin sisään + osoitteeseen{" "} + + {webUiUrl} + {" "} + luodaksesi tilisi. + + ) + }, + ProviderValueField: { + copy: "Kopioi" + }, + ModelsSection: { + "model label": "Malli", + "not defined": "Ei määritetty", + "models fetch error": "Mallien haku epäonnistui — tarkista URL ja API-avain." + }, + CustomProviderFormDialog: { + "add custom provider title": "Mukautetut tekoälyntarjoajat", + "edit custom provider title": "Muokkaa tekoälyntarjoajaa", + "custom provider label field": "Tunniste", + "custom provider type field": "Palveluntarjoajan tyyppi", + "custom provider api base field": "API-perus-URL", + "custom provider api key field": "API-avain", + "provider test": "Testaa yhteys", + "provider test success": "Yhteys onnistui", + "provider test error": "Yhteyttä ei voi muodostaa — tarkista URL ja API-avain.", + "provider save": "Lisää", + "provider update": "Tallenna", + "provider cancel": "Peruuta" + }, AccountVaultTab: { "credentials section title": "Vault-todennustiedot", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index c82b1b715..fd4f5e249 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,64 @@ 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", + "gateway error": "Impossible d'initialiser la passerelle IA.", + "default provider": "Provider par défaut", + "set default provider": "Définir par défaut", + "refresh credentials": "Rafraîchir les identifiants", + "delete provider": "Supprimer", + "edit provider": "Modifier", + "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.", + "custom provider api base field": "URL de base de l'API", + "custom provider api key field": "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. + + ) + }, + ProviderValueField: { + copy: "Copier" + }, + ModelsSection: { + "model label": "Modèles", + "not defined": "Non défini", + "models fetch error": + "Impossible de récupérer les modèles — vérifiez l'URL et la clé API." + }, + CustomProviderFormDialog: { + "add custom provider title": "Providers IA personnalisés", + "edit custom provider title": "Modifier le provider IA", + "custom provider label field": "Nom", + "custom provider type field": "Type de provider", + "custom provider api base field": "URL de base de l'API", + "custom provider api key field": "Clé API", + "provider test": "Tester la connexion", + "provider test success": "Connexion réussie", + "provider test error": + "Impossible de se connecter — vérifiez l'URL et la clé API.", + "provider save": "Ajouter", + "provider update": "Enregistrer", + "provider cancel": "Annuler" + }, AccountVaultTab: { "credentials section title": "Identifiants Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index fff3fd947..ef09ab167 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,63 @@ 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", + "gateway error": "Impossibile inizializzare il gateway IA.", + "default provider": "Provider predefinito", + "set default provider": "Imposta come predefinito", + "refresh credentials": "Aggiorna credenziali", + "delete provider": "Elimina", + "edit provider": "Modifica", + "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.", + "custom provider api base field": "URL base API", + "custom provider api key field": "Chiave API", + "no account": ({ webUiUrl }) => ( + <> + Non hai ancora un account sul gateway IA. Per favore accedi prima su{" "} + + {webUiUrl} + {" "} + per creare il tuo account. + + ) + }, + ProviderValueField: { + copy: "Copia" + }, + ModelsSection: { + "model label": "Modello", + "not defined": "Non definito", + "models fetch error": + "Impossibile recuperare i modelli — controlla l'URL e la chiave API." + }, + CustomProviderFormDialog: { + "add custom provider title": "Provider IA personalizzati", + "edit custom provider title": "Modifica provider IA", + "custom provider label field": "Etichetta", + "custom provider type field": "Tipo di provider", + "custom provider api base field": "URL base API", + "custom provider api key field": "Chiave API", + "provider test": "Testa connessione", + "provider test success": "Connessione riuscita", + "provider test error": + "Impossibile connettersi — controlla l'URL e la chiave API.", + "provider save": "Aggiungi", + "provider update": "Salva", + "provider cancel": "Annulla" + }, AccountVaultTab: { "credentials section title": "Credenziali Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( @@ -509,9 +567,10 @@ export const translations: Translations<"it"> = { la nostra documentazione - {". \u00a0"} - Configurare il tuo Vault CLI locale - {"."} + .   + + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index e70227e01..79298f60f 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,63 @@ 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", + "gateway error": "Kan de AI-gateway niet initialiseren.", + "default provider": "Standaardprovider", + "set default provider": "Als standaard instellen", + "refresh credentials": "Referenties vernieuwen", + "delete provider": "Verwijderen", + "edit provider": "Bewerken", + "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.", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "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. + + ) + }, + ProviderValueField: { + copy: "Kopiëren" + }, + ModelsSection: { + "model label": "Model", + "not defined": "Niet gedefinieerd", + "models fetch error": + "Kan modellen niet ophalen — controleer uw URL en API-sleutel." + }, + CustomProviderFormDialog: { + "add custom provider title": "Aangepaste AI-providers", + "edit custom provider title": "AI-provider bewerken", + "custom provider label field": "Label", + "custom provider type field": "Providertype", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-sleutel", + "provider test": "Verbinding testen", + "provider test success": "Verbinding geslaagd", + "provider test error": + "Kan geen verbinding maken — controleer URL en API-sleutel.", + "provider save": "Toevoegen", + "provider update": "Opslaan", + "provider cancel": "Annuleren" + }, AccountVaultTab: { "credentials section title": "Gebrukersnamen Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 8912113f0..f8c46aeae 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,61 @@ 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", + "gateway error": "Kunne ikke initialisere AI-gatewayen.", + "default provider": "Standardleverandør", + "set default provider": "Angi som standard", + "refresh credentials": "Oppdater legitimasjon", + "delete provider": "Slett", + "edit provider": "Rediger", + "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.", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-nøkkel", + "no account": ({ webUiUrl }) => ( + <> + Du har ikke en konto på AI-gatewayen ennå. Logg inn først på{" "} + + {webUiUrl} + {" "} + for å opprette kontoen din. + + ) + }, + ProviderValueField: { + copy: "Kopier" + }, + ModelsSection: { + "model label": "Modell", + "not defined": "Ikke definert", + "models fetch error": "Kan ikke hente modeller — sjekk URL-en og API-nøkkelen." + }, + CustomProviderFormDialog: { + "add custom provider title": "Tilpassede AI-leverandører", + "edit custom provider title": "Rediger AI-leverandør", + "custom provider label field": "Etikett", + "custom provider type field": "Leverandørtype", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-nøkkel", + "provider test": "Test tilkobling", + "provider test success": "Tilkobling vellykket", + "provider test error": "Kan ikke koble til — sjekk URL og API-nøkkel.", + "provider save": "Legg til", + "provider update": "Lagre", + "provider cancel": "Avbryt" + }, AccountVaultTab: { "credentials section title": "Vault credentials", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index d65a93ad2..1ca65e252 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,61 @@ 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: "令牌", + "gateway error": "无法初始化 AI 网关。", + "default provider": "默认提供商", + "set default provider": "设为默认提供商", + "refresh credentials": "刷新凭据", + "delete provider": "删除", + "edit provider": "编辑", + "custom providers section title": "自定义 AI 提供商", + "custom providers section helper": + "添加您自己的兼容 OpenAI 的 AI 提供商。凭据存储在您的浏览器中。", + "custom provider api base field": "API 基础 URL", + "custom provider api key field": "API 密钥", + "no account": ({ webUiUrl }) => ( + <> + 您还没有 AI 网关账户。请先登录{" "} + + {webUiUrl} + {" "} + 以创建您的账户。 + + ) + }, + ProviderValueField: { + copy: "复制" + }, + ModelsSection: { + "model label": "模型", + "not defined": "未定义", + "models fetch error": "无法获取模型 — 请检查您的 URL 和 API 密钥。" + }, + CustomProviderFormDialog: { + "add custom provider title": "自定义 AI 提供商", + "edit custom provider title": "编辑 AI 提供商", + "custom provider label field": "标签", + "custom provider type field": "提供商类型", + "custom provider api base field": "API 基础 URL", + "custom provider api key field": "API 密钥", + "provider test": "测试连接", + "provider test success": "连接成功", + "provider test error": "无法连接 — 请检查 URL 和 API 密钥。", + "provider save": "添加", + "provider update": "保存", + "provider cancel": "取消" + }, AccountVaultTab: { "credentials section title": "保险库凭证", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index f7200dd3e..2ae98292f 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -55,6 +55,10 @@ export type ComponentKey = | import("ui/pages/account/AccountKubernetesTab").I18n | import("ui/pages/account/AccountUserInterfaceTab").I18n | import("ui/pages/account/AccountVaultTab").I18n + | import("ui/pages/account/AccountAiTab/AccountAiTab").I18n + | import("ui/pages/account/AccountAiTab/ProviderValueField").I18n + | import("ui/pages/account/AccountAiTab/ModelsSection").I18n + | import("ui/pages/account/AccountAiTab/CustomProviderFormDialog").I18n | import("ui/App/Footer").I18n | import("ui/pages/catalog/Page").I18n | import("ui/pages/catalog/CatalogChartCard").I18n diff --git a/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx new file mode 100644 index 000000000..e86073582 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx @@ -0,0 +1,337 @@ +import { memo } from "react"; +import { useTranslation } from "ui/i18n"; +import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { useConst } from "powerhooks/useConst"; +import { copyToClipboard } from "ui/tools/copyToClipboard"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { Evt } from "evt"; +import type { UnpackEvt } from "evt"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Icon } from "onyxia-ui/Icon"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { useCoreState, getCoreSync } from "core"; +import { getIconUrlByName } from "lazy-icons"; +import { ProviderValueField } from "./ProviderValueField"; +import { ModelsSection } from "./ModelsSection"; +import { + CustomProviderFormDialog, + type Props as CustomProviderFormDialogProps +} from "./CustomProviderFormDialog"; + +export type Props = { + className?: string; +}; + +export const AccountAiTab = memo((props: Props) => { + const { className } = props; + + const { classes } = useStyles(); + + const { + functions: { ai } + } = getCoreSync(); + + const { stateDescription, regionProviders, customProviders } = useCoreState( + "ai", + "main" + ); + + const { t } = useTranslation({ AccountAiGatewayTab: AccountAiTab }); + + const evtCustomProviderFormDialogOpen = useConst(() => + Evt.create>() + ); + + const onFieldRequestCopyFactory = useCallbackFactory(([text]: [string]) => + copyToClipboard(text) + ); + + const onRefreshClickFactory = useCallbackFactory(([providerId]: [string]) => + ai.refreshToken({ providerId }) + ); + + const onSetDefaultProviderFactory = useCallbackFactory(([providerId]: [string]) => + ai.setActiveProvider({ + activeProviderId: providerId + }) + ); + + const onDeleteCustomProviderFactory = useCallbackFactory(([providerId]: [string]) => + ai.deleteCustomProvider({ providerId }) + ); + + const onAddClick = useConstCallback(() => + evtCustomProviderFormDialogOpen.post({ editedProvider: undefined }) + ); + + const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => { + const provider = customProviders.find(p => p.id === providerId); + if (provider === undefined) return; + evtCustomProviderFormDialogOpen.post({ + editedProvider: { + id: provider.id, + name: provider.name, + provider: provider.provider, + apiBase: provider.apiBase, + apiKey: provider.apiKey + } + }); + }); + + if (stateDescription !== "initialized") { + return stateDescription === "error" ? ( + + {t("gateway error")} + + ) : ( + + ); + } + + const renderDefaultProviderAction = (params: { + providerId: string; + isDefault: boolean; + }) => + params.isDefault ? ( +
+ + + {t("default provider")} + +
+ ) : ( + + ); + + return ( +
+ {regionProviders.map(regionProvider => ( +
+
+ {regionProvider.name} +
+ {regionProvider.auth.stateDescription === "authenticated" && ( + <> + + {renderDefaultProviderAction({ + providerId: regionProvider.id, + isDefault: regionProvider.isDefault + })} + + )} +
+
+ + {regionProvider.auth.stateDescription === "no account" && ( + + {t("no account", { webUiUrl: regionProvider.webUiUrl })} + + )} + + {regionProvider.auth.stateDescription === "error" && ( + + {t("gateway error")} + + )} + + {regionProvider.auth.stateDescription === "authenticated" && ( + <> + +
+ + + +
+ + )} +
+ ))} + +
+ + +
+ + {customProviders.map(provider => ( +
+
+ {provider.name} +
+ + + {renderDefaultProviderAction({ + providerId: provider.id, + isDefault: provider.isDefault + })} +
+
+
+ + + +
+
+ ))} + + +
+ ); +}); + +const { i18n } = declareComponentKeys< + | "default provider" + | "set default provider" + | "refresh credentials" + | "delete provider" + | "edit provider" + | "credentials section title" + | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } + | "api base url" + | "token" + | "gateway error" + | "custom providers section title" + | "custom providers section helper" + | "custom provider api base field" + | "custom provider api key field" + | { K: "no account"; P: { webUiUrl: string }; R: JSX.Element } +>()({ AccountAiGatewayTab: AccountAiTab }); +export type I18n = typeof i18n; + +const useStyles = tss + .withName({ AccountAiGatewayTab: AccountAiTab }) + .create(({ theme }) => ({ + customProvidersSectionHeader: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(1), + marginTop: theme.spacing(4) + }, + providerCard: { + border: `1px solid ${theme.colors.useCases.typography.textDisabled}`, + borderRadius: theme.spacing(1), + padding: theme.spacing(3), + marginTop: theme.spacing(3) + }, + providerCardHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: theme.spacing(2) + }, + providerCardActions: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + flexWrap: "wrap", + justifyContent: "flex-end" + }, + compactActionButton: { + minHeight: 28, + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5) + }, + defaultProviderBadge: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(1), + minHeight: 28, + borderRadius: 9999, + backgroundColor: theme.colors.useCases.alertSeverity.success.background + }, + defaultProviderBadgeIcon: { + color: theme.colors.useCases.alertSeverity.success.main + }, + defaultProviderBadgeText: { + color: theme.colors.useCases.alertSeverity.success.main + }, + providerFields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3) + }, + errorText: { + color: theme.colors.useCases.alertSeverity.error.main + } + })); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx new file mode 100644 index 000000000..ada17beb6 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx @@ -0,0 +1,249 @@ +import { memo, useState } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { useEvt } from "evt/hooks"; +import type { NonPostableEvt, UnpackEvt } from "evt"; +import { assert } from "tsafe/assert"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import TextField from "@mui/material/TextField"; +import { getCoreSync } from "core"; + +export type Props = { + evtOpen: NonPostableEvt<{ + editedProvider: + | { + id: string; + name: string; + provider: string; + apiBase: string; + apiKey: string; + } + | undefined; + }>; +}; + +type OpenParams = UnpackEvt; + +type FormValues = { + name: string; + provider: string; + apiBase: string; + apiKey: string; +}; + +type FormTest = + | { stateDescription: "idle" } + | { stateDescription: "testing" } + | { stateDescription: "success"; modelCount: number } + | { stateDescription: "error" }; + +const defaultFormValues: FormValues = { + name: "", + provider: "openai", + apiBase: "", + apiKey: "" +}; + +export const CustomProviderFormDialog = memo((props: Props) => { + const { evtOpen } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ CustomProviderFormDialog }); + + const { + functions: { ai } + } = getCoreSync(); + + // The add/edit custom-provider form is entirely UI-owned: its open state, edited + // values and connection-test result never go through the core. The core only + // exposes the resulting operations (add/edit/test). + const [openState, setOpenState] = useState< + { editedProviderId: string | undefined } | undefined + >(undefined); + const [values, setValues] = useState(defaultFormValues); + const [test, setTest] = useState({ stateDescription: "idle" }); + + useEvt( + ctx => { + evtOpen.attach(ctx, ({ editedProvider }: OpenParams) => { + setValues( + editedProvider === undefined + ? defaultFormValues + : { + name: editedProvider.name, + provider: editedProvider.provider, + apiBase: editedProvider.apiBase, + apiKey: editedProvider.apiKey + } + ); + setTest({ stateDescription: "idle" }); + setOpenState({ editedProviderId: editedProvider?.id }); + }); + }, + [evtOpen] + ); + + const isEditing = openState?.editedProviderId !== undefined; + const canSave = + values.name !== "" && + values.provider !== "" && + values.apiBase !== "" && + values.apiKey !== ""; + const canTest = + values.apiBase !== "" && + values.apiKey !== "" && + test.stateDescription !== "testing"; + + const onClose = useConstCallback(() => setOpenState(undefined)); + + const onFieldChangeFactory = useCallbackFactory( + ([key]: [keyof FormValues], [event]: [{ target: { value: string } }]) => { + const { value } = event.target; + setValues(values => ({ ...values, [key]: value })); + // Only credential changes invalidate a previous connection-test result; + // the display label and provider type don't affect connectivity. + if (key !== "name" && key !== "provider") { + setTest({ stateDescription: "idle" }); + } + } + ); + + const onTest = useConstCallback(async () => { + setTest({ stateDescription: "testing" }); + try { + const { modelCount } = await ai.testCustomProviderConnection({ + apiBase: values.apiBase, + apiKey: values.apiKey + }); + setTest({ stateDescription: "success", modelCount }); + } catch { + setTest({ stateDescription: "error" }); + } + }); + + const onSave = useConstCallback(async () => { + assert(openState !== undefined); + const { editedProviderId } = openState; + setOpenState(undefined); + if (editedProviderId === undefined) { + await ai.addCustomProvider(values); + } else { + await ai.editCustomProvider({ providerId: editedProviderId, ...values }); + } + }); + + return ( + + + + + +
+ + {test.stateDescription === "success" && ( + + {t("provider test success")} ({test.modelCount}) + + )} + {test.stateDescription === "error" && ( + + {t("provider test error")} + + )} +
+ + } + buttons={ + <> + + + + } + /> + ); +}); + +const { i18n } = declareComponentKeys< + | "add custom provider title" + | "edit custom provider title" + | "custom provider label field" + | "custom provider type field" + | "custom provider api base field" + | "custom provider api key field" + | "provider test" + | "provider test success" + | "provider test error" + | "provider save" + | "provider update" + | "provider cancel" +>()({ CustomProviderFormDialog }); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ CustomProviderFormDialog }).create(({ theme }) => ({ + formFields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(4) + }, + testRow: { + display: "flex", + alignItems: "center", + gap: theme.spacing(3) + }, + testSuccess: { + color: theme.colors.useCases.alertSeverity.success.main + }, + testError: { + color: theme.colors.useCases.alertSeverity.error.main + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx new file mode 100644 index 000000000..e4e9aa3ab --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx @@ -0,0 +1,115 @@ +import { memo } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import { getCoreSync } from "core"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; + +export type AiModel = { id: string; name: string }; + +export type Models = + | { stateDescription: "fetching" } + | { stateDescription: "error" } + | { stateDescription: "loaded"; availableModels: AiModel[] } + | undefined; + +export type Props = { + providerId: string; + models: Models; + selectedModel: string | undefined; +}; + +export const ModelsSection = memo((props: Props) => { + const { providerId, models, selectedModel } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ ModelsSection }); + const { + functions: { ai } + } = getCoreSync(); + + const onModelChange = useConstCallback((event: { target: { value: string } }) => + ai.setSelectedModel({ providerId, modelId: event.target.value }) + ); + + if (models === undefined) { + return null; + } + + switch (models.stateDescription) { + case "fetching": + return ; + case "error": + return ( + + {t("models fetch error")} + + ); + case "loaded": + return ( +
+ {t("model label")} +
+ +
+
+ ); + } +}); + +const { i18n } = declareComponentKeys< + "model label" | "not defined" | "models fetch error" +>()({ + ModelsSection +}); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ ModelsSection }).create(({ theme }) => ({ + root: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + codeFrame: { + minHeight: 45, + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, + borderRadius: theme.spacing(1), + backgroundColor: theme.colors.useCases.surfaces.surface2, + minWidth: 0 + }, + modelSelect: { + flex: 1, + minWidth: 0, + "& .MuiOutlinedInput-notchedOutline": { + border: "none" + }, + "& .MuiSelect-select": { + paddingLeft: 0 + } + }, + errorText: { + color: theme.colors.useCases.alertSeverity.error.main + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/ProviderValueField.tsx b/web/src/ui/pages/account/AccountAiTab/ProviderValueField.tsx new file mode 100644 index 000000000..ee242e0c1 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ProviderValueField.tsx @@ -0,0 +1,87 @@ +import { memo, useState } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { getIconUrlByName } from "lazy-icons"; + +export type Props = { + label: string; + value: string; + onRequestCopy: () => void; + isSensitiveInformation?: boolean; +}; + +export const ProviderValueField = memo((props: Props) => { + const { label, value, onRequestCopy, isSensitiveInformation = false } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ ProviderValueField }); + const [isHidden, setIsHidden] = useState(isSensitiveInformation); + + const onToggleHidden = useConstCallback(() => setIsHidden(isHidden => !isHidden)); + + return ( +
+ {label} +
+ + {isHidden ? "•".repeat(Math.max(value.length, 30)) : value} + + {isSensitiveInformation && ( + + )} + +
+
+ ); +}); + +const { i18n } = declareComponentKeys<"copy">()({ ProviderValueField }); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ ProviderValueField }).create(({ theme }) => ({ + root: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + codeFrame: { + minHeight: 45, + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + padding: `${theme.spacing(1)}px ${theme.spacing(1.5)}px`, + borderRadius: theme.spacing(1), + backgroundColor: theme.colors.useCases.surfaces.surface2, + minWidth: 0 + }, + codeFrameValue: { + flex: 1, + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontFamily: "monospace" + }, + codeFrameButton: { + minHeight: 28, + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + flexShrink: 0 + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/index.ts b/web/src/ui/pages/account/AccountAiTab/index.ts new file mode 100644 index 000000000..5b93e4d96 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/index.ts @@ -0,0 +1,3 @@ +import { AccountAiTab } from "./AccountAiTab"; + +export default AccountAiTab; 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" diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 53efc020e..f8db13f28 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -60,6 +60,7 @@ type ImportMetaEnv = { VAULT_DOCUMENTATION_LINK: string ONYXIA_API_URL: string DISABLE_DISPLAY_ALL_CATALOG: string + ENABLED_AI: string ONYXIA_VERSION: string ONYXIA_VERSION_URL: string SCREEN_SCALER: string