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..ab27bad49 --- /dev/null +++ b/web/src/core/adapters/ai/openWebUi.ts @@ -0,0 +1,67 @@ +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; + provider: Ai["provider"]; + description: Ai["description"]; + accountCreation: Ai["accountCreation"]; + webUiUrl: string; + oauthProvider: string; + getOidcAccessToken: () => Promise; +}): Ai { + const { + id, + name, + provider, + description, + accountCreation, + webUiUrl, + oauthProvider, + getOidcAccessToken + } = params; + + const apiBase = `${webUiUrl}/api`; + + return { + id, + name, + provider, + description, + accountCreation, + 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..91f042198 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -82,6 +82,20 @@ export type ApiTypes = { }; }; data?: { + ai?: ArrayOrNot<{ + id?: string; + URL: string; + name?: string; + provider?: string; + description?: LocalizedString; + accountCreation?: { + title?: LocalizedString; + description?: LocalizedString; + buttonLabel?: LocalizedString; + }; + 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..53ab8e1b6 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -435,6 +435,42 @@ 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, + provider: aiConfig_api.provider ?? "openai", + description: aiConfig_api.description, + accountCreation: + aiConfig_api.accountCreation === undefined + ? undefined + : { + title: aiConfig_api.accountCreation + .title, + description: + aiConfig_api.accountCreation + .description, + buttonLabel: + aiConfig_api.accountCreation + .buttonLabel + }, + oauthProvider: aiConfig_api.oauthProvider, + oidcParams: + apiTypesOidcConfigurationToOidcParams_Partial( + aiConfig_api.oidcConfiguration + ) + })); + })(), proxyInjection: apiRegion.proxyInjection === undefined ? undefined diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index c07bf863f..8a7831fe8 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,92 @@ 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}\0${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, + provider: aiConfig.provider, + description: aiConfig.description, + accountCreation: aiConfig.accountCreation, + 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..76d1d29e3 --- /dev/null +++ b/web/src/core/ports/Ai.ts @@ -0,0 +1,23 @@ +import type { DeploymentRegion, LocalizedString } from "./OnyxiaApi"; + +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; + description: LocalizedString | undefined; + accountCreation: DeploymentRegion.AiAccountCreation | undefined; + 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..fd5959156 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -44,6 +44,16 @@ export type DeploymentRegion = { oidcParams: OidcParams_Partial; } | undefined; + ai: { + id: string; + url: string; + name: string | undefined; + provider: string; + description: LocalizedString | undefined; + accountCreation: DeploymentRegion.AiAccountCreation | undefined; + oauthProvider: string; + oidcParams: OidcParams_Partial; + }[]; proxyInjection: | { enabled: boolean | undefined; @@ -104,6 +114,12 @@ export type DeploymentRegion = { | undefined; }; export namespace DeploymentRegion { + export type AiAccountCreation = { + title: LocalizedString | undefined; + description: LocalizedString | undefined; + buttonLabel: LocalizedString | undefined; + }; + export type S3Profile = { url: string; pathStyleAccess: boolean; diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index ed76d4d3a..2bda8c479 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,15 @@ export type XOnyxiaContext = { }; assert>(); + +type AiProvider = { + id: string; + isDefault: boolean; + 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.test.ts b/web/src/core/tools/fetchAiModels.test.ts new file mode 100644 index 000000000..8a0fca428 --- /dev/null +++ b/web/src/core/tools/fetchAiModels.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchAiModels } from "./fetchAiModels"; + +describe(fetchAiModels.name, () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("lists models from an OpenAI-compatible endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: [{ id: "model-a" }, { id: "model-b", name: "Model B" }] + }), + { status: 200 } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchAiModels({ + provider: "openai-compatible", + apiBase: "https://gateway.example.com/v1", + token: "openai-key" + }) + ).resolves.toStrictEqual([ + { id: "model-a", name: "model-a" }, + { id: "model-b", name: "Model B" } + ]); + expect(fetchMock).toHaveBeenCalledWith("https://gateway.example.com/v1/models", { + headers: { Authorization: "Bearer openai-key" } + }); + }); + + it("uses the native Anthropic authentication and response shape", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { + id: "claude-sonnet-4-6", + display_name: "Claude Sonnet 4.6" + } + ] + }), + { status: 200 } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchAiModels({ + provider: "anthropic", + apiBase: "https://api.anthropic.com/v1", + token: "anthropic-key" + }) + ).resolves.toStrictEqual([ + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" } + ]); + expect(fetchMock).toHaveBeenCalledWith("https://api.anthropic.com/v1/models", { + headers: { + "x-api-key": "anthropic-key", + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true" + } + }); + }); +}); diff --git a/web/src/core/tools/fetchAiModels.ts b/web/src/core/tools/fetchAiModels.ts new file mode 100644 index 000000000..8e5920c9a --- /dev/null +++ b/web/src/core/tools/fetchAiModels.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +export type AiModel = { id: string; name: string }; + +/** Lists the models exposed by a user-added provider using its native protocol. */ +export async function fetchAiModels(params: { + provider: string; + apiBase: string; + token: string; +}): Promise { + const { provider, apiBase, token } = params; + + const headers: Record = + provider === "anthropic" + ? { + "x-api-key": token, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true" + } + : { Authorization: `Bearer ${token}` }; + + const response = await fetch(`${apiBase}/models`, { + headers + }); + + if (!response.ok) { + throw new Error(`Failed to list models (${response.status})`); + } + + const json = await response.json(); + + if (provider === "anthropic") { + try { + const { data } = z + .object({ + data: z.array( + z.object({ + id: z.string(), + display_name: z.string().optional() + }) + ) + }) + .parse(json); + + return data.map(({ id, display_name }) => ({ + id, + name: display_name ?? id + })); + } catch { + throw new Error("Unexpected Anthropic /models response shape"); + } + } + + try { + const { data } = z + .object({ + data: z.array(z.object({ id: z.string(), name: z.string().optional() })) + }) + .parse(json); + + return data.map(({ id, name }) => ({ id, name: name ?? id })); + } catch { + throw new Error("Unexpected OpenAI-compatible /models response shape"); + } +} 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..54213388c --- /dev/null +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.test.ts @@ -0,0 +1,90 @@ +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" + }, + { + id: "p2", + name: "Mistral AI", + provider: "mistral", + apiBase: "https://api.mistral.ai/v1", + apiKey: "mistral-secret" + }, + { + id: "p3", + name: "Anthropic", + provider: "anthropic", + apiBase: "https://api.anthropic.com/v1", + apiKey: "anthropic-secret" + } + ], + selections: { + p1: { modelId: "gpt-4" }, + p2: { modelId: "devstral-small-latest" }, + p3: { modelId: "claude-sonnet-4-6" }, + 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..dc99a68c5 --- /dev/null +++ b/web/src/core/usecases/ai/decoupledLogic/persistedAiConfig.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +// 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; + 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(), + 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 { + return zPersistedAiConfig.parse(aiConfig); + } catch { + console.warn("Stored AI config does not match the expected shape, ignoring it."); + return null; + } +} + +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..62040b90c --- /dev/null +++ b/web/src/core/usecases/ai/selectors.ts @@ -0,0 +1,97 @@ +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, + description: p.description, + accountCreation: p.accountCreation, + 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, + isDefault: provider.id === activeProviderId, + 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..652f2b00f --- /dev/null +++ b/web/src/core/usecases/ai/state.ts @@ -0,0 +1,197 @@ +import { createUsecaseActions } from "clean-architecture"; +import type { Ai } from "core/ports/Ai"; +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; + description: Ai["description"]; + accountCreation: Ai["accountCreation"]; + 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; + models: State.AiModel[]; + selectedModelId: 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; + provider.models = { + stateDescription: "loaded", + availableModels: payload.models + }; + provider.selectedModelId = payload.selectedModelId; + }, + 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..0ec4c0715 --- /dev/null +++ b/web/src/core/usecases/ai/thunks.ts @@ -0,0 +1,416 @@ +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; + models: State.AiModel[]; + selectedModelId: string; + doSetAsDefault: boolean; + }) => + async (...args) => { + const { + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + } = params; + const [dispatch] = args; + + const providerId = crypto.randomUUID(); + + dispatch( + actions.addCustomProvider({ + provider: { + kind: "custom", + id: providerId, + name, + provider, + apiBase, + apiKey, + models: { stateDescription: "loaded", availableModels: models }, + selectedModelId + } + }) + ); + + if (doSetAsDefault) { + dispatch(actions.activeProviderChanged({ activeProviderId: providerId })); + } + + await dispatch(privateThunks.persistConfig()); + }, + editCustomProvider: + (params: { + providerId: string; + name: string; + provider: string; + apiBase: string; + apiKey: string; + models: State.AiModel[]; + selectedModelId: string; + doSetAsDefault: boolean; + }) => + async (...args) => { + const { + providerId, + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + } = params; + const [dispatch] = args; + + dispatch( + actions.editCustomProvider({ + providerId, + name: name, + provider, + apiBase, + apiKey, + models, + selectedModelId + }) + ); + + if (doSetAsDefault) { + dispatch(actions.activeProviderChanged({ activeProviderId: providerId })); + } + + await dispatch(privateThunks.persistConfig()); + }, + // 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: { provider: string; apiBase: string; apiKey: string }) => + async (): Promise<{ models: State.AiModel[] }> => { + const { provider, apiBase, apiKey } = params; + const models = await fetchAiModels({ + provider, + apiBase, + token: apiKey + }); + return { models }; + } +} 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, + description: aiProvider.description, + accountCreation: aiProvider.accountCreation, + 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, + provider: p.provider, + 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, + provider: p.provider, + 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; + provider: string; + apiBase: string; + apiKey: string; +}): Promise { + const { dispatch, providerId, provider, apiBase, apiKey } = params; + try { + const models = await fetchAiModels({ provider, apiBase, token: apiKey }); + dispatch(actions.modelsLoaded({ providerId, models })); + } catch { + dispatch(actions.modelsFetchFailed({ providerId })); + } +} diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.test.ts b/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.test.ts new file mode 100644 index 000000000..96e6fe02d --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { symToStr } from "tsafe/symToStr"; +import { resolveApiBaseOnProviderChange } from "./resolveApiBaseOnProviderChange"; + +describe(symToStr({ resolveApiBaseOnProviderChange }), () => { + it("uses the selected provider default when the field is empty", () => { + expect( + resolveApiBaseOnProviderChange({ + currentApiBase: "", + nextProvider: "mistral" + }) + ).toBe("https://api.mistral.ai/v1"); + }); + + it("replaces another provider default", () => { + expect( + resolveApiBaseOnProviderChange({ + currentApiBase: "https://api.openai.com/v1", + nextProvider: "anthropic" + }) + ).toBe("https://api.anthropic.com/v1"); + }); + + it("preserves a custom API base", () => { + expect( + resolveApiBaseOnProviderChange({ + currentApiBase: " https://llm.example.test/v1 ", + nextProvider: "openai" + }) + ).toBe(" https://llm.example.test/v1 "); + }); + + it("clears the field for an OpenAI-compatible provider", () => { + expect( + resolveApiBaseOnProviderChange({ + currentApiBase: "https://api.mistral.ai/v1", + nextProvider: "openai-compatible" + }) + ).toBe(""); + }); +}); diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.ts b/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.ts new file mode 100644 index 000000000..28ca638d0 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/decoupledLogic/resolveApiBaseOnProviderChange.ts @@ -0,0 +1,38 @@ +export const customProviderProtocolDefaultApiBase = { + openai: "https://api.openai.com/v1", + "openai-compatible": "", + mistral: "https://api.mistral.ai/v1", + anthropic: "https://api.anthropic.com/v1" +} as const; + +type CustomProviderProtocol = keyof typeof customProviderProtocolDefaultApiBase; + +export const customProviderProtocols = Object.keys( + customProviderProtocolDefaultApiBase +) as CustomProviderProtocol[]; + +export function isCustomProviderProtocol(value: string): value is CustomProviderProtocol { + return Object.prototype.hasOwnProperty.call( + customProviderProtocolDefaultApiBase, + value + ); +} + +export function resolveApiBaseOnProviderChange(params: { + currentApiBase: string; + nextProvider: CustomProviderProtocol; +}): string { + const { currentApiBase, nextProvider } = params; + + const currentApiBaseTrimmed = currentApiBase.trim(); + + const isUsingProviderDefaultApiBase = Object.values( + customProviderProtocolDefaultApiBase + ).some(defaultApiBase => defaultApiBase === currentApiBaseTrimmed); + + if (currentApiBaseTrimmed !== "" && !isUsingProviderDefaultApiBase) { + return currentApiBase; + } + + return customProviderProtocolDefaultApiBase[nextProvider]; +} diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/index.ts b/web/src/core/usecases/aiCustomProviderFormUiController/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/selectors.ts b/web/src/core/usecases/aiCustomProviderFormUiController/selectors.ts new file mode 100644 index 000000000..06f88fc96 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/selectors.ts @@ -0,0 +1,89 @@ +import { createSelector } from "clean-architecture"; +import type { State as RootState } from "core/bootstrap"; +import { customProviderProtocols } from "./decoupledLogic/resolveApiBaseOnProviderChange"; +import { name } from "./state"; + +const state = (rootState: RootState) => rootState[name]; + +const isFormSubmittable = createSelector(state, state => { + if (state.stateDescription !== "opened" || state.isSubmitting) { + return false; + } + + const { formValues, connectionTest } = state; + + return ( + formValues.name.trim() !== "" && + formValues.provider !== "" && + formValues.apiBase.trim() !== "" && + formValues.apiKey.trim() !== "" && + formValues.selectedModelId !== "" && + connectionTest.stateDescription === "success" && + connectionTest.models.some(model => model.id === formValues.selectedModelId) + ); +}); + +const canTestConnection = createSelector(state, state => { + if (state.stateDescription !== "opened" || state.isSubmitting) { + return false; + } + + return ( + state.formValues.provider !== "" && + state.formValues.apiBase.trim() !== "" && + state.formValues.apiKey.trim() !== "" + ); +}); + +const submittableForm = createSelector(state, isFormSubmittable, (state, isValid) => { + if (state.stateDescription !== "opened" || !isValid) { + return undefined; + } + + if (state.connectionTest.stateDescription !== "success") { + return undefined; + } + + return { + editedProviderId: state.editedProviderId, + name: state.formValues.name.trim(), + provider: state.formValues.provider, + apiBase: state.formValues.apiBase.trim(), + apiKey: state.formValues.apiKey.trim(), + models: state.connectionTest.models, + selectedModelId: state.formValues.selectedModelId, + doSetAsDefault: state.doSetAsDefault + }; +}); + +const main = createSelector( + state, + isFormSubmittable, + canTestConnection, + (state, canSubmit, canTest) => { + if (state.stateDescription === "closed") { + return { isOpen: false as const }; + } + + return { + isOpen: true as const, + isEditing: state.editedProviderId !== undefined, + isAlreadyDefault: state.isAlreadyDefault, + formValues: state.formValues, + connectionTest: state.connectionTest, + testedModels: + state.connectionTest.stateDescription === "success" + ? state.connectionTest.models + : undefined, + doSetAsDefault: state.doSetAsDefault, + isSubmitting: state.isSubmitting, + canSubmit, + canTest, + supportedProtocols: customProviderProtocols + }; + } +); + +export const privateSelectors = { state, canTestConnection, submittableForm }; + +export const selectors = { main }; diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/state.test.ts b/web/src/core/usecases/aiCustomProviderFormUiController/state.test.ts new file mode 100644 index 000000000..61e7184f2 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/state.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { actions, reducer, type State } from "./state"; + +function createOpenedState(params?: { + isAlreadyDefault?: boolean; + selectedModelId?: string; + connectionTest?: State.ConnectionTest; +}) { + return reducer( + undefined, + actions.opened({ + editedProviderId: undefined, + isAlreadyDefault: params?.isAlreadyDefault ?? false, + formValues: { + name: "My provider", + provider: "openai", + apiBase: "https://api.openai.com/v1", + apiKey: "secret", + selectedModelId: params?.selectedModelId ?? "model-1" + }, + connectionTest: params?.connectionTest ?? { + stateDescription: "success", + models: [{ id: "model-1", name: "Model 1" }] + }, + connectionTestRequestId: undefined, + doSetAsDefault: params?.isAlreadyDefault ?? false, + isSubmitting: false, + submissionRequestId: undefined + }) + ); +} + +describe("aiCustomProviderFormUiController state", () => { + it("invalidates the connection test when credentials change", () => { + const state = reducer( + createOpenedState(), + actions.formValueChanged({ key: "apiKey", value: "new-secret" }) + ); + + expect(state).toMatchObject({ + stateDescription: "opened", + formValues: { + apiKey: "new-secret", + selectedModelId: "" + }, + connectionTest: { stateDescription: "idle" } + }); + }); + + it("ignores a stale connection-test result", () => { + const state = reducer( + reducer( + createOpenedState({ + selectedModelId: "", + connectionTest: { stateDescription: "idle" } + }), + actions.connectionTestStarted({ requestId: "current-request" }) + ), + actions.connectionTestSucceeded({ + requestId: "stale-request", + models: [{ id: "model-2", name: "Model 2" }] + }) + ); + + expect(state).toMatchObject({ + stateDescription: "opened", + connectionTest: { stateDescription: "testing" }, + connectionTestRequestId: "current-request" + }); + }); + + it("does not allow an already-default provider to be unset", () => { + const state = reducer( + createOpenedState({ isAlreadyDefault: true }), + actions.doSetAsDefaultChanged({ doSetAsDefault: false }) + ); + + expect(state).toMatchObject({ + stateDescription: "opened", + doSetAsDefault: true + }); + }); + + it("closes only after the current submission succeeds", () => { + const submittingState = reducer( + createOpenedState(), + actions.submissionStarted({ requestId: "current-request" }) + ); + + const afterStaleSuccess = reducer( + submittingState, + actions.submissionSucceeded({ requestId: "stale-request" }) + ); + + expect(afterStaleSuccess).toMatchObject({ + stateDescription: "opened", + isSubmitting: true + }); + + expect( + reducer( + afterStaleSuccess, + actions.submissionSucceeded({ requestId: "current-request" }) + ) + ).toEqual({ stateDescription: "closed" }); + }); +}); diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/state.ts b/web/src/core/usecases/aiCustomProviderFormUiController/state.ts new file mode 100644 index 000000000..d4971a17e --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/state.ts @@ -0,0 +1,169 @@ +import { createUsecaseActions } from "clean-architecture"; +import { assert } from "tsafe/assert"; +import { id } from "tsafe/id"; + +export type State = State.Closed | State.Opened; + +export namespace State { + export type Closed = { stateDescription: "closed" }; + + export type Opened = { + stateDescription: "opened"; + editedProviderId: string | undefined; + isAlreadyDefault: boolean; + formValues: FormValues; + connectionTest: ConnectionTest; + connectionTestRequestId: string | undefined; + doSetAsDefault: boolean; + isSubmitting: boolean; + submissionRequestId: string | undefined; + }; + + export type FormValues = { + name: string; + provider: string; + apiBase: string; + apiKey: string; + selectedModelId: string; + }; + + export type AiModel = { id: string; name: string }; + + export type ConnectionTest = + | { stateDescription: "idle" } + | { stateDescription: "testing" } + | { stateDescription: "success"; models: AiModel[] } + | { stateDescription: "error" }; +} + +export type ChangeValueParams = { + key: keyof State.FormValues; + value: string; +}; + +export const name = "aiCustomProviderFormUiController"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id({ stateDescription: "closed" }), + reducers: { + opened: (_, { payload }: { payload: Omit }) => + id({ stateDescription: "opened", ...payload }), + closed: () => id({ stateDescription: "closed" }), + formValueChanged: (state, { payload }: { payload: ChangeValueParams }) => { + assert(state.stateDescription === "opened"); + + state.formValues[payload.key] = payload.value; + + if (payload.key !== "apiBase" && payload.key !== "apiKey") { + return; + } + + state.formValues.selectedModelId = ""; + state.connectionTest = { stateDescription: "idle" }; + state.connectionTestRequestId = undefined; + }, + providerChanged: ( + state, + { payload }: { payload: { provider: string; apiBase: string } } + ) => { + assert(state.stateDescription === "opened"); + + state.formValues.provider = payload.provider; + state.formValues.apiBase = payload.apiBase; + state.formValues.selectedModelId = ""; + state.connectionTest = { stateDescription: "idle" }; + state.connectionTestRequestId = undefined; + }, + doSetAsDefaultChanged: ( + state, + { payload }: { payload: { doSetAsDefault: boolean } } + ) => { + assert(state.stateDescription === "opened"); + + if (state.isAlreadyDefault) { + return; + } + + state.doSetAsDefault = payload.doSetAsDefault; + }, + connectionTestStarted: ( + state, + { payload }: { payload: { requestId: string } } + ) => { + assert(state.stateDescription === "opened"); + state.connectionTest = { stateDescription: "testing" }; + state.connectionTestRequestId = payload.requestId; + }, + connectionTestSucceeded: ( + state, + { + payload + }: { + payload: { requestId: string; models: State.AiModel[] }; + } + ) => { + if ( + state.stateDescription !== "opened" || + state.connectionTestRequestId !== payload.requestId + ) { + return; + } + + if ( + !payload.models.some( + model => model.id === state.formValues.selectedModelId + ) + ) { + state.formValues.selectedModelId = ""; + } + + state.connectionTest = { + stateDescription: "success", + models: payload.models + }; + state.connectionTestRequestId = undefined; + }, + connectionTestFailed: ( + state, + { payload }: { payload: { requestId: string } } + ) => { + if ( + state.stateDescription !== "opened" || + state.connectionTestRequestId !== payload.requestId + ) { + return; + } + + state.formValues.selectedModelId = ""; + state.connectionTest = { stateDescription: "error" }; + state.connectionTestRequestId = undefined; + }, + submissionStarted: (state, { payload }: { payload: { requestId: string } }) => { + assert(state.stateDescription === "opened"); + state.isSubmitting = true; + state.submissionRequestId = payload.requestId; + }, + submissionFailed: (state, { payload }: { payload: { requestId: string } }) => { + if ( + state.stateDescription !== "opened" || + state.submissionRequestId !== payload.requestId + ) { + return; + } + + state.isSubmitting = false; + state.submissionRequestId = undefined; + }, + submissionSucceeded: (state, { payload }: { payload: { requestId: string } }) => { + if ( + state.stateDescription !== "opened" || + state.submissionRequestId !== payload.requestId + ) { + return; + } + + return id({ stateDescription: "closed" }); + } + } +}); diff --git a/web/src/core/usecases/aiCustomProviderFormUiController/thunks.ts b/web/src/core/usecases/aiCustomProviderFormUiController/thunks.ts new file mode 100644 index 000000000..fd2e8ff32 --- /dev/null +++ b/web/src/core/usecases/aiCustomProviderFormUiController/thunks.ts @@ -0,0 +1,215 @@ +import type { Thunks } from "core/bootstrap"; +import * as ai from "core/usecases/ai"; +import { assert } from "tsafe/assert"; +import { + isCustomProviderProtocol, + resolveApiBaseOnProviderChange +} from "./decoupledLogic/resolveApiBaseOnProviderChange"; +import { privateSelectors } from "./selectors"; +import { actions, type ChangeValueParams } from "./state"; + +function createEmptyFormValues() { + return { + name: "", + provider: "", + apiBase: "", + apiKey: "", + selectedModelId: "" + }; +} + +export const thunks = { + open: + (params: { providerId: string | undefined }) => + (...args) => { + const { providerId } = params; + const [dispatch, getState] = args; + + if (providerId === undefined) { + dispatch( + actions.opened({ + editedProviderId: undefined, + isAlreadyDefault: false, + formValues: createEmptyFormValues(), + connectionTest: { stateDescription: "idle" }, + connectionTestRequestId: undefined, + doSetAsDefault: false, + isSubmitting: false, + submissionRequestId: undefined + }) + ); + return; + } + + const aiState = ai.selectors.main(getState()); + assert(aiState.stateDescription === "initialized"); + + const provider = aiState.customProviders.find( + provider => provider.id === providerId + ); + assert(provider !== undefined); + + const models = + provider.models?.stateDescription === "loaded" + ? provider.models.availableModels + : undefined; + + const selectedModelId = + models?.some(model => model.id === provider.selectedModelId) === true + ? provider.selectedModelId + : undefined; + + dispatch( + actions.opened({ + editedProviderId: provider.id, + isAlreadyDefault: provider.isDefault, + formValues: { + name: provider.name, + provider: provider.provider, + apiBase: provider.apiBase, + apiKey: provider.apiKey, + selectedModelId: selectedModelId ?? "" + }, + connectionTest: + models === undefined + ? { stateDescription: "idle" } + : { stateDescription: "success", models }, + connectionTestRequestId: undefined, + doSetAsDefault: provider.isDefault, + isSubmitting: false, + submissionRequestId: undefined + }) + ); + }, + close: + () => + (...args) => { + const [dispatch] = args; + dispatch(actions.closed()); + }, + changeValue: + (params: ChangeValueParams) => + (...args) => { + const [dispatch] = args; + dispatch(actions.formValueChanged(params)); + }, + changeProvider: + (params: { provider: string }) => + (...args) => { + const { provider } = params; + const [dispatch, getState] = args; + + assert(isCustomProviderProtocol(provider)); + + const state = privateSelectors.state(getState()); + assert(state.stateDescription === "opened"); + + dispatch( + actions.providerChanged({ + provider, + apiBase: resolveApiBaseOnProviderChange({ + currentApiBase: state.formValues.apiBase, + nextProvider: provider + }) + }) + ); + }, + changeDoSetAsDefault: + (params: { doSetAsDefault: boolean }) => + (...args) => { + const [dispatch] = args; + dispatch(actions.doSetAsDefaultChanged(params)); + }, + testConnection: + () => + async (...args) => { + const [dispatch, getState] = args; + + const state = privateSelectors.state(getState()); + assert(state.stateDescription === "opened"); + + if ( + !privateSelectors.canTestConnection(getState()) || + state.connectionTest.stateDescription === "testing" + ) { + return; + } + + const requestId = crypto.randomUUID(); + const { provider, apiBase, apiKey } = state.formValues; + + dispatch(actions.connectionTestStarted({ requestId })); + + try { + const { models } = await dispatch( + ai.thunks.testCustomProviderConnection({ + provider, + apiBase, + apiKey + }) + ); + + dispatch(actions.connectionTestSucceeded({ requestId, models })); + } catch { + dispatch(actions.connectionTestFailed({ requestId })); + } + }, + submit: + () => + async (...args) => { + const [dispatch, getState] = args; + + const form = privateSelectors.submittableForm(getState()); + + if (form === undefined) { + return; + } + + const requestId = crypto.randomUUID(); + dispatch(actions.submissionStarted({ requestId })); + + const { + editedProviderId, + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + } = form; + + try { + if (editedProviderId === undefined) { + await dispatch( + ai.thunks.addCustomProvider({ + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + }) + ); + } else { + await dispatch( + ai.thunks.editCustomProvider({ + providerId: editedProviderId, + name, + provider, + apiBase, + apiKey, + models, + selectedModelId, + doSetAsDefault + }) + ); + } + + dispatch(actions.submissionSucceeded({ requestId })); + } catch { + dispatch(actions.submissionFailed({ requestId })); + } + } +} satisfies Thunks; diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 3aba184a3..632a9e6e0 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -1,3 +1,5 @@ +import * as ai from "./ai"; +import * as aiCustomProviderFormUiController from "./aiCustomProviderFormUiController"; import * as autoLogoutCountdown from "./autoLogoutCountdown"; import * as catalog from "./catalog"; import * as clusterEventsMonitor from "./clusterEventsMonitor"; @@ -26,6 +28,8 @@ import * as s3ProfilesCreationUiController from "./s3ProfilesCreationUiControlle import * as s3ExplorerUiController from "./s3ExplorerUiController"; export const usecases = { + ai, + aiCustomProviderFormUiController, 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/assets/img/openWebUiIcon.png b/web/src/ui/assets/img/openWebUiIcon.png new file mode 100644 index 000000000..7ce77bad8 Binary files /dev/null and b/web/src/ui/assets/img/openWebUiIcon.png differ diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index e5345a6c7..4ac93a9b2 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,90 @@ export const translations: Translations<"de"> = { "expires in": ({ howMuchTime }) => `Diese Anmeldedaten sind für die nächsten ${howMuchTime} gültig` }, + AccountAiGatewayTab: { + "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.", + "add custom provider": "Benutzerdefinierten Anbieter hinzufügen", + "custom provider api base field": "API-Basis-URL", + "custom provider api key field": "API-Schlüssel", + "no account": ({ webUiUrl }) => ( + <> + Sie haben noch kein Konto beim KI-Gateway. Bitte melden Sie sich zuerst an + bei{" "} + + {webUiUrl} + {" "} + um Ihr Konto zu erstellen. + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "Diesen benutzerdefinierten KI-Anbieter löschen", + "dialog body": + "Dadurch werden der Anbieter und die im Browser gespeicherten Zugangsdaten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + cancel: "Abbrechen", + "delete provider": "Anbieter löschen" + }, + ProviderValueField: { + copy: "Kopieren", + copied: "Kopiert" + }, + 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 section title": "Benutzerdefinierte KI-Anbieter", + "custom provider section subtitle": + "Konfigurieren Sie Ihre KI-Anbieter entsprechend dem verwendeten API-Protokoll.", + "custom provider label field": "Name", + "custom provider type field": "API-Protokoll", + "openai provider option": "OpenAI (nativ)", + "openai compatible provider option": "OpenAI-kompatibel", + "mistral provider option": "Mistral (nativ)", + "anthropic provider option": "Anthropic (nativ)", + "credentials section title": "Anmeldedaten des Anbieters", + "credentials section subtitle": + "Geben Sie Ihre Anmeldedaten ein. Sie werden in Ihrem Browser gespeichert.", + "custom provider api base field": "API-Basis-URL", + "custom provider api key field": "API-Schlüssel", + "verification section title": "Modelle prüfen und laden", + "verification section subtitle": + "Prüfen Sie Ihre Anmeldedaten und laden Sie automatisch die verfügbaren Modelle.", + "custom provider model field": "Modelltyp", + "provider test": "Verbindung testen", + "provider testing": "Verbindung wird getestet...", + "provider test success": + "Verbindung erfolgreich. Der Anbieter ist einsatzbereit.", + "provider test error": + "Verbindung fehlgeschlagen — URL und API-Schlüssel prüfen.", + "set as default provider": "Als Standardanbieter festlegen", + "provider save": "Hinzufügen", + "provider update": "Speichern", + "provider cancel": "Abbrechen", + "close aria label": "Schließen" + }, 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..8e6855f02 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,87 @@ export const translations: Translations<"en"> = { "expires in": ({ howMuchTime }) => `These credentials are valid for the next ${howMuchTime}` }, + AccountAiGatewayTab: { + "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.", + "add custom provider": "Add custom provider", + "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. + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "Delete this Custom AI Provider", + "dialog body": + "This will permanently remove the provider and its stored credentials from your browser. This action cannot be undone.", + cancel: "Cancel", + "delete provider": "Delete provider" + }, + ProviderValueField: { + copy: "Copy", + copied: "Copied" + }, + 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": "Add Custom AI Providers", + "edit custom provider title": "Edit AI provider", + "custom provider section title": "Custom AI Providers", + "custom provider section subtitle": + "Configure AI providers according to the API protocol they expose.", + "custom provider label field": "Define a custom name", + "custom provider type field": "API protocol", + "openai provider option": "OpenAI (native)", + "openai compatible provider option": "OpenAI-compatible", + "mistral provider option": "Mistral (native)", + "anthropic provider option": "Anthropic (native)", + "credentials section title": "Providers Credentials", + "credentials section subtitle": + "Enter your credentials. They will be stored in your browser.", + "custom provider api base field": "API Base URL", + "custom provider api key field": "API Key", + "verification section title": "Verify & Load Models", + "verification section subtitle": + "Verify your credentials and automatically discover the available models.", + "custom provider model field": "Model Type", + "provider test": "Test connection", + "provider testing": "Testing connection...", + "provider test success": "Connection successful. Your provider is ready to use.", + "provider test error": + "Connection failed. Please check your credentials or endpoint.", + "set as default provider": "Set as default provider", + "provider save": "Add Custom AI Providers", + "provider update": "Save changes", + "provider cancel": "Cancel", + "close aria label": "Close" + }, 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..48e5936c9 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,88 @@ export const translations: Translations<"es"> = { "expires in": ({ howMuchTime }) => `Estas credenciales son válidas por los próximos ${howMuchTime}` }, + AccountAiGatewayTab: { + "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.", + "add custom provider": "Añadir proveedor personalizado", + "custom provider api base field": "URL base de la API", + "custom provider api key field": "Clave API", + "no account": ({ webUiUrl }) => ( + <> + Aún no tiene una cuenta en la pasarela de IA. Por favor, inicie sesión + primero en{" "} + + {webUiUrl} + {" "} + para crear su cuenta. + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "Eliminar este proveedor de IA personalizado", + "dialog body": + "Esto eliminará permanentemente el proveedor y las credenciales almacenadas en tu navegador. Esta acción no se puede deshacer.", + cancel: "Cancelar", + "delete provider": "Eliminar proveedor" + }, + ProviderValueField: { + copy: "Copiar", + copied: "Copiado" + }, + 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 section title": "Proveedores de IA personalizados", + "custom provider section subtitle": + "Configure sus proveedores de IA según el protocolo de API que utilicen.", + "custom provider label field": "Etiqueta", + "custom provider type field": "Protocolo API", + "openai provider option": "OpenAI (nativo)", + "openai compatible provider option": "Compatible con OpenAI", + "mistral provider option": "Mistral (nativo)", + "anthropic provider option": "Anthropic (nativo)", + "credentials section title": "Credenciales del proveedor", + "credentials section subtitle": + "Introduzca sus credenciales. Se almacenarán en su navegador.", + "custom provider api base field": "URL base de la API", + "custom provider api key field": "Clave API", + "verification section title": "Verificar y cargar modelos", + "verification section subtitle": + "Verifique sus credenciales y descubra automáticamente los modelos disponibles.", + "custom provider model field": "Tipo de modelo", + "provider test": "Probar conexión", + "provider testing": "Probando conexión...", + "provider test success": "Conexión exitosa. El proveedor está listo para usar.", + "provider test error": "No se puede conectar — compruebe la URL y la clave API.", + "set as default provider": "Establecer como proveedor predeterminado", + "provider save": "Añadir", + "provider update": "Guardar", + "provider cancel": "Cancelar", + "close aria label": "Cerrar" + }, 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..89a962775 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,87 @@ export const translations: Translations<"fi"> = { "expires in": ({ howMuchTime }) => `Nämä käyttöoikeudet ovat voimassa seuraavat ${howMuchTime}` }, + AccountAiGatewayTab: { + "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.", + "add custom provider": "Lisää mukautettu tarjoaja", + "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. + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "Poista tämä mukautettu tekoälypalveluntarjoaja", + "dialog body": + "Tämä poistaa palveluntarjoajan ja selaimeen tallennetut tunnistetiedot pysyvästi. Toimintoa ei voi kumota.", + cancel: "Peruuta", + "delete provider": "Poista palveluntarjoaja" + }, + ProviderValueField: { + copy: "Kopioi", + copied: "Kopioitu" + }, + 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 section title": "Mukautetut tekoälyntarjoajat", + "custom provider section subtitle": + "Määritä tekoälypalveluntarjoajat niiden käyttämän API-protokollan mukaan.", + "custom provider label field": "Tunniste", + "custom provider type field": "API-protokolla", + "openai provider option": "OpenAI (natiivi)", + "openai compatible provider option": "OpenAI-yhteensopiva", + "mistral provider option": "Mistral (natiivi)", + "anthropic provider option": "Anthropic (natiivi)", + "credentials section title": "Palveluntarjoajan tunnistetiedot", + "credentials section subtitle": + "Anna tunnistetietosi. Ne tallennetaan selaimeesi.", + "custom provider api base field": "API-perus-URL", + "custom provider api key field": "API-avain", + "verification section title": "Vahvista ja lataa mallit", + "verification section subtitle": + "Vahvista tunnistetietosi ja etsi käytettävissä olevat mallit automaattisesti.", + "custom provider model field": "Mallin tyyppi", + "provider test": "Testaa yhteys", + "provider testing": "Testataan yhteyttä...", + "provider test success": "Yhteys onnistui. Palveluntarjoaja on käyttövalmis.", + "provider test error": "Yhteyttä ei voi muodostaa — tarkista URL ja API-avain.", + "set as default provider": "Aseta oletuspalveluntarjoajaksi", + "provider save": "Lisää", + "provider update": "Tallenna", + "provider cancel": "Peruuta", + "close aria label": "Sulje" + }, 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..cfe1825e1 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,89 @@ export const translations: Translations<"fr"> = { "expires in": ({ howMuchTime }) => `Ces identifiants sont valables pour les ${howMuchTime} prochaines` }, + AccountAiGatewayTab: { + "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.", + "add custom provider": "Ajouter un provider personnalisé", + "custom provider api base field": "URL de base de l'API", + "custom provider api key field": "Clé API", + "no account": ({ webUiUrl }) => ( + <> + Vous n'avez pas encore de compte sur la passerelle IA. Veuillez + d'abord vous connecter sur{" "} + + {webUiUrl} + {" "} + pour créer votre compte. + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "Supprimer ce fournisseur d’IA personnalisé", + "dialog body": + "Cette action supprimera définitivement le fournisseur et les identifiants enregistrés dans votre navigateur. Cette action est irréversible.", + cancel: "Annuler", + "delete provider": "Supprimer le fournisseur" + }, + ProviderValueField: { + copy: "Copier", + copied: "Copié" + }, + 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 section title": "Providers IA personnalisés", + "custom provider section subtitle": + "Configurez vos providers IA selon le protocole d’API qu’ils exposent.", + "custom provider label field": "Nom", + "custom provider type field": "Protocole API", + "openai provider option": "OpenAI (natif)", + "openai compatible provider option": "Compatible OpenAI", + "mistral provider option": "Mistral (natif)", + "anthropic provider option": "Anthropic (natif)", + "credentials section title": "Identifiants du provider", + "credentials section subtitle": + "Saisissez vos identifiants. Ils seront stockés dans votre navigateur.", + "custom provider api base field": "URL de base de l'API", + "custom provider api key field": "Clé API", + "verification section title": "Vérifier et charger les modèles", + "verification section subtitle": + "Vérifiez vos identifiants et découvrez automatiquement les modèles disponibles.", + "custom provider model field": "Type de modèle", + "provider test": "Tester la connexion", + "provider testing": "Test de la connexion...", + "provider test success": "Connexion réussie. Votre provider est prêt à l'emploi.", + "provider test error": + "Impossible de se connecter — vérifiez l'URL et la clé API.", + "set as default provider": "Définir comme provider par défaut", + "provider save": "Ajouter", + "provider update": "Enregistrer", + "provider cancel": "Annuler", + "close aria label": "Fermer" + }, 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..c083e5f4b 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,88 @@ export const translations: Translations<"it"> = { "expires in": ({ howMuchTime }) => `Queste credenziali sono valide per i prossimi ${howMuchTime}` }, + AccountAiGatewayTab: { + "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.", + "add custom provider": "Aggiungi provider personalizzato", + "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. + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "Elimina questo provider IA personalizzato", + "dialog body": + "Il provider e le credenziali memorizzate nel browser verranno eliminati definitivamente. Questa azione non può essere annullata.", + cancel: "Annulla", + "delete provider": "Elimina provider" + }, + ProviderValueField: { + copy: "Copia", + copied: "Copiato" + }, + 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 section title": "Provider IA personalizzati", + "custom provider section subtitle": + "Configura i provider IA in base al protocollo API utilizzato.", + "custom provider label field": "Etichetta", + "custom provider type field": "Protocollo API", + "openai provider option": "OpenAI (nativo)", + "openai compatible provider option": "Compatibile con OpenAI", + "mistral provider option": "Mistral (nativo)", + "anthropic provider option": "Anthropic (nativo)", + "credentials section title": "Credenziali del provider", + "credentials section subtitle": + "Inserisci le credenziali. Verranno memorizzate nel browser.", + "custom provider api base field": "URL base API", + "custom provider api key field": "Chiave API", + "verification section title": "Verifica e carica i modelli", + "verification section subtitle": + "Verifica le credenziali e individua automaticamente i modelli disponibili.", + "custom provider model field": "Tipo di modello", + "provider test": "Testa connessione", + "provider testing": "Test della connessione...", + "provider test success": "Connessione riuscita. Il provider è pronto all'uso.", + "provider test error": + "Impossibile connettersi — controlla l'URL e la chiave API.", + "set as default provider": "Imposta come provider predefinito", + "provider save": "Aggiungi", + "provider update": "Salva", + "provider cancel": "Annulla", + "close aria label": "Chiudi" + }, AccountVaultTab: { "credentials section title": "Credenziali Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( @@ -509,9 +592,9 @@ export const translations: Translations<"it"> = { la nostra documentazione - {". \u00a0"} + .   Configurare il tuo Vault CLI locale - {"."} + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index e70227e01..9b9b10e0e 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,89 @@ export const translations: Translations<"nl"> = { "expires in": ({ howMuchTime }) => `Deze inloggegevens zijn geldig voor de komende ${howMuchTime}` }, + AccountAiGatewayTab: { + "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.", + "add custom provider": "Aangepaste provider toevoegen", + "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. + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "Deze aangepaste AI-provider verwijderen", + "dialog body": + "Hiermee worden de provider en de in je browser opgeslagen inloggegevens permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.", + cancel: "Annuleren", + "delete provider": "Provider verwijderen" + }, + ProviderValueField: { + copy: "Kopiëren", + copied: "Gekopieerd" + }, + 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 section title": "Aangepaste AI-providers", + "custom provider section subtitle": + "Configureer AI-providers op basis van het API-protocol dat ze gebruiken.", + "custom provider label field": "Label", + "custom provider type field": "API-protocol", + "openai provider option": "OpenAI (native)", + "openai compatible provider option": "OpenAI-compatibel", + "mistral provider option": "Mistral (native)", + "anthropic provider option": "Anthropic (native)", + "credentials section title": "Providerreferenties", + "credentials section subtitle": + "Voer uw referenties in. Ze worden in uw browser opgeslagen.", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-sleutel", + "verification section title": "Modellen verifiëren en laden", + "verification section subtitle": + "Verifieer uw referenties en ontdek automatisch de beschikbare modellen.", + "custom provider model field": "Modeltype", + "provider test": "Verbinding testen", + "provider testing": "Verbinding testen...", + "provider test success": + "Verbinding geslaagd. De provider is klaar voor gebruik.", + "provider test error": + "Kan geen verbinding maken — controleer URL en API-sleutel.", + "set as default provider": "Instellen als standaardprovider", + "provider save": "Toevoegen", + "provider update": "Opslaan", + "provider cancel": "Annuleren", + "close aria label": "Sluiten" + }, 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..3de6caa3b 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,86 @@ export const translations: Translations<"no"> = { "expires in": ({ howMuchTime }) => `Disse legitimasjonene er gyldige for de neste ${howMuchTime}` }, + AccountAiGatewayTab: { + "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.", + "add custom provider": "Legg til tilpasset leverandør", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-nøkkel", + "no account": ({ webUiUrl }) => ( + <> + Du har ikke en konto på AI-gatewayen ennå. Logg inn først på{" "} + + {webUiUrl} + {" "} + for å opprette kontoen din. + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "Slett denne egendefinerte KI-leverandøren", + "dialog body": + "Dette fjerner leverandøren og den lagrede påloggingsinformasjonen permanent fra nettleseren. Handlingen kan ikke angres.", + cancel: "Avbryt", + "delete provider": "Slett leverandør" + }, + ProviderValueField: { + copy: "Kopier", + copied: "Kopiert" + }, + 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 section title": "Tilpassede AI-leverandører", + "custom provider section subtitle": + "Konfigurer AI-leverandører etter API-protokollen de bruker.", + "custom provider label field": "Etikett", + "custom provider type field": "API-protokoll", + "openai provider option": "OpenAI (native)", + "openai compatible provider option": "OpenAI-kompatibel", + "mistral provider option": "Mistral (native)", + "anthropic provider option": "Anthropic (native)", + "credentials section title": "Leverandørlegitimasjon", + "credentials section subtitle": + "Skriv inn legitimasjonen din. Den lagres i nettleseren.", + "custom provider api base field": "API-basis-URL", + "custom provider api key field": "API-nøkkel", + "verification section title": "Bekreft og last inn modeller", + "verification section subtitle": + "Bekreft legitimasjonen og finn tilgjengelige modeller automatisk.", + "custom provider model field": "Modelltype", + "provider test": "Test tilkobling", + "provider testing": "Tester tilkobling...", + "provider test success": "Tilkobling vellykket. Leverandøren er klar til bruk.", + "provider test error": "Kan ikke koble til — sjekk URL og API-nøkkel.", + "set as default provider": "Angi som standardleverandør", + "provider save": "Legg til", + "provider update": "Lagre", + "provider cancel": "Avbryt", + "close aria label": "Lukk" + }, 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..3dad84c74 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,82 @@ export const translations: Translations<"zh-CN"> = { ), "expires in": ({ howMuchTime }) => `这些凭证在接下来的 ${howMuchTime} 内有效` }, + AccountAiGatewayTab: { + "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 提供商。凭据存储在您的浏览器中。", + "add custom provider": "添加自定义提供商", + "custom provider api base field": "API 基础 URL", + "custom provider api key field": "API 密钥", + "no account": ({ webUiUrl }) => ( + <> + 您还没有 AI 网关账户。请先登录{" "} + + {webUiUrl} + {" "} + 以创建您的账户。 + + ) + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "删除此自定义 AI 提供商", + "dialog body": "这将永久删除该提供商及浏览器中存储的凭据。此操作无法撤销。", + cancel: "取消", + "delete provider": "删除提供商" + }, + ProviderValueField: { + copy: "复制", + copied: "已复制" + }, + ModelsSection: { + "model label": "模型", + "not defined": "未定义", + "models fetch error": "无法获取模型 — 请检查您的 URL 和 API 密钥。" + }, + CustomProviderFormDialog: { + "add custom provider title": "自定义 AI 提供商", + "edit custom provider title": "编辑 AI 提供商", + "custom provider section title": "自定义 AI 提供商", + "custom provider section subtitle": "根据 AI 提供商使用的 API 协议进行配置。", + "custom provider label field": "标签", + "custom provider type field": "API 协议", + "openai provider option": "OpenAI(原生)", + "openai compatible provider option": "兼容 OpenAI", + "mistral provider option": "Mistral(原生)", + "anthropic provider option": "Anthropic(原生)", + "credentials section title": "提供商凭证", + "credentials section subtitle": "请输入凭证。凭证将存储在您的浏览器中。", + "custom provider api base field": "API 基础 URL", + "custom provider api key field": "API 密钥", + "verification section title": "验证并加载模型", + "verification section subtitle": "验证凭证并自动发现可用模型。", + "custom provider model field": "模型类型", + "provider test": "测试连接", + "provider testing": "正在测试连接...", + "provider test success": "连接成功。提供商已准备就绪。", + "provider test error": "无法连接 — 请检查 URL 和 API 密钥。", + "set as default provider": "设为默认提供商", + "provider save": "添加", + "provider update": "保存", + "provider cancel": "取消", + "close aria label": "关闭" + }, 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..4fd4540ad 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -55,6 +55,11 @@ 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/pages/account/AccountAiTab/ConfirmCustomProviderDeletionDialog").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..7df126f9a --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx @@ -0,0 +1,540 @@ +import { memo } from "react"; +import { useTranslation } from "ui/i18n"; +import openWebUiIconUrl from "ui/assets/img/openWebUiIcon.png"; +import { LocalizedMarkdown } from "ui/shared/Markdown"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { useConst } from "powerhooks/useConst"; +import { copyToClipboard } from "ui/tools/copyToClipboard"; +import { tss } from "tss"; +import { alpha } from "@mui/material/styles"; +import ButtonBase from "@mui/material/ButtonBase"; +import { declareComponentKeys } from "i18nifty"; +import { Evt } from "evt"; +import type { UnpackEvt } from "evt"; +import { Deferred } from "evt/tools/Deferred"; +import { Icon } from "onyxia-ui/Icon"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Button } from "onyxia-ui/Button"; +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 } from "./CustomProviderFormDialog"; +import { + ConfirmCustomProviderDeletionDialog, + type Props as ConfirmCustomProviderDeletionDialogProps +} from "./ConfirmCustomProviderDeletionDialog"; +import { Divider } from "@mui/material"; + +export type Props = { + className?: string; +}; + +export const AccountAiTab = memo((props: Props) => { + const { className } = props; + + const { classes } = useStyles(); + + const { + functions: { ai, aiCustomProviderFormUiController } + } = getCoreSync(); + + const { stateDescription, regionProviders, customProviders } = useCoreState( + "ai", + "main" + ); + + const { t } = useTranslation({ AccountAiGatewayTab: AccountAiTab }); + + const evtConfirmDeleteDialogOpen = 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( + async ([providerId]: [string]) => { + const dDoProceed = new Deferred(); + + evtConfirmDeleteDialogOpen.post({ + resolveDoProceed: dDoProceed.resolve + }); + + if (!(await dDoProceed.pr)) { + return; + } + + await ai.deleteCustomProvider({ providerId }); + } + ); + + const onAddClick = useConstCallback(() => + aiCustomProviderFormUiController.open({ providerId: undefined }) + ); + + const onEditClickFactory = useCallbackFactory(([providerId]: [string]) => + aiCustomProviderFormUiController.open({ providerId }) + ); + + 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.description === undefined ? ( + t("credentials section helper", { + webUiUrl: regionProvider.webUiUrl + }) + ) : ( + + {regionProvider.description} + + )} + + {regionProvider.auth.stateDescription === "authenticated" && ( +
+ + {renderDefaultProviderAction({ + providerId: regionProvider.id, + isDefault: regionProvider.isDefault + })} +
+ )} +
+
+ + {regionProvider.auth.stateDescription === "no account" && + (() => { + const { accountCreation } = regionProvider; + + if ( + accountCreation === undefined || + (accountCreation.title === undefined && + accountCreation.description === undefined && + accountCreation.buttonLabel === undefined) + ) { + return ( + + {t("no account", { + webUiUrl: regionProvider.webUiUrl + })} + + ); + } + return ( +
+
+
+ + {accountCreation.title !== undefined && ( + + + {accountCreation.title} + + + )} +
+ + {accountCreation.description === undefined ? ( + t("no account", { + webUiUrl: regionProvider.webUiUrl + }) + ) : ( + + {accountCreation.description} + + )} + +
+ {accountCreation.buttonLabel !== undefined && ( + + )} +
+ ); + })()} + + {regionProvider.auth.stateDescription === "error" && ( + + {t("gateway error")} + + )} + + {regionProvider.auth.stateDescription === "authenticated" && ( +
+ + + + ai.setSelectedModel({ + providerId: regionProvider.id, + modelId + }) + } + /> +
+ )} +
+ ))} + +
+ +
+ + {t("custom providers section title")} + + + {t("custom providers section helper")} + +
+ + {customProviders.map(provider => ( +
+
+ {provider.name} +
+ + + {renderDefaultProviderAction({ + providerId: provider.id, + isDefault: provider.isDefault + })} +
+
+
+ + + + ai.setSelectedModel({ + providerId: provider.id, + modelId + }) + } + /> +
+
+ ))} + + + + + {t("add custom provider")} + + +
+ + + +
+ ); +}); + +const { i18n } = declareComponentKeys< + | "default provider" + | "set default provider" + | "refresh credentials" + | "delete provider" + | "edit provider" + | { K: "credentials section helper"; P: { webUiUrl: string }; R: JSX.Element } + | "api base url" + | "token" + | "gateway error" + | "custom providers section title" + | "custom providers section helper" + | "add custom provider" + | "custom provider api base field" + | "custom provider api key field" + | { K: "no account"; P: { webUiUrl: string }; R: JSX.Element } +>()({ AccountAiGatewayTab: AccountAiTab }); +export type I18n = typeof i18n; + +const useStyles = tss + .withName({ AccountAiGatewayTab: AccountAiTab }) + .create(({ theme }) => ({ + regionProviderSection: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), + "& + &": { + marginTop: theme.spacing(5) + } + }, + regionProviderHeader: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + regionProviderSubtitleRow: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + flexWrap: "wrap" + }, + regionProviderDescription: { + flex: 1, + minWidth: 260, + color: theme.colors.useCases.typography.textSecondary + }, + customProvidersSection: { + marginTop: theme.spacing(4), + display: "flex", + flexDirection: "column" + }, + customProvidersDivider: { + marginBottom: theme.spacing(3) + }, + customProvidersHeader: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + customProvidersDescription: { + color: theme.colors.useCases.typography.textSecondary + }, + providerCard: { + border: `1px solid ${theme.colors.useCases.typography.textDisabled}`, + borderRadius: theme.spacing(3), + 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: { + ...theme.typography.variants["label 2"].style, + boxSizing: "border-box", + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(2.5), + paddingRight: theme.spacing(2.5), + "& .MuiButton-startIcon": { + marginLeft: 0, + marginRight: theme.spacing(1) + }, + "& .MuiButton-startIcon > *": { + fontSize: theme.iconSizesInPxByName["extra small"] + } + }, + defaultProviderBadge: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(1), + boxSizing: "border-box", + padding: `${theme.spacing(1)}px ${theme.spacing(2.5)}px`, + borderRadius: 9999, + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1) + }, + defaultProviderBadgeIcon: { + color: theme.colors.useCases.typography.textFocus + }, + defaultProviderBadgeText: { + color: theme.colors.useCases.typography.textPrimary + }, + providerFields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3) + }, + addCustomProviderAction: { + justifyContent: "flex-start", + gap: theme.spacing(3), + marginTop: theme.spacing(3), + padding: theme.spacing(3), + borderWidth: theme.spacing(0.5), + borderStyle: "solid", + borderColor: theme.colors.useCases.surfaces.surface2, + borderRadius: theme.spacing(2), + textAlign: "left", + transition: theme.muiTheme.transitions.create("background-color"), + "&:hover": { + backgroundColor: alpha(theme.colors.useCases.surfaces.surface2, 0.4) + }, + "&:focus-visible": { + outlineWidth: theme.spacing(0.5), + outlineStyle: "solid", + outlineColor: theme.colors.useCases.typography.textFocus, + outlineOffset: theme.spacing(0.5) + } + }, + noAccountCard: { + maxWidth: 759, + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + gap: theme.spacing(3), + padding: theme.spacing(3), + borderRadius: theme.spacing(2), + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1) + }, + noAccountText: { + width: "100%" + }, + noAccountTitle: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1) + }, + noAccountLogo: { + flexShrink: 0, + width: 32, + height: 32, + objectFit: "contain", + display: "block" + }, + noAccountTitleText: { + minWidth: 0, + fontWeight: 600 + }, + noAccountDescription: { + marginTop: theme.spacing(1), + color: theme.colors.useCases.typography.textSecondary + }, + noAccountButton: { + alignSelf: "flex-end" + }, + errorText: { + color: theme.colors.useCases.alertSeverity.error.main + } + })); diff --git a/web/src/ui/pages/account/AccountAiTab/ConfirmCustomProviderDeletionDialog.tsx b/web/src/ui/pages/account/AccountAiTab/ConfirmCustomProviderDeletionDialog.tsx new file mode 100644 index 000000000..4de8df074 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ConfirmCustomProviderDeletionDialog.tsx @@ -0,0 +1,77 @@ +import { memo, useState } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { getIconUrlByName } from "lazy-icons"; +import { declareComponentKeys } from "i18nifty"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import type { NonPostableEvt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { assert } from "tsafe/assert"; + +export type Props = { + evtOpen: NonPostableEvt<{ + resolveDoProceed: (doProceed: boolean) => void; + }>; +}; + +export const ConfirmCustomProviderDeletionDialog = memo((props: Props) => { + const { evtOpen } = props; + + const { t } = useTranslation({ ConfirmCustomProviderDeletionDialog }); + const { classes } = useStyles(); + + const [state, setState] = useState | undefined>(undefined); + + useEvt(ctx => evtOpen.attach(ctx, setState), [evtOpen]); + + const onCloseFactory = useCallbackFactory(([doProceed]: [boolean]) => { + assert(state !== undefined); + + state.resolveDoProceed(doProceed); + setState(undefined); + }); + + return ( + + + + + } + /> + ); +}); + +const { i18n } = declareComponentKeys< + "dialog title" | "dialog body" | "cancel" | "delete provider" +>()({ ConfirmCustomProviderDeletionDialog }); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ ConfirmCustomProviderDeletionDialog }).create(() => ({ + paper: { + width: 650, + maxWidth: "calc(100vw - 48px)", + borderRadius: 12 + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx new file mode 100644 index 000000000..f69472016 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CustomProviderFormDialogView, type ViewProps } from "./CustomProviderFormDialog"; + +const meta = { + title: "Pages/Account/CustomProviderFormDialog", + component: CustomProviderFormDialogView, + parameters: { + layout: "fullscreen" + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const doNothing = () => {}; + +const commonArgs: Pick< + ViewProps, + | "supportedProtocols" + | "onClose" + | "onFieldChange" + | "onProviderChange" + | "onTest" + | "onSave" + | "onDoSetAsDefaultChange" +> = { + supportedProtocols: ["openai", "openai-compatible", "mistral", "anthropic"], + onClose: doNothing, + onFieldChange: doNothing, + onProviderChange: doNothing, + onTest: doNothing, + onSave: doNothing, + onDoSetAsDefaultChange: doNothing +}; + +export const Default: Story = { + args: { + ...commonArgs, + isEditing: false, + isAlreadyDefault: false, + values: { + name: "", + provider: "", + apiBase: "", + apiKey: "", + selectedModelId: "" + }, + test: { stateDescription: "idle" }, + doSetAsDefault: false, + canSave: false, + canTest: false + } +}; + +export const Filled: Story = { + args: { + ...commonArgs, + isEditing: true, + isAlreadyDefault: false, + values: { + name: "Custom Provider 1", + provider: "openai", + apiBase: "https://llm.example.test/api", + apiKey: "storybook-api-key", + selectedModelId: "qwen3-vl" + }, + test: { + stateDescription: "success", + models: [ + { id: "gemma4-26b-moe", name: "gemma4-26b-moe" }, + { id: "qwen3-6-35b-mo", name: "qwen3-6-35b-mo" }, + { id: "qwen3-embedding-8b", name: "qwen3-embedding-8b" }, + { id: "qwen3-vl", name: "qwen3-vl" } + ] + }, + doSetAsDefault: false, + canSave: true, + canTest: true + } +}; diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx new file mode 100644 index 000000000..d16d34ea8 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog.tsx @@ -0,0 +1,77 @@ +import { getCoreSync, useCoreState } from "core"; +import { declareComponentKeys } from "i18nifty"; +import { memo } from "react"; +import { CustomProviderFormDialogView } from "./CustomProviderFormDialog/CustomProviderFormDialogView"; + +export { CustomProviderFormDialogView } from "./CustomProviderFormDialog/CustomProviderFormDialogView"; +export type { ViewProps } from "./CustomProviderFormDialog/types"; + +export const CustomProviderFormDialog = memo(() => { + const form = useCoreState("aiCustomProviderFormUiController", "main"); + + const { + functions: { aiCustomProviderFormUiController } + } = getCoreSync(); + + if (!form.isOpen) { + return null; + } + + return ( + aiCustomProviderFormUiController.close()} + onFieldChange={(key, value) => + aiCustomProviderFormUiController.changeValue({ key, value }) + } + onProviderChange={provider => + aiCustomProviderFormUiController.changeProvider({ provider }) + } + onTest={() => void aiCustomProviderFormUiController.testConnection()} + onSave={() => void aiCustomProviderFormUiController.submit()} + onDoSetAsDefaultChange={doSetAsDefault => + aiCustomProviderFormUiController.changeDoSetAsDefault({ + doSetAsDefault + }) + } + /> + ); +}); + +const { i18n } = declareComponentKeys< + | "add custom provider title" + | "edit custom provider title" + | "custom provider section title" + | "custom provider section subtitle" + | "custom provider label field" + | "custom provider type field" + | "openai provider option" + | "openai compatible provider option" + | "mistral provider option" + | "anthropic provider option" + | "credentials section title" + | "credentials section subtitle" + | "custom provider api base field" + | "custom provider api key field" + | "verification section title" + | "verification section subtitle" + | "custom provider model field" + | "provider test" + | "provider testing" + | "provider test success" + | "provider test error" + | "set as default provider" + | "provider save" + | "provider update" + | "provider cancel" + | "close aria label" +>()({ CustomProviderFormDialog }); + +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/CustomProviderFormDialogView.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/CustomProviderFormDialogView.tsx new file mode 100644 index 000000000..6f4a933c6 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/CustomProviderFormDialogView.tsx @@ -0,0 +1,136 @@ +import Checkbox from "@mui/material/Checkbox"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { memo, type FormEventHandler } from "react"; +import { tss } from "tss"; +import { useTranslation } from "ui/i18n"; +import { CredentialsSection, ProviderSection, VerificationSection } from "./FormSections"; +import { SideDialog } from "./SideDialog"; +import type { ViewProps } from "./types"; + +export const CustomProviderFormDialogView = memo((props: ViewProps) => { + const { + isEditing, + isAlreadyDefault, + values, + test, + doSetAsDefault, + canSave, + canTest, + supportedProtocols, + onClose, + onFieldChange, + onProviderChange, + onTest, + onSave, + onDoSetAsDefaultChange + } = props; + + const { classes } = useStyles(); + const { t } = useTranslation("CustomProviderFormDialog"); + + const onSubmit: FormEventHandler = event => { + event.preventDefault(); + + if (canSave) { + onSave(); + } + }; + + return ( + +
+
+ onFieldChange("name", value)} + onProviderChange={onProviderChange} + /> + + + + + onFieldChange("selectedModelId", value) + } + onTest={onTest} + /> +
+ +
+ + +
+ + +
+
+
+
+ ); +}); + +const useStyles = tss.withName({ CustomProviderFormDialogView }).create(({ theme }) => ({ + root: { + height: "100%", + minHeight: 0, + display: "flex", + flexDirection: "column", + color: theme.colors.useCases.typography.textPrimary + }, + body: { + flex: 1, + minHeight: 0, + overflowY: "auto", + paddingRight: theme.spacing(0.5) + }, + footer: { + flex: "none", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + paddingTop: theme.spacing(3), + marginTop: theme.spacing(2) + }, + defaultProviderControl: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + cursor: "pointer" + }, + actions: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2) + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx new file mode 100644 index 000000000..fce4edb2a --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormFields.tsx @@ -0,0 +1,189 @@ +import FormControl from "@mui/material/FormControl"; +import Input from "@mui/material/Input"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import { useId } from "react"; +import { tss } from "tss"; +import type { AiModel } from "./types"; + +export function FormTextField(props: { + label: string; + value: string; + onChange: (value: string) => void; + autoComplete: string; + isSensitive?: boolean; +}) { + const { label, value, onChange, autoComplete, isSensitive = false } = props; + const inputId = useId(); + const { classes } = useStyles(); + + return ( + onChange(event.target.value)} + type={isSensitive ? "password" : "text"} + fullWidth={true} + disableUnderline={true} + autoComplete={autoComplete} + inputProps={{ "aria-label": label }} + /> + ); +} + +export function FormSelectField(props: { + label: string; + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; +}) { + const { label, value, onChange, options } = props; + const { classes } = useStyles(); + + return ( + + + value={value} + displayEmpty={true} + onChange={event => onChange(event.target.value)} + inputProps={{ "aria-label": label }} + renderValue={selectedValue => + selectedValue === "" + ? label + : (options.find(option => option.value === selectedValue) + ?.label ?? selectedValue) + } + > + {options.map(option => ( + + {option.label} + + ))} + + + ); +} + +export function ModelSelectField(props: { + label: string; + value: string; + onChange: (value: string) => void; + models: AiModel[]; + disabled: boolean; +}) { + const { label, value, onChange, models, disabled } = props; + const { classes } = useStyles(); + + return ( + + + value={value} + displayEmpty={true} + onChange={event => onChange(event.target.value)} + inputProps={{ "aria-label": label }} + renderValue={selectedValue => { + if (selectedValue === "") { + return label; + } + + const model = models.find(model => model.id === selectedValue); + + return model?.name ?? selectedValue; + }} + MenuProps={{ + PaperProps: { className: classes.modelMenu } + }} + > + {models.map(model => ( + + {model.name} + + ))} + + + ); +} + +const useStyles = tss + .withName({ CustomProviderFormFields: FormTextField }) + .create(({ theme }) => { + const fieldBackground = theme.colors.useCases.surfaces.background; + + return { + input: { + minHeight: 45, + borderRadius: 8, + border: "2px solid transparent", + backgroundColor: fieldBackground, + transition: "border-color 160ms ease, background-color 160ms ease", + "&:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "&.Mui-focused": { + borderColor: theme.colors.useCases.buttons.actionActive + }, + "& .MuiInputBase-input": { + ...theme.typography.variants["body 2"].style, + padding: `${theme.spacing(1.25)}px ${theme.spacing(1.5)}px`, + color: theme.colors.useCases.typography.textPrimary, + "&::placeholder": { + color: theme.colors.useCases.typography.textSecondary, + opacity: 1 + } + } + }, + selectControl: { + "& .MuiInputBase-root": { + minHeight: 45, + borderRadius: 8, + backgroundColor: fieldBackground, + color: theme.colors.useCases.typography.textPrimary + }, + "& .MuiOutlinedInput-notchedOutline": { + border: "2px solid transparent" + }, + "& .MuiInputBase-root:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "& .Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: theme.colors.useCases.buttons.actionActive + }, + "& .MuiSelect-select": { + ...theme.typography.variants["body 2"].style, + display: "flex", + alignItems: "center", + minHeight: "unset", + padding: `${theme.spacing(1.25)}px ${theme.spacing( + 5 + )}px ${theme.spacing(1.25)}px ${theme.spacing(1.5)}px` + }, + "& .MuiSelect-icon": { + color: theme.colors.useCases.typography.textPrimary, + right: theme.spacing(1.5) + }, + "& .Mui-disabled": { + color: theme.colors.useCases.typography.textDisabled, + WebkitTextFillColor: theme.colors.useCases.typography.textDisabled + } + }, + modelMenu: { + marginTop: theme.spacing(0.5), + padding: theme.spacing(0.5), + borderRadius: 8 + }, + modelMenuItem: { + minHeight: 32, + borderRadius: 6 + } + }; + }); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormSections.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormSections.tsx new file mode 100644 index 000000000..f7bd502f4 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/FormSections.tsx @@ -0,0 +1,285 @@ +import { alpha } from "@mui/material/styles"; +import { getIconUrlByName } from "lazy-icons"; +import { Button } from "onyxia-ui/Button"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import type { ReactNode } from "react"; +import { tss } from "tss"; +import { useTranslation } from "ui/i18n"; +import { FormSelectField, FormTextField, ModelSelectField } from "./FormFields"; +import type { FormTest, FormValues } from "./types"; + +export function ProviderSection(props: { + name: string; + provider: string; + supportedProtocols: readonly string[]; + onNameChange: (value: string) => void; + onProviderChange: (value: string) => void; +}) { + const { name, provider, supportedProtocols, onNameChange, onProviderChange } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + + const providerOptions = [ + { value: "openai", label: t("openai provider option") }, + { + value: "openai-compatible", + label: t("openai compatible provider option") + }, + { value: "mistral", label: t("mistral provider option") }, + { value: "anthropic", label: t("anthropic provider option") } + ].filter(({ value }) => supportedProtocols.includes(value)); + + return ( + + + + + ); +} + +export function CredentialsSection(props: { + apiBase: string; + apiKey: string; + onFieldChange: (key: "apiBase" | "apiKey", value: string) => void; +}) { + const { apiBase, apiKey, onFieldChange } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + + return ( + + onFieldChange("apiBase", value)} + autoComplete="url" + /> + onFieldChange("apiKey", value)} + autoComplete="off" + isSensitive={true} + /> + + ); +} + +export function VerificationSection(props: { + selectedModelId: FormValues["selectedModelId"]; + test: FormTest; + canTest: boolean; + onSelectedModelIdChange: (value: string) => void; + onTest: () => void; +}) { + const { selectedModelId, test, canTest, onSelectedModelIdChange, onTest } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + const { classes } = useStyles(); + + const testedModels = test.stateDescription === "success" ? test.models : undefined; + + return ( + + {t("provider test")} + + } + > + + + {test.stateDescription === "testing" && ( +
+ + {t("provider testing")} +
+ )} + + {test.stateDescription === "success" && ( + + {t("provider test success")} + + )} + + {test.stateDescription === "error" && ( + {t("provider test error")} + )} +
+ ); +} + +function FormSection(props: { + title: string; + subtitle: string; + action?: ReactNode; + children: ReactNode; +}) { + const { title, subtitle, action, children } = props; + const { classes } = useStyles(); + + return ( +
+ {action === undefined ? ( + <> + +
{children}
+ + ) : ( + <> +
+ + {action} +
+ {children} + + )} +
+ ); +} + +function SectionHeading(props: { title: string; subtitle: string }) { + const { title, subtitle } = props; + const { classes } = useStyles_SectionHeading(); + + return ( +
+ {title} + + {subtitle} + +
+ ); +} + +function StatusMessage(props: { severity: "success" | "error"; children: ReactNode }) { + const { severity, children } = props; + const { classes, cx } = useStyles_StatusMessage(); + + return ( +
+ + {children} +
+ ); +} + +const useStyles = tss + .withName({ CustomProviderFormSections: FormSection }) + .create(({ theme }) => ({ + section: { + paddingBottom: theme.spacing(4), + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + "& + &": { + marginTop: theme.spacing(4) + }, + "&:last-child": { + borderBottom: "none", + paddingBottom: 0 + } + }, + fields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), + marginTop: theme.spacing(4) + }, + verificationHeadingRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + marginBottom: theme.spacing(5) + }, + testButton: { + flex: "none" + }, + testingMessage: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + minHeight: 40, + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(2) + } + })); + +const useStyles_SectionHeading = tss.withName({ SectionHeading }).create(({ theme }) => ({ + root: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + } +})); + +const useStyles_StatusMessage = tss.withName({ StatusMessage }).create(({ theme }) => ({ + root: { + minHeight: 40, + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + marginTop: theme.spacing(2), + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + borderRadius: 8, + boxSizing: "border-box", + color: theme.colors.useCases.typography.textPrimary + }, + success: { + backgroundColor: alpha(theme.colors.useCases.alertSeverity.success.main, 0.2) + }, + error: { + backgroundColor: alpha(theme.colors.useCases.alertSeverity.error.main, 0.2) + }, + dot: { + flex: "none", + width: 16, + height: 16, + borderRadius: "50%" + }, + dotSuccess: { + backgroundColor: theme.colors.useCases.alertSeverity.success.main + }, + dotError: { + backgroundColor: theme.colors.useCases.alertSeverity.error.main + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/SideDialog.tsx b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/SideDialog.tsx new file mode 100644 index 000000000..61ae4d6cd --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/SideDialog.tsx @@ -0,0 +1,132 @@ +import { alpha } from "@mui/material/styles"; +import { getIconUrlByName } from "lazy-icons"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Text } from "onyxia-ui/Text"; +import { useEffect, type MouseEvent, type ReactNode } from "react"; +import { keyframes } from "tss-react"; +import { tss } from "tss"; + +export function SideDialog(props: { + title: ReactNode; + closeLabel: string; + onClose: () => void; + children: ReactNode; +}) { + const { children, title, closeLabel, onClose } = props; + const { classes } = useStyles(); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", onKeyDown); + + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const onRootClick = (event: MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + return ( +
+
+
+ + {title} + + +
+ +
{children}
+
+
+ ); +} + +const useStyles = tss.withName({ SideDialog }).create(({ theme }) => ({ + root: { + position: "fixed", + inset: 0, + zIndex: theme.muiTheme.zIndex.modal, + marginRight: theme.spacing(3), + display: "flex", + justifyContent: "flex-end", + alignItems: "stretch", + padding: `64px ${theme.spacing(2)}px 32px`, + boxSizing: "border-box", + backgroundColor: alpha(theme.colors.useCases.surfaces.background, 0.72), + backdropFilter: "blur(1px)", + "@media (max-width: 720px)": { + marginRight: 0, + padding: 0 + } + }, + panel: { + width: 657, + maxWidth: "100%", + minHeight: 0, + display: "flex", + flexDirection: "column", + overflow: "hidden", + borderRadius: 16, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[4], + animation: `${keyframes` + from { + opacity: 0; + transform: translateX(28px); + } + to { + opacity: 1; + transform: translateX(0); + } + `} 340ms cubic-bezier(0.2, 0, 0, 1)`, + "@media (max-width: 720px)": { + borderRadius: 0, + borderTop: "none", + borderBottom: "none" + } + }, + headingWrapper: { + flex: "none", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + margin: `0 ${theme.spacing(5)}px`, + padding: `${theme.spacing(5)}px 0 ${theme.spacing(2)}px`, + borderBottom: `1px solid ${theme.colors.useCases.typography.textSecondary}` + }, + title: { + minWidth: 0, + color: theme.colors.useCases.typography.textPrimary + }, + closeButton: { + flex: "none" + }, + childrenWrapper: { + flex: 1, + minHeight: 0, + overflow: "hidden", + padding: `${theme.spacing(5)}px ${theme.spacing(5)}px ${theme.spacing(4)}px`, + boxSizing: "border-box" + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/types.ts b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/types.ts new file mode 100644 index 000000000..a6e83ce9c --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/CustomProviderFormDialog/types.ts @@ -0,0 +1,35 @@ +export type AiModel = { + id: string; + name: string; +}; + +export type FormValues = { + name: string; + provider: string; + apiBase: string; + apiKey: string; + selectedModelId: string; +}; + +export type FormTest = + | { stateDescription: "idle" } + | { stateDescription: "testing" } + | { stateDescription: "success"; models: AiModel[] } + | { stateDescription: "error" }; + +export type ViewProps = { + isEditing: boolean; + isAlreadyDefault: boolean; + values: FormValues; + test: FormTest; + doSetAsDefault: boolean; + canSave: boolean; + canTest: boolean; + supportedProtocols: readonly string[]; + onClose: () => void; + onFieldChange: (key: keyof FormValues, value: string) => void; + onProviderChange: (provider: string) => void; + onTest: () => void; + onSave: () => void; + onDoSetAsDefaultChange: (doSetAsDefault: boolean) => void; +}; 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..4be6b2914 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ModelsSection.tsx @@ -0,0 +1,103 @@ +import { memo } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { declareComponentKeys } from "i18nifty"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +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 = { + models: Models; + selectedModel: string | undefined; + onSelectedModelChange: (modelId: string) => void | Promise; +}; + +export const ModelsSection = memo((props: Props) => { + const { models, selectedModel, onSelectedModelChange } = props; + + const { classes } = useStyles(); + const { t } = useTranslation({ ModelsSection }); + + 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..81e237026 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ProviderValueField.tsx @@ -0,0 +1,127 @@ +import { memo, useEffect, useState } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { alpha } from "@mui/material/styles"; +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 | Promise; + isSensitiveInformation?: boolean; +}; + +export const ProviderValueField = memo((props: Props) => { + const { label, value, onRequestCopy, isSensitiveInformation = false } = props; + + const { classes, cx } = useStyles(); + const { t } = useTranslation({ ProviderValueField }); + const [isHidden, setIsHidden] = useState(isSensitiveInformation); + const [isCopied, setIsCopied] = useState(false); + + useEffect(() => { + setIsCopied(false); + }, [value]); + + useEffect(() => { + if (!isCopied) { + return; + } + + const timeoutId = window.setTimeout(() => setIsCopied(false), 1400); + + return () => window.clearTimeout(timeoutId); + }, [isCopied]); + + const onToggleHidden = useConstCallback(() => setIsHidden(isHidden => !isHidden)); + const onCopy = useConstCallback(async () => { + await onRequestCopy(); + setIsCopied(true); + }); + + return ( +
+ {label} +
+ + {isHidden ? "•".repeat(Math.max(value.length, 30)) : value} + + {isSensitiveInformation && ( + + )} + +
+
+ ); +}); + +const { i18n } = declareComponentKeys<"copy" | "copied">()({ 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), + border: "1px solid transparent", + backgroundColor: theme.colors.useCases.surfaces.surface2, + minWidth: 0, + boxSizing: "border-box", + transition: "background-color 180ms ease, border-color 180ms ease" + }, + codeFrameCopied: { + borderColor: alpha(theme.colors.useCases.alertSeverity.success.main, 0.36), + backgroundColor: theme.colors.useCases.alertSeverity.success.background + }, + 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 + }, + codeFrameButtonCopied: { + "&&": { + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: theme.colors.useCases.alertSeverity.success.main, + borderColor: theme.colors.useCases.alertSeverity.success.main, + "&:hover": { + backgroundColor: theme.colors.useCases.alertSeverity.success.main + } + } + } +})); 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