diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..798c6c1ce --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "storybook", + "runtimeExecutable": "yarn", + "runtimeArgs": ["--cwd", "web", "storybook", "--no-open"], + "port": 6006 + } + ] +} 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 ef87f7271..512a90a86 100644 --- a/web/.env +++ b/web/.env @@ -862,6 +862,26 @@ DISABLE_DISPLAY_ALL_CATALOG=false # See: https://docs.onyxia.sh/admin-doc/s3-configuration S3= +# AI providers displayed in Account > AI. Accepts a JSON5 object: +# { disable: false, disallowUserToAddProviders: false, description: "...", providers: [...] } +# description is displayed under the "AI Providers" title. It's Markdown, so it can link +# to your documentation, and accepts a string or a translation per language: +# description: { en: "... [Read more](https://...)", fr: "... [En savoir plus](https://...)" } +# Each provider defines name, providerType, apiBase, authentification and optional models. +# A provider can also have a logoUrl, displayed on its card: an image url, or one per +# theme like the other logos: { light: "https://...", dark: "https://..." }. +# It can have a documentation, displayed when the user manages it: +# documentation: { mainText: "...", links: [{ label: "...", url: "https://..." }] } +# mainText and label accept a string or a translation per language: { en: "...", fr: "..." }. +# Example: { providers: { name: "Local", providerType: "openai-compatible", +# apiBase: "https://llm.example.com/v1", authentification: { type: "none" }, +# logoUrl: "https://example.com/local-llm.svg" } } +# An empty value enables AI with no configured providers. Set disable: true to hide AI. +# Models are retrieved from the provider unless an explicit list is supplied. +# This configuration is exposed to the browser and must not contain secrets. +AI= + + # ================================================================================== # Private parameters - Not expected to be positioned manually, handled by the helm Chat. # ================================================================================== 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/scripts/unyamlify-env-local.ts b/web/scripts/unyamlify-env-local.ts index e0201ff9d..ba8de2275 100644 --- a/web/scripts/unyamlify-env-local.ts +++ b/web/scripts/unyamlify-env-local.ts @@ -37,6 +37,26 @@ if (!fs.existsSync(envLocalYamlFilePath)) { ` url: "https://www.sspcloud.fr/formation"`, ` }`, ` ]`, + ` AI: |`, + ` {`, + ` disable: false,`, + ` disallowUserToAddProviders: false,`, + ` providers: [`, + ` {`, + ` name: "SSPCloud LLM",`, + ` providerType: "openai-compatible",`, + ` apiBase: "https://llm.lab.sspcloud.fr/api",`, + ` authentification: {`, + ` type: "api-key",`, + ` obtentionMethod: "open-webui-oidc-token-exchange",`, + ` allowFallbackToUserProvidedApiKey: false,`, + ` oidcConfiguration: {`, + ` clientID: "onyxia-token-exchange-bridge",`, + ` },`, + ` },`, + ` },`, + ` ],`, + ` }`, ` S3: |`, ` {`, ` URL: "https://minio.lab.sspcloud.fr",`, diff --git a/web/src/core/adapters/oidc/oidc.ts b/web/src/core/adapters/oidc/oidc.ts index d959bc4ed..cfd4dc564 100644 --- a/web/src/core/adapters/oidc/oidc.ts +++ b/web/src/core/adapters/oidc/oidc.ts @@ -18,6 +18,13 @@ export async function createOidc( getCurrentLang: () => Language; autoLogin: AutoLogin; enableDebugLogs: boolean; + /** + * Opt this specific OIDC client instance out of DPoP. + * Use it when the access token has to be handed over to a third party that + * will use it on the user's behalf (e.g. an OpenWebUI token exchange): such + * a party cannot present a DPoP proof, so the token must not be sender-constrained. + */ + disableDPoP?: true; } ): Promise { const { @@ -29,7 +36,8 @@ export async function createOidc( extraQueryParams_raw, idleSessionLifetimeInSeconds, autoLogin, - enableDebugLogs + enableDebugLogs, + disableDPoP } = params; const extraQueryParams_raw_normalized = extraQueryParams_raw @@ -99,7 +107,8 @@ export async function createOidc( extraTokenParams, idleSessionLifetimeInSeconds, debugLogs: enableDebugLogs, - autoLogin + autoLogin, + ...(disableDPoP ? { disableDPoP } : {}) }); return oidc; diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 73d634bbf..039e116d1 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -16,6 +16,7 @@ import { createOnyxiaApi } from "core/adapters/onyxiaApi"; import { assert } from "tsafe/assert"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { type S3Config, parseS3ConfigFromEnvValue } from "core/ports/OnyxiaApi/S3Config"; +import { type AiConfig, parseAiConfigFromEnvValue } from "core/ports/OnyxiaApi/AiConfig"; import { setRootContext } from "./rootContext"; export type ParamsOfBootstrapCore = { @@ -33,6 +34,7 @@ export type ParamsOfBootstrapCore = { disableDisplayAllCatalog: boolean; getIsDarkModeEnabled: () => boolean; S3_envValue: string; + AI_envValue: string; }; export type Context = { @@ -42,6 +44,7 @@ export type Context = { secretsManager: SecretsManager; sqlOlap: SqlOlap; s3Config: S3Config; + aiConfig: AiConfig; }; export type Core = GenericCore; @@ -65,6 +68,8 @@ export async function bootstrapCore( envValue: params.S3_envValue }); + const aiConfig = parseAiConfigFromEnvValue({ envValue: params.AI_envValue }); + let oidc: Oidc | undefined = undefined; const onyxiaApi: OnyxiaApi = await (async () => { @@ -183,7 +188,6 @@ export async function bootstrapCore( if (isAuthGloballyRequired && !oidc.isUserLoggedIn) { await oidc.login({ doesCurrentHrefRequiresAuth: true }); - // NOTE: Never reached } const context: Context = { @@ -200,7 +204,8 @@ export async function bootstrapCore( usecases.s3ProfilesManagement.protectedThunks.getAmbientS3ProfileAndClient() ) }), - s3Config + s3Config, + aiConfig }; setRootContext(context); diff --git a/web/src/core/ports/OnyxiaApi/AiConfig.test.ts b/web/src/core/ports/OnyxiaApi/AiConfig.test.ts new file mode 100644 index 000000000..b223e7d71 --- /dev/null +++ b/web/src/core/ports/OnyxiaApi/AiConfig.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { parseAiConfigFromEnvValue } from "./AiConfig"; +const provider = { + name: "Local", + providerType: "openai-compatible", + apiBase: "https://example.com/v1/", + authentification: { type: "none" } +}; +const parse = (value: unknown) => + parseAiConfigFromEnvValue({ envValue: JSON.stringify(value) }); +describe("AI environment configuration", () => { + it("enables personal providers when the environment is empty", () => { + expect(parseAiConfigFromEnvValue({ envValue: "" })).toEqual({ + disable: false, + disallowUserToAddProviders: false, + description: undefined, + providers: [] + }); + }); + it("accepts JSON5 and normalizes a single provider", () => { + expect( + parseAiConfigFromEnvValue({ + envValue: + "{providers: {name: 'Local', providerType: 'openai', apiBase: 'https://example.com/v1/', authentification: {type: 'none'},},}" + }) + ).toMatchObject({ + disable: false, + disallowUserToAddProviders: false, + providers: [ + { name: "Local", apiBase: "https://example.com/v1", models: undefined } + ] + }); + }); + it("normalizes OIDC configuration", () => { + expect( + parse({ + providers: { + ...provider, + authentification: { + type: "api-key", + obtentionMethod: "open-webui-oidc-token-exchange", + oidcConfiguration: { clientID: "client" } + } + } + }) + ).toMatchObject({ + providers: [ + { + authentification: { + oidcParams: { + clientId: "client", + issuerUri: undefined, + scope_spaceSeparated: undefined, + extraQueryParams_raw: undefined, + idleSessionLifetimeInSeconds: undefined + } + } + } + ] + }); + }); + it("allows OIDC token exchange without a provider-specific OIDC configuration", () => { + expect( + parse({ + providers: { + ...provider, + authentification: { + type: "api-key", + obtentionMethod: "open-webui-oidc-token-exchange" + } + } + }) + ).toMatchObject({ + providers: [ + { + authentification: { + oidcParams: { + clientId: undefined, + issuerUri: undefined, + scope_spaceSeparated: undefined, + extraQueryParams_raw: undefined, + idleSessionLifetimeInSeconds: undefined + } + } + } + ] + }); + }); + it("distinguishes a static empty list from models to discover", () => { + expect(parse({ providers: { ...provider, models: [] } }).providers).toMatchObject( + [{ models: [] }] + ); + }); + it("accepts a logo url, or one per theme", () => { + const logoUrl_themed = { + light: "https://example.com/logo-light.svg", + dark: "https://example.com/logo-dark.svg" + }; + + expect( + parse({ + providers: [ + { ...provider, name: "A", logoUrl: "https://example.com/logo.png" }, + { ...provider, name: "B", logoUrl: logoUrl_themed }, + { ...provider, name: "C" } + ] + }).providers.map(({ logoUrl }) => logoUrl) + ).toStrictEqual(["https://example.com/logo.png", logoUrl_themed, undefined]); + }); + it("accepts a documentation, the links being optional", () => { + const link = { + label: { en: "Read more", fr: "En savoir plus" }, + url: "https://docs.example.com" + }; + + expect( + parse({ + providers: [ + { + ...provider, + name: "A", + documentation: { mainText: "Some help", links: [link] } + }, + { ...provider, name: "B", documentation: { mainText: "Some help" } }, + { ...provider, name: "C" } + ] + }).providers.map(({ documentation }) => documentation) + ).toStrictEqual([ + { mainText: "Some help", links: [link] }, + { mainText: "Some help", links: [] }, + undefined + ]); + }); + it.each([ + { providers: [provider, provider] }, + { providers: { ...provider, name: "a/b" } }, + { providers: { ...provider, providerType: "unknown" } }, + { providers: { ...provider, authentification: { type: "api-key" } } }, + { providers: { ...provider, logoUrl: "not an url" } }, + { providers: { ...provider, documentation: { links: [] } } }, + { + providers: { + ...provider, + documentation: { + mainText: "Some help", + links: [{ label: "Docs", url: "not an url" }] + } + } + }, + { providers: { ...provider, logoUrl: { light: "https://example.com/logo.svg" } } } + ])("rejects invalid or ambiguous providers", value => { + expect(() => parse(value)).toThrow(); + }); + it("reports invalid JSON5", () => { + expect(() => parseAiConfigFromEnvValue({ envValue: "{" })).toThrow( + "not a valid JSON5" + ); + }); + it("keeps the description written by the admin", () => { + expect( + parse({ + description: { en: "[Read more](https://example.com)", fr: "Lire" }, + providers: provider + }).description + ).toEqual({ en: "[Read more](https://example.com)", fr: "Lire" }); + }); +}); diff --git a/web/src/core/ports/OnyxiaApi/AiConfig.ts b/web/src/core/ports/OnyxiaApi/AiConfig.ts new file mode 100644 index 000000000..0caed87e4 --- /dev/null +++ b/web/src/core/ports/OnyxiaApi/AiConfig.ts @@ -0,0 +1,256 @@ +import type { ArrayOrNot } from "core/tools/ArrayOrNot"; +import { type LocalizedString, zLocalizedString } from "./Language"; +import { z } from "zod"; +import { assert, type Equals, id } from "tsafe"; +import JSON5 from "json5"; +import type { ApiTypes } from "core/adapters/onyxiaApi/ApiTypes"; +import type { OidcParams_Partial } from "./OidcParams"; + +type AI_EnvValue_ExpectedShape = { + disable?: boolean; + disallowUserToAddProviders?: boolean; + description?: LocalizedString; + providers: ArrayOrNot; +}; + +namespace AI_EnvValue_ExpectedShape { + export type Provider = { + name: string; + providerType: AiConfig.SupportedAiProviderType; + apiBase: string; + documentation?: { + mainText: LocalizedString; + links?: AiConfig.Documentation.Link[]; + }; + logoUrl?: AiConfig.LogoUrl; + authentification: + | { type: "none" } + | { + type: "api-key"; + obtentionMethod: "user-provided"; + } + | { + type: "api-key"; + obtentionMethod: "open-webui-oidc-token-exchange"; + oidcConfiguration?: Partial; + }; + models?: string[] /** When undefined, the models will be fetched from the provider model endpoint */; + }; +} + +const zAI_EnvValue_ExpectedShape = (() => { + type TargetType = AI_EnvValue_ExpectedShape; + + const zOidcConfiguration = z.custom>( + value => + z + .object({ + issuerURI: z.string().optional(), + clientID: z.string().min(1).optional(), + extraQueryParams: z.string().optional(), + scope: z.string().optional(), + idleSessionLifetimeInSeconds: z + .union([z.number(), z.string()]) + .optional() + }) + .safeParse(value).success + ); + + const zProvider = z.object({ + name: z + .string() + .trim() + .refine(name => !name.includes("/")), + providerType: z.enum([ + "openai-compatible", + "openai", + "anthropic", + "mistral", + "deepseek" + ]), + apiBase: z.string().url(), + documentation: z + .object({ + mainText: zLocalizedString, + links: z + .array(z.object({ label: zLocalizedString, url: z.string().url() })) + .optional() + }) + .optional(), + logoUrl: z + .union([ + z.string().url(), + z.object({ light: z.string().url(), dark: z.string().url() }) + ]) + .optional(), + authentification: z.union([ + z.object({ type: z.literal("none") }), + z.object({ + type: z.literal("api-key"), + obtentionMethod: z.literal("user-provided") + }), + z.object({ + type: z.literal("api-key"), + obtentionMethod: z.literal("open-webui-oidc-token-exchange"), + oidcConfiguration: zOidcConfiguration.optional() + }) + ]), + models: z.array(z.string().min(1)).optional() + }); + const zTargetType = z.object({ + disable: z.boolean().optional(), + disallowUserToAddProviders: z.boolean().optional(), + description: zLocalizedString.optional(), + providers: z.union([zProvider, z.array(zProvider)]) + }); + + type InferredType = z.infer; + + assert>(); + + return id>(zTargetType); +})(); + +export type AiConfig = { + disable: boolean; + disallowUserToAddProviders: boolean; + /** Markdown, written by the admin, introducing AI in the account tab */ + description: LocalizedString | undefined; + providers: AiConfig.Provider[]; +}; + +export namespace AiConfig { + export type Provider = { + name: string; + providerType: SupportedAiProviderType; + apiBase: string; + documentation: Documentation | undefined; + logoUrl: LogoUrl | undefined; + authentification: + | { type: "none" } + | { + type: "api-key"; + obtentionMethod: "user-provided"; + } + | { + type: "api-key"; + obtentionMethod: "open-webui-oidc-token-exchange"; + oidcParams: OidcParams_Partial; + }; + /** When undefined, the models will be fetched from the provider model endpoint */ + models: string[] | undefined; + }; + + /** Help about the provider, written by the admin, displayed when managing it. */ + export type Documentation = { + mainText: LocalizedString; + links: Documentation.Link[]; + }; + + export namespace Documentation { + export type Link = { label: LocalizedString; url: string }; + } + + /** An image url, or one url per theme like the other logos an admin can provide. */ + export type LogoUrl = string | { light: string; dark: string }; + + export type SupportedAiProviderType = + | "openai-compatible" + | "openai" + | "anthropic" + | "mistral" + | "deepseek"; +} + +export function parseAiConfigFromEnvValue(params: { envValue: string }): AiConfig { + const { envValue } = params; + + if (envValue === "") { + return { + disable: false, + disallowUserToAddProviders: false, + description: undefined, + providers: [] + }; + } + + let parsedValue: unknown; + try { + parsedValue = JSON5.parse(envValue); + } catch { + throw new Error("The AI env is not a valid JSON5"); + } + let config: AI_EnvValue_ExpectedShape; + try { + config = zAI_EnvValue_ExpectedShape.parse(parsedValue); + } catch (error) { + throw new Error(`The format of the AI env is not valid: ${String(error)}`); + } + const providers = Array.isArray(config.providers) + ? config.providers + : [config.providers]; + assert( + new Set(providers.map(p => p.name)).size === providers.length, + "AI provider names must be unique" + ); + return { + disable: config.disable ?? false, + disallowUserToAddProviders: config.disallowUserToAddProviders ?? false, + description: config.description, + providers: providers.map( + (provider): AiConfig.Provider => ({ + name: provider.name, + providerType: provider.providerType, + apiBase: provider.apiBase.replace(/\/+$/, ""), + documentation: + provider.documentation === undefined + ? undefined + : { + mainText: provider.documentation.mainText, + links: provider.documentation.links ?? [] + }, + logoUrl: provider.logoUrl, + models: + provider.models === undefined + ? undefined + : [...new Set(provider.models)], + authentification: + provider.authentification.type === "none" || + provider.authentification.obtentionMethod === "user-provided" + ? provider.authentification + : { + type: "api-key", + obtentionMethod: "open-webui-oidc-token-exchange", + oidcParams: id({ + issuerUri: + provider.authentification.oidcConfiguration + ?.issuerURI, + clientId: + provider.authentification.oidcConfiguration + ?.clientID, + extraQueryParams_raw: + provider.authentification.oidcConfiguration + ?.extraQueryParams, + scope_spaceSeparated: + provider.authentification.oidcConfiguration?.scope, + idleSessionLifetimeInSeconds: (() => { + const value = + provider.authentification.oidcConfiguration + ?.idleSessionLifetimeInSeconds; + + if (value === "" || value === undefined) { + return undefined; + } + + if (typeof value === "number") { + return value; + } + + return parseInt(value); + })() + }) + } + }) + ) + }; +} diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index d61999186..fc614f29d 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -198,6 +198,18 @@ export type XOnyxiaContext = { useCertManager: boolean; certManagerClusterIssuer: string | undefined; }; + ai: { + enabled: boolean; + models: string[]; // /[] + defaultModel: string | undefined; // / + providers: { + name: string; //Needs to be unique + apiBase: string; + apiKey: string | undefined; + models: string[]; + type: "openai-compatible" | "openai" | "anthropic" | "mistral" | "deepseek"; + }[]; + }; proxyInjection: | { enabled: boolean | undefined; diff --git a/web/src/core/tools/exchangeOpenWebUiToken.test.ts b/web/src/core/tools/exchangeOpenWebUiToken.test.ts new file mode 100644 index 000000000..e83b09d72 --- /dev/null +++ b/web/src/core/tools/exchangeOpenWebUiToken.test.ts @@ -0,0 +1,38 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { exchangeOpenWebUiToken } from "./exchangeOpenWebUiToken"; +afterEach(() => vi.unstubAllGlobals()); +it("exchanges the OIDC token using the configured API base", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ token: "exchanged" }))); + vi.stubGlobal("fetch", fetchMock); + expect( + await exchangeOpenWebUiToken({ + apiBase: "https://example.com/api/", + oidcAccessToken: "oidc" + }) + ).toBe("exchanged"); + expect(fetchMock).toHaveBeenCalledWith( + "https://example.com/api/v1/auths/oauth/oidc/token/exchange", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ token: "oidc" }) + }) + ); +}); +it("rejects malformed tokens", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ access_token: "unexpected" })) + ) + ); + await expect( + exchangeOpenWebUiToken({ + apiBase: "https://example.com/api", + oidcAccessToken: "oidc" + }) + ).rejects.toThrow(); +}); diff --git a/web/src/core/tools/exchangeOpenWebUiToken.ts b/web/src/core/tools/exchangeOpenWebUiToken.ts new file mode 100644 index 000000000..3a2b77371 --- /dev/null +++ b/web/src/core/tools/exchangeOpenWebUiToken.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export async function exchangeOpenWebUiToken(params: { + apiBase: string; + oidcAccessToken: string; +}): Promise { + const response = await fetch( + `${params.apiBase.replace(/\/+$/, "")}/v1/auths/oauth/oidc/token/exchange`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: params.oidcAccessToken }), + signal: AbortSignal.timeout(10_000) + } + ); + if (!response.ok) throw new Error(`OIDC token exchange failed (${response.status})`); + return z.object({ token: z.string().min(1) }).parse(await response.json()).token; +} diff --git a/web/src/core/tools/fetchAiModels.test.ts b/web/src/core/tools/fetchAiModels.test.ts new file mode 100644 index 000000000..a77d5e075 --- /dev/null +++ b/web/src/core/tools/fetchAiModels.test.ts @@ -0,0 +1,65 @@ +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({ + protocol: "openai-compatible", + apiBase: "https://gateway.example.com/v1", + apiKey: "openai-key" + }) + ).resolves.toStrictEqual([{ id: "model-a" }, { id: "model-b" }]); + expect(fetchMock).toHaveBeenCalledWith("https://gateway.example.com/v1/models", { + headers: { Authorization: "Bearer openai-key" }, + signal: expect.any(AbortSignal) + }); + }); + + 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({ + protocol: "anthropic", + apiBase: "https://api.anthropic.com/v1", + apiKey: "anthropic-key" + }) + ).resolves.toStrictEqual([{ id: "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" + }, + signal: expect.any(AbortSignal) + }); + }); +}); diff --git a/web/src/core/tools/fetchAiModels.ts b/web/src/core/tools/fetchAiModels.ts new file mode 100644 index 000000000..bf869f070 --- /dev/null +++ b/web/src/core/tools/fetchAiModels.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +export type AiModel = { id: string }; + +const requestTimeoutMs = 10_000; + +/** Lists the models exposed by a user-added provider using its native protocol. */ +export async function fetchAiModels(params: { + protocol: string; + apiBase: string; + apiKey: string | undefined; +}): Promise { + const { protocol, apiBase, apiKey } = params; + + const headers: Record = + protocol === "anthropic" + ? { + ...(apiKey === undefined ? {} : { "x-api-key": apiKey }), + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true" + } + : apiKey === undefined + ? {} + : { Authorization: `Bearer ${apiKey}` }; + + const response = await fetch(`${apiBase.replace(/\/+$/, "")}/models`, { + headers, + signal: AbortSignal.timeout(requestTimeoutMs) + }); + + if (!response.ok) { + throw new Error(`Failed to list models (${response.status})`); + } + + const json = await response.json(); + + if (protocol === "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 }) => ({ 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 }) => ({ id })); + } catch { + throw new Error("Unexpected OpenAI-compatible /models response shape"); + } +} diff --git a/web/src/core/usecases/aiAccountUiController/index.ts b/web/src/core/usecases/aiAccountUiController/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/aiAccountUiController/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/aiAccountUiController/selectors.ts b/web/src/core/usecases/aiAccountUiController/selectors.ts new file mode 100644 index 000000000..f111c0b0f --- /dev/null +++ b/web/src/core/usecases/aiAccountUiController/selectors.ts @@ -0,0 +1,88 @@ +import { createSelector } from "clean-architecture"; +import type { State as RootState } from "core/bootstrap"; +import * as aiProvidersManagements from "core/usecases/aiProvidersManagements"; +import { + stringifyModel, + getProviderConnectionState +} from "core/usecases/aiProvidersManagements/decoupledLogic"; +import { getRootContext } from "core/rootContext"; +import { name } from "./state"; + +const state = (rootState: RootState) => rootState[name]; + +const main = createSelector( + state, + aiProvidersManagements.selectors.stateDescription, + aiProvidersManagements.selectors.errorReason, + aiProvidersManagements.selectors.aiProviders, + aiProvidersManagements.selectors.defaultModel, + aiProvidersManagements.selectors.configSaveState, + ( + state, + stateDescription, + errorReason, + aiProviders, + defaultModel, + configSaveState + ) => { + if (aiProviders === undefined) { + return { + stateDescription, + isReady: false as const, + /** The stored config can't be read back, it can only be reset. */ + isConfigUnreadable: errorReason === "unreadable config" + }; + } + + const providers = aiProviders.map(aiProvider => ({ + ...aiProvider, + operationState: state.operationByProviderName[aiProvider.name] ?? "idle", + connectionState: getProviderConnectionState({ + // Whether the provider could be reached, even if its models are pinned + connection: + aiProvider.auth.stateDescription === "error" || + aiProvider.modelsListing.stateDescription === "error" + ? "failed" + : aiProvider.modelsListing.stateDescription === "loaded" + ? "succeeded" + : aiProvider.auth.stateDescription === "fetching" || + aiProvider.modelsListing.stateDescription === "fetching" + ? "testing" + : "not tested" + }), + canRefreshToken: + aiProvider.origin === "configured by admin" && + aiProvider.authentification.type === "api-key" && + aiProvider.authentification.obtentionMethod === + "open-webui-oidc-token-exchange" + })); + + // The default model is picked among what the user ticked, across all providers. + const defaultModelOptionGroups = providers + .filter(provider => provider.selectedModelIds.length !== 0) + .map(provider => ({ + providerName: provider.name, + options: provider.selectedModelIds.map(modelId => ({ + value: stringifyModel({ + providerName: provider.name, + modelId + }), + modelId + })) + })); + + return { + stateDescription, + isReady: true as const, + providers, + defaultModelOptionGroups, + /** Markdown written by the admin in the instance configuration */ + description: getRootContext().aiConfig.description, + defaultModel: + defaultModel === undefined ? undefined : stringifyModel(defaultModel), + configSaveState + }; + } +); + +export const selectors = { main }; diff --git a/web/src/core/usecases/aiAccountUiController/state.ts b/web/src/core/usecases/aiAccountUiController/state.ts new file mode 100644 index 000000000..1a5afa747 --- /dev/null +++ b/web/src/core/usecases/aiAccountUiController/state.ts @@ -0,0 +1,33 @@ +import { createUsecaseActions } from "clean-architecture"; +import { id } from "tsafe/id"; + +export const name = "aiAccountUiController"; + +export type OperationState = "idle" | "pending" | "error"; + +export type State = { + operationByProviderName: Record; +}; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id({ + operationByProviderName: {} + }), + reducers: { + providerOperationStarted: ( + state, + { payload }: { payload: { providerName: string } } + ) => { + state.operationByProviderName[payload.providerName] = "pending"; + }, + providerOperationCompleted: ( + state, + { payload }: { payload: { providerName: string; isSuccess: boolean } } + ) => { + state.operationByProviderName[payload.providerName] = payload.isSuccess + ? "idle" + : "error"; + } + } +}); diff --git a/web/src/core/usecases/aiAccountUiController/thunks.test.ts b/web/src/core/usecases/aiAccountUiController/thunks.test.ts new file mode 100644 index 000000000..8bbb9331a --- /dev/null +++ b/web/src/core/usecases/aiAccountUiController/thunks.test.ts @@ -0,0 +1,611 @@ +import { beforeEach, afterEach, expect, it, vi } from "vitest"; +import { createCore, createUsecaseActions } from "clean-architecture"; +import type { Context } from "core/bootstrap"; +import * as providers from "../aiProvidersManagements"; +import * as account from "./index"; +import * as form from "../aiProviderFormUiController"; +const mocks = vi.hoisted(() => ({ + context: undefined as unknown, + getTokens: vi.fn(), + save: vi.fn() +})); +vi.mock("core/rootContext", () => ({ getRootContext: () => mocks.context })); +vi.mock("core/adapters/oidc", () => ({ + createOidc: async () => ({ isUserLoggedIn: true, getTokens: mocks.getTokens }), + mergeOidcParams: ({ + oidcParams, + oidcParams_partial + }: { + oidcParams: Record; + oidcParams_partial: Record; + }) => ({ ...oidcParams, ...oidcParams_partial }) +})); +vi.mock("core/usecases/userConfigs", () => ({ + selectors: { + userConfigs: (state: { userConfigs: { aiConfigStr: string | null } }) => + state.userConfigs + }, + thunks: { + changeValue: + ({ value }: { value: string }) => + async (dispatch: (action: unknown) => void) => { + await mocks.save(value); + dispatch({ type: "userConfigs/valueChanged", payload: value }); + } + } +})); +function setup(params?: { aiConfigStr: string | null }) { + const { aiConfigStr = null } = params ?? {}; + const userConfigs = { + thunks: {}, + selectors: {}, + ...createUsecaseActions({ + name: "userConfigs", + initialState: { aiConfigStr }, + reducers: { + valueChanged: (state, { payload }: { payload: string }) => { + state.aiConfigStr = payload; + } + } + }) + }; + const context = { + aiConfig: { + disable: false, + disallowUserToAddProviders: false, + providers: [ + { + name: "Exchange", + providerType: "openai-compatible", + apiBase: "https://example.com/api", + documentation: undefined, + models: undefined, + authentification: { + type: "api-key", + obtentionMethod: "open-webui-oidc-token-exchange", + oidcParams: { + issuerUri: undefined, + clientId: "bridge", + extraQueryParams_raw: undefined, + scope_spaceSeparated: undefined, + idleSessionLifetimeInSeconds: undefined + } + } + }, + { + name: "Public", + providerType: "openai-compatible", + apiBase: "https://public.example/v1", + documentation: undefined, + models: ["a"], + authentification: { type: "none" } + }, + { + name: "Keyed", + providerType: "openai-compatible", + apiBase: "https://keyed.example/v1", + documentation: undefined, + models: undefined, + authentification: { + type: "api-key", + obtentionMethod: "user-provided" + } + } + ] + }, + oidc: { isUserLoggedIn: true }, + onyxiaApi: { + getAvailableRegionsAndOidcParams: async () => ({ + oidcParams: { issuerUri: "https://issuer.example", clientId: "onyxia" } + }) + }, + paramsOfBootstrapCore: { + getCurrentLang: () => "en", + transformBeforeRedirectForKeycloakTheme: ({ + authorizationUrl + }: { + authorizationUrl: string; + }) => authorizationUrl + } + } as unknown as Context; + mocks.context = context; + const { core, dispatch } = createCore({ + context, + usecases: { + aiProvidersManagements: providers, + aiAccountUiController: account, + aiProviderFormUiController: form, + userConfigs + } + }); + return { + core, + dispatch: dispatch as unknown as Parameters< + ReturnType + >[0] + }; +} +beforeEach(() => { + mocks.save.mockReset().mockResolvedValue(undefined); + mocks.getTokens.mockReset().mockResolvedValue({ accessToken: "oidc" }); + vi.stubGlobal( + "fetch", + vi.fn( + async (url: string) => + new Response( + JSON.stringify( + url.endsWith("/models") + ? { data: [{ id: "a" }] } + : { token: "exchanged" } + ) + ) + ) + ); +}); +afterEach(() => vi.unstubAllGlobals()); +it("exposes the account data and refreshes only the exchange token", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + const state = core.states.aiAccountUiController.getMain(); + expect(state.isReady).toBe(true); + expect(state.providers?.map(p => p.canRefreshToken)).toEqual([true, false, false]); + vi.mocked(fetch).mockClear(); + await core.functions.aiAccountUiController.refreshToken({ providerName: "Exchange" }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + "https://example.com/api/v1/auths/oauth/oidc/token/exchange", + expect.anything() + ); + expect( + core.states.aiAccountUiController.getMain().providers?.[0].models.stateDescription + ).toBe("loaded"); +}); +it("rejects token refresh for a provider without exchange authentication", async () => { + const { core, dispatch } = setup(); + await core.functions.aiAccountUiController.load(); + vi.mocked(fetch).mockClear(); + await expect( + dispatch(providers.thunks.refreshToken({ providerName: "Public" })) + ).rejects.toThrow(); + expect(fetch).not.toHaveBeenCalled(); +}); +it("creates a provider through the current form controller and selects its default", async () => { + const { core, dispatch } = setup(); + await core.functions.aiAccountUiController.load(); + const creation = core.functions.aiProviderFormUiController; + creation.open({ providerName: undefined }); + creation.changeValue({ key: "name", value: "Personal" }); + creation.changeProviderType({ providerType: "openai" }); + await creation.testConnection(); + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + canSubmit: true, + connectionTest: { stateDescription: "succeeded" } + }); + await creation.submit(); + expect(core.states.aiProviderFormUiController.getMain().isOpen).toBe(false); + await core.functions.aiAccountUiController.setSelectedModelIds({ + providerName: "Personal", + modelIds: ["a"] + }); + await core.functions.aiAccountUiController.setDefaultModel({ model: "Personal/a" }); + expect(await dispatch(providers.protectedThunks.getAiContext())).toMatchObject({ + defaultModel: "Personal/a", + models: ["Exchange/a", "Public/a", "Personal/a"] + }); +}); + +it("selects every model of the providers absent from the user config, and the first one as default", async () => { + const { core, dispatch } = setup(); + await core.functions.aiAccountUiController.load(); + + const creation = core.functions.aiProviderFormUiController; + creation.open({ providerName: undefined }); + creation.changeValue({ key: "name", value: "Personal" }); + creation.changeProviderType({ providerType: "openai" }); + await creation.testConnection(); + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + selectedModelIds_draft: ["a"] + }); + await creation.submit(); + + expect(core.states.aiAccountUiController.getMain()).toMatchObject({ + defaultModel: "Exchange/a" + }); + expect(await dispatch(providers.protectedThunks.getAiContext())).toMatchObject({ + defaultModel: "Exchange/a", + models: ["Exchange/a", "Public/a", "Personal/a"] + }); + + // Once the user filtered, their choice sticks + await core.functions.aiAccountUiController.setSelectedModelIds({ + providerName: "Exchange", + modelIds: [] + }); + expect(await dispatch(providers.protectedThunks.getAiContext())).toMatchObject({ + defaultModel: "Public/a", + models: ["Public/a", "Personal/a"] + }); +}); + +it("prefills a unique provider name and saves it when model loading fails", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + + const creation = core.functions.aiProviderFormUiController; + creation.open({ providerName: undefined }); + creation.changeProviderType({ providerType: "mistral" }); + + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + formValues: { name: "Mistral" }, + canSubmit: true + }); + + vi.mocked(fetch).mockRejectedValueOnce(new Error("unreachable")); + await creation.testConnection(); + + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + connectionTest: { stateDescription: "failed" }, + canSubmit: true + }); + + await creation.submit(); + + expect(core.states.aiProviderFormUiController.getMain().isOpen).toBe(false); + expect( + core.states.aiAccountUiController + .getMain() + .providers?.find(provider => provider.name === "Mistral")?.models + ).toMatchObject({ stateDescription: "not loaded" }); + + creation.open({ providerName: undefined }); + creation.changeProviderType({ providerType: "mistral" }); + creation.changeProviderType({ providerType: "openai" }); + creation.changeProviderType({ providerType: "mistral" }); + + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + formValues: { name: "Mistral 2" } + }); +}); + +it("does not allow a custom provider to reuse an existing provider name", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + + const creation = core.functions.aiProviderFormUiController; + creation.open({ providerName: undefined }); + creation.changeProviderType({ providerType: "openai" }); + creation.changeValue({ key: "name", value: "Exchange" }); + + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + isNameValid: false, + canSubmit: false + }); +}); + +it("keeps provider errors when another operation succeeds", async () => { + const { core } = setup(); + const ui = core.functions.aiAccountUiController; + await ui.load(); + await ui.refreshToken({ providerName: "Public" }); + await ui.setDefaultModel({ model: undefined }); + expect(core.states.aiAccountUiController.getMain()).toMatchObject({ + providers: [ + { name: "Exchange", operationState: "idle" }, + { name: "Public", operationState: "error" }, + { name: "Keyed", operationState: "idle" } + ] + }); +}); + +it("allows model selection while a token refresh is pending", async () => { + const { core } = setup(); + const ui = core.functions.aiAccountUiController; + await ui.load(); + let resolveTokens!: (tokens: { accessToken: string }) => void; + mocks.getTokens.mockReturnValue( + new Promise(resolve => { + resolveTokens = resolve; + }) + ); + const refresh = ui.refreshToken({ providerName: "Exchange" }); + await ui.setSelectedModelIds({ providerName: "Exchange", modelIds: ["a"] }); + expect(core.states.aiAccountUiController.getMain().providers?.[0]).toMatchObject({ + operationState: "pending", + selectedModelIds: ["a"] + }); + resolveTokens({ accessToken: "renewed" }); + await refresh; + expect( + core.states.aiAccountUiController.getMain().providers?.[0].operationState + ).toBe("idle"); +}); + +it("updates selections immediately and saves the latest snapshot after an in-flight write", async () => { + const { core } = setup(); + const ui = core.functions.aiAccountUiController; + await ui.load(); + let finishSave!: () => void; + mocks.save.mockImplementationOnce( + () => + new Promise(resolve => { + finishSave = resolve; + }) + ); + ui.setSelectedModelIds({ providerName: "Exchange", modelIds: ["a"] }); + await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(1)); + ui.setDefaultModel({ model: "Exchange/a" }); + ui.setSelectedModelIds({ providerName: "Exchange", modelIds: [] }); + ui.setSelectedModelIds({ providerName: "Exchange", modelIds: ["a"] }); + expect(core.states.aiAccountUiController.getMain()).toMatchObject({ + defaultModel: "Exchange/a", + configSaveState: "pending" + }); + expect(mocks.save).toHaveBeenCalledTimes(1); + finishSave(); + await vi.waitFor(() => + expect(core.states.aiAccountUiController.getMain().configSaveState).toBe("idle") + ); + expect(mocks.save).toHaveBeenCalledTimes(2); + expect(JSON.parse(mocks.save.mock.calls[1][0])).toMatchObject({ + excludedModelIdsByProviderName: { Exchange: [] }, + defaultModel: { providerName: "Exchange", modelId: "a" } + }); +}); + +it("keeps unsaved choices in memory after failure and retries them", async () => { + const { core } = setup(); + const ui = core.functions.aiAccountUiController; + await ui.load(); + mocks.save.mockRejectedValueOnce(new Error("offline")); + ui.setSelectedModelIds({ providerName: "Exchange", modelIds: ["a"] }); + await vi.waitFor(() => + expect(core.states.aiAccountUiController.getMain().configSaveState).toBe("error") + ); + expect( + core.states.aiAccountUiController.getMain().providers?.[0].selectedModelIds + ).toEqual(["a"]); + ui.retrySave(); + await vi.waitFor(() => + expect(core.states.aiAccountUiController.getMain().configSaveState).toBe("idle") + ); + expect(mocks.save).toHaveBeenCalledTimes(2); + expect(mocks.save.mock.calls[1][0]).toBe(mocks.save.mock.calls[0][0]); +}); + +it("tests a typed API key without saving it, then saves it without testing again", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + + const edition = core.functions.aiProviderFormUiController; + const getKeyedProvider = () => + core.states.aiAccountUiController + .getMain() + .providers?.find(provider => provider.name === "Keyed"); + + edition.open({ providerName: "Keyed" }); + + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + providerOrigin: "configured by admin", + canEditApiKey: true, + canSubmit: false + }); + expect(() => edition.changeValue({ key: "name", value: "Renamed" })).toThrow(); + + edition.changeValue({ key: "apiKey", value: " typed " }); + + expect(core.states.aiProviderFormUiController.getMain().canSubmit).toBe(true); + + edition.changeValue({ key: "apiKey", value: "" }); + + expect(core.states.aiProviderFormUiController.getMain().canSubmit).toBe(false); + + edition.changeValue({ key: "apiKey", value: " typed " }); + + vi.mocked(fetch).mockClear(); + mocks.save.mockClear(); + + await edition.testConnection(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith("https://keyed.example/v1/models", { + headers: { Authorization: "Bearer typed" }, + signal: expect.anything() + }); + expect(mocks.save).not.toHaveBeenCalled(); + expect(getKeyedProvider()).toMatchObject({ + auth: { stateDescription: "api-key not provided" } + }); + + edition.changeSelectedModelIds({ selectedModelIds: ["a"] }); + + vi.mocked(fetch).mockClear(); + + await edition.submit(); + + expect(core.states.aiProviderFormUiController.getMain().isOpen).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + expect(mocks.save).toHaveBeenCalled(); + expect(getKeyedProvider()).toMatchObject({ + auth: { stateDescription: "authenticated", apiKey: "typed" }, + models: { stateDescription: "loaded", availableModels: [{ id: "a" }] }, + selectedModelIds: ["a"] + }); +}); + +it("saves the model selection right away when the models are the saved ones", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + + const edition = core.functions.aiProviderFormUiController; + + edition.open({ providerName: "Public" }); + + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + isModelSelectionSavedImmediately: true, + canSubmit: false + }); + + edition.changeSelectedModelIds({ selectedModelIds: ["a"] }); + + expect( + core.states.aiAccountUiController + .getMain() + .providers?.find(provider => provider.name === "Public")?.selectedModelIds + ).toEqual(["a"]); + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + isOpen: true, + canSubmit: false + }); +}); + +it("gives a provider without authentication the same state on its card and in its dialog", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + + const getCardState = () => + core.states.aiAccountUiController + .getMain() + .providers?.find(provider => provider.name === "Public")?.connectionState; + + const edition = core.functions.aiProviderFormUiController; + + edition.open({ providerName: "Public" }); + + // Reachable: whether models are picked doesn't matter + expect(getCardState()).toBe("connected"); + expect(core.states.aiProviderFormUiController.getMain().connectionState).toBe( + "connected" + ); +}); + +it("actually calls a provider whose models are pinned by the admin when testing it", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + + const edition = core.functions.aiProviderFormUiController; + + edition.open({ providerName: "Public" }); + + vi.mocked(fetch).mockClear(); + + await edition.testConnection(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith("https://public.example/v1/models", { + headers: {}, + signal: expect.anything() + }); + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + connectionTest: { stateDescription: "succeeded", availableModels: [{ id: "a" }] } + }); + + vi.mocked(fetch).mockRejectedValueOnce(new Error("unreachable")); + + await edition.testConnection(); + + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + connectionTest: { stateDescription: "failed" }, + connectionState: "connection error" + }); +}); + +it("reports an unreachable provider with pinned models, whose models stay selectable", async () => { + const { core } = setup(); + + vi.mocked(fetch).mockImplementation(async (url: string | URL | Request) => { + if (String(url) === "https://public.example/v1/models") { + throw new Error("unreachable"); + } + + return new Response( + JSON.stringify( + String(url).endsWith("/models") + ? { data: [{ id: "a" }] } + : { token: "exchanged" } + ) + ); + }); + + await core.functions.aiAccountUiController.load(); + + const getPublic = () => + core.states.aiAccountUiController + .getMain() + .providers?.find(provider => provider.name === "Public"); + + // The card tells the truth, but still offers the models pinned by the admin + expect(getPublic()).toMatchObject({ + connectionState: "connection error", + models: { stateDescription: "loaded", availableModels: [{ id: "a" }] } + }); + + const edition = core.functions.aiProviderFormUiController; + + edition.open({ providerName: "Public" }); + + expect(core.states.aiProviderFormUiController.getMain()).toMatchObject({ + connectionTest: { stateDescription: "failed" }, + connectionState: "connection error", + availableModels: [{ id: "a" }] + }); + + edition.changeSelectedModelIds({ selectedModelIds: ["a"] }); + + expect(getPublic()?.selectedModelIds).toEqual(["a"]); +}); + +it("doesn't report a provider as connected once its API key is removed", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + + const edition = core.functions.aiProviderFormUiController; + const getCardState = () => + core.states.aiAccountUiController + .getMain() + .providers?.find(provider => provider.name === "Keyed")?.connectionState; + + edition.open({ providerName: "Keyed" }); + edition.changeValue({ key: "apiKey", value: "typed" }); + await edition.testConnection(); + await edition.submit(); + + expect(getCardState()).toBe("connected"); + + edition.open({ providerName: "Keyed" }); + edition.changeValue({ key: "apiKey", value: "" }); + await edition.submit(); + + // What was listed with the removed key must not be taken for a connection + expect(getCardState()).toBe("setup required"); +}); + +it("groups the default model options by provider, leaving out the ones without models", async () => { + const { core } = setup(); + await core.functions.aiAccountUiController.load(); + + await core.functions.aiAccountUiController.setSelectedModelIds({ + providerName: "Exchange", + modelIds: [] + }); + + expect(core.states.aiAccountUiController.getMain().defaultModelOptionGroups).toEqual([ + { providerName: "Public", options: [{ value: "Public/a", modelId: "a" }] } + ]); +}); + +it("requires the user to reset a stored config that can't be read back", async () => { + const { core } = setup({ aiConfigStr: "{ not json" }); + await core.functions.aiAccountUiController.load(); + + expect(core.states.aiAccountUiController.getMain()).toMatchObject({ + isReady: false, + isConfigUnreadable: true + }); + // Nothing is overwritten behind the user's back + expect(mocks.save).not.toHaveBeenCalled(); + + await core.functions.aiAccountUiController.resetConfig(); + + expect(mocks.save).toHaveBeenCalledTimes(1); + expect(core.states.aiAccountUiController.getMain().isReady).toBe(true); +}); diff --git a/web/src/core/usecases/aiAccountUiController/thunks.ts b/web/src/core/usecases/aiAccountUiController/thunks.ts new file mode 100644 index 000000000..9d8413889 --- /dev/null +++ b/web/src/core/usecases/aiAccountUiController/thunks.ts @@ -0,0 +1,112 @@ +import type { Thunks } from "core/bootstrap"; +import * as aiProvidersManagements from "core/usecases/aiProvidersManagements"; +import { parseModel } from "core/usecases/aiProvidersManagements/decoupledLogic"; +import { assert } from "tsafe/assert"; +import { actions, name } from "./state"; + +export const thunks = { + /** Whether the AI tab has to be shown at all. */ + isAvailable: + () => + (...args): boolean => { + const [dispatch] = args; + + return dispatch(aiProvidersManagements.thunks.isAvailable()); + }, + load: + () => + async (...args): Promise => { + const [dispatch] = args; + + await dispatch(aiProvidersManagements.thunks.load()); + }, + /** Only when the stored config can't be read back: everything it held is lost. */ + resetConfig: + () => + async (...args): Promise => { + const [dispatch] = args; + + await dispatch(aiProvidersManagements.thunks.resetConfig()); + }, + canUserCreateProviders: + () => + (...args): boolean => { + const [dispatch] = args; + + return dispatch(aiProvidersManagements.thunks.canUserCreateProviders()); + }, + refreshToken: + (params: { providerName: string }) => + async (...[dispatch]): Promise => { + await dispatch( + privateThunks.runProviderOperation({ + providerName: params.providerName, + mutate: () => + dispatch(aiProvidersManagements.thunks.refreshToken(params)) + }) + ); + }, + + setSelectedModelIds: + (params: { providerName: string; modelIds: string[] }) => + (...[dispatch]) => { + dispatch(aiProvidersManagements.thunks.setSelectedModelIds(params)); + }, + retrySave: + () => + (...[dispatch]) => { + dispatch(aiProvidersManagements.thunks.saveConfig()); + }, + deleteUserProvider: + (params: { providerName: string }) => + async (...args): Promise => { + const [dispatch] = args; + + await dispatch( + privateThunks.runProviderOperation({ + providerName: params.providerName, + mutate: () => + dispatch(aiProvidersManagements.thunks.deleteUserProvider(params)) + }) + ); + }, + /** `model` is the `/` value of the global select. */ + setDefaultModel: + (params: { model: string | undefined }) => + (...[dispatch]) => { + const { model } = params; + const defaultModel = model === undefined ? undefined : parseModel({ model }); + assert(model === undefined || defaultModel !== undefined); + dispatch(aiProvidersManagements.thunks.setDefaultModel({ defaultModel })); + } +} satisfies Thunks; + +const privateThunks = { + runProviderOperation: + (params: { providerName: string; mutate: () => Promise }) => + async (...args): Promise => { + const { providerName, mutate } = params; + + const [dispatch, getState] = args; + + if (getState()[name].operationByProviderName[providerName] === "pending") { + return; + } + + dispatch(actions.providerOperationStarted({ providerName })); + + try { + await mutate(); + } catch { + dispatch( + actions.providerOperationCompleted({ providerName, isSuccess: false }) + ); + + return; + } + + dispatch( + actions.providerOperationCompleted({ providerName, isSuccess: true }) + ); + } +} satisfies Thunks; diff --git a/web/src/core/usecases/aiProviderFormUiController/decoupledLogic/providerTypeDefaultApiBase.ts b/web/src/core/usecases/aiProviderFormUiController/decoupledLogic/providerTypeDefaultApiBase.ts new file mode 100644 index 000000000..d578b50f7 --- /dev/null +++ b/web/src/core/usecases/aiProviderFormUiController/decoupledLogic/providerTypeDefaultApiBase.ts @@ -0,0 +1,10 @@ +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; + +/** Prefilled when the user picks a provider type, they remain free to change it. */ +export const providerTypeDefaultApiBase = { + "openai-compatible": "", + openai: "https://api.openai.com/v1", + anthropic: "https://api.anthropic.com/v1", + mistral: "https://api.mistral.ai/v1", + deepseek: "https://api.deepseek.com" +} as const satisfies Record; diff --git a/web/src/core/usecases/aiProviderFormUiController/index.ts b/web/src/core/usecases/aiProviderFormUiController/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/aiProviderFormUiController/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/aiProviderFormUiController/selectors.ts b/web/src/core/usecases/aiProviderFormUiController/selectors.ts new file mode 100644 index 000000000..23d8f701f --- /dev/null +++ b/web/src/core/usecases/aiProviderFormUiController/selectors.ts @@ -0,0 +1,179 @@ +import { createSelector } from "clean-architecture"; +import type { State as RootState } from "core/bootstrap"; +import * as aiProvidersManagements from "core/usecases/aiProvidersManagements"; +import { + supportedAiProviderTypes, + getSelectedModelIds, + getProviderConnectionState +} from "core/usecases/aiProvidersManagements/decoupledLogic"; +import { name } from "./state"; + +const state = (rootState: RootState) => rootState[name]; + +const main = createSelector( + state, + aiProvidersManagements.selectors.aiProviders, + aiProvidersManagements.protectedSelectors.persistedAiConfig, + (state, aiProviders, persistedAiConfig) => { + if (state.stateDescription === "closed") { + return { isOpen: false as const }; + } + + const { formValues, providerName_current, providerOrigin } = state; + + const isConfiguredByAdmin = providerOrigin === "configured by admin"; + + const aiProvider_current = + providerName_current === undefined + ? undefined + : aiProviders?.find( + aiProvider => aiProvider.name === providerName_current + ); + + const canEditApiKey = + !isConfiguredByAdmin || + (aiProvider_current?.origin === "configured by admin" && + aiProvider_current.authentification.type === "api-key" && + aiProvider_current.authentification.obtentionMethod === "user-provided"); + + const providerName = formValues.name.trim(); + + // What the admin configured isn't editable, hence always valid + const isNameValid = isConfiguredByAdmin + ? true + : providerName !== "" && + !providerName.includes("/") && + // The name is the provider's id, it has to stay unique. + !(aiProviders ?? []).some( + aiProvider => + aiProvider.name === providerName && + aiProvider.name !== providerName_current + ); + + const isApiBaseValid = (() => { + if (isConfiguredByAdmin) { + return true; + } + + let url: URL; + + try { + url = new URL(formValues.apiBase.trim()); + } catch { + return false; + } + + return url.protocol === "http:" || url.protocol === "https:"; + })(); + + // Normalized the way they are when saved + const isConnectionSaved = + aiProvider_current !== undefined && + formValues.providerType === aiProvider_current.providerType && + formValues.apiBase.trim().replace(/\/+$/, "") === + aiProvider_current.apiBase && + formValues.apiKey.trim() === + (persistedAiConfig.apiKeyByProviderName[aiProvider_current.name] ?? ""); + + /** + * The models the user can pick from: the ones listed by the last successful + * test, or else the ones pinned by the admin, which are offered anyway. + */ + const availableModels = + state.connectionTest.stateDescription === "succeeded" + ? state.connectionTest.availableModels + : aiProvider_current?.origin === "configured by admin" && + aiProvider_current.modelIds !== undefined + ? aiProvider_current.modelIds.map(id => ({ id })) + : undefined; + + /** + * When the listed models are the ones of the saved configuration, picking some is + * saved right away, like from the provider card. Otherwise the selection depends + * on unsaved values and is saved along with them. + */ + const selectedModelIds_draft = + availableModels === undefined + ? [] + : getSelectedModelIds({ + availableModels, + excludedModelIds: state.excludedModelIds_draft + }); + + const isModelSelectionSavedImmediately = + isConnectionSaved && + aiProvider_current !== undefined && + aiProvider_current.models.stateDescription === "loaded" && + availableModels !== undefined; + + const hasChanges = (() => { + // Nothing is saved yet, there is nothing to compare with + if (aiProvider_current === undefined) { + return true; + } + + if ( + selectedModelIds_draft.length !== + aiProvider_current.selectedModelIds.length || + aiProvider_current.selectedModelIds.some( + modelId => !selectedModelIds_draft.includes(modelId) + ) + ) { + return true; + } + + // A successful test lists models we didn't have: saving makes them usable + if ( + state.connectionTest.stateDescription === "succeeded" && + aiProvider_current.models.stateDescription !== "loaded" + ) { + return true; + } + + return ( + formValues.name.trim() !== aiProvider_current.name || !isConnectionSaved + ); + })(); + + /** The same as on the card, but for what is on screen, saved or not */ + const connectionState = getProviderConnectionState({ + connection: state.connectionTest.stateDescription + }); + + const canTestConnection = + formValues.providerType !== undefined && + isApiBaseValid && + !state.isSubmitting && + state.connectionTest.stateDescription !== "testing"; + + return { + isOpen: true as const, + providerName_current, + providerOrigin, + isEditing: providerName_current !== undefined, + canEditApiKey, + formValues, + connectionTest: state.connectionTest, + selectedModelIds_draft, + isSubmitting: state.isSubmitting, + hasSubmissionFailed: state.hasSubmissionFailed, + isNameValid, + isApiBaseValid, + canTestConnection, + hasChanges, + connectionState, + availableModels, + isModelSelectionSavedImmediately, + canSubmit: + hasChanges && + isNameValid && + formValues.providerType !== undefined && + isApiBaseValid && + !state.isSubmitting && + state.connectionTest.stateDescription !== "testing", + supportedProviderTypes: supportedAiProviderTypes + }; + } +); + +export const selectors = { main }; diff --git a/web/src/core/usecases/aiProviderFormUiController/state.ts b/web/src/core/usecases/aiProviderFormUiController/state.ts new file mode 100644 index 000000000..84ff22706 --- /dev/null +++ b/web/src/core/usecases/aiProviderFormUiController/state.ts @@ -0,0 +1,156 @@ +import { createUsecaseActions } from "clean-architecture"; +import { id } from "tsafe/id"; +import { assert } from "tsafe/assert"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +import type { AiModel } from "core/tools/fetchAiModels"; + +export const name = "aiProviderFormUiController"; + +export type State = State.Closed | State.Open; + +export declare namespace State { + export type Closed = { stateDescription: "closed" }; + + export type Open = { + stateDescription: "open"; + /** The provider being edited, undefined when one is being created. */ + providerName_current: string | undefined; + /** + * A provider configured by the admin can't be redefined: at most the user can + * type in their own API key, when the provider accepts one. + */ + providerOrigin: "created by user" | "configured by admin"; + formValues: FormValues; + connectionTest: ConnectionTest; + /** + * The models the user unticked before saving. Every other listed model is + * selected, so that the ones a new test reveals are ticked by default. + */ + excludedModelIds_draft: string[]; + isSubmitting: boolean; + hasSubmissionFailed: boolean; + }; + + export type FormValues = { + name: string; + providerType: AiConfig.SupportedAiProviderType | undefined; + apiBase: string; + apiKey: string; + }; + + export type ConnectionTest = + | { stateDescription: "not tested" } + | { stateDescription: "testing" } + | { stateDescription: "failed" } + | { stateDescription: "succeeded"; availableModels: AiModel[] }; +} + +export type ChangeValueParams = + { + key: K; + value: State.FormValues[K]; + }; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id(id({ stateDescription: "closed" })), + reducers: { + opened: ( + _state, + { + payload + }: { + payload: { + providerName_current: string | undefined; + providerOrigin: State.Open["providerOrigin"]; + formValues: State.FormValues; + connectionTest: State.ConnectionTest; + excludedModelIds_draft: string[]; + }; + } + ) => { + const { + providerName_current, + providerOrigin, + formValues, + connectionTest, + excludedModelIds_draft + } = payload; + + return id({ + stateDescription: "open", + providerName_current, + providerOrigin, + formValues, + connectionTest, + excludedModelIds_draft, + isSubmitting: false, + hasSubmissionFailed: false + }); + }, + closed: () => id({ stateDescription: "closed" }), + formValueChanged: (state, { payload }: { payload: ChangeValueParams }) => { + const { key, value } = payload; + + assert(state.stateDescription === "open"); + + if (state.formValues[key] === value) { + return; + } + + Object.assign(state.formValues, { [key]: value }); + + state.hasSubmissionFailed = false; + + if (key === "name") { + //the name do not change the connection test result + return; + } + + state.connectionTest = { stateDescription: "not tested" }; + }, + connectionTestStarted: state => { + assert(state.stateDescription === "open"); + + state.connectionTest = { stateDescription: "testing" }; + }, + connectionTestSucceeded: ( + state, + { payload }: { payload: { availableModels: AiModel[] } } + ) => { + const { availableModels } = payload; + + assert(state.stateDescription === "open"); + + state.connectionTest = { stateDescription: "succeeded", availableModels }; + }, + excludedModelIdsChanged: ( + state, + { payload }: { payload: { excludedModelIds: string[] } } + ) => { + const { excludedModelIds } = payload; + + assert(state.stateDescription === "open"); + + state.excludedModelIds_draft = excludedModelIds; + }, + connectionTestFailed: state => { + assert(state.stateDescription === "open"); + + state.connectionTest = { stateDescription: "failed" }; + }, + submissionStarted: state => { + assert(state.stateDescription === "open"); + + state.isSubmitting = true; + state.hasSubmissionFailed = false; + }, + submissionFailed: state => { + assert(state.stateDescription === "open"); + + state.isSubmitting = false; + state.hasSubmissionFailed = true; + }, + submissionSucceeded: () => id({ stateDescription: "closed" }) + } +}); diff --git a/web/src/core/usecases/aiProviderFormUiController/thunks.ts b/web/src/core/usecases/aiProviderFormUiController/thunks.ts new file mode 100644 index 000000000..f53a855d3 --- /dev/null +++ b/web/src/core/usecases/aiProviderFormUiController/thunks.ts @@ -0,0 +1,432 @@ +import type { Thunks } from "core/bootstrap"; +import { assert } from "tsafe/assert"; +import { same } from "evt/tools/inDepth/same"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +import { fetchAiModels, type AiModel } from "core/tools/fetchAiModels"; +import * as aiProvidersManagements from "core/usecases/aiProvidersManagements"; +import type { AiProviderWithRuntime } from "core/usecases/aiProvidersManagements/decoupledLogic"; +import { providerTypeDefaultApiBase } from "./decoupledLogic/providerTypeDefaultApiBase"; +import { actions, type ChangeValueParams, type State } from "./state"; +import { selectors } from "./selectors"; + +export const thunks = { + /** `providerName` undefined opens the form for a provider to be created. */ + open: + (params: { providerName: string | undefined }) => + (...args): void => { + const { providerName } = params; + + const [dispatch, getState] = args; + + if (providerName === undefined) { + assert( + dispatch(aiProvidersManagements.thunks.canUserCreateProviders()), + "the instance configuration doesn't let the user add providers" + ); + + dispatch( + actions.opened({ + providerName_current: undefined, + providerOrigin: "created by user", + formValues: { + name: "", + providerType: undefined, + apiBase: "", + apiKey: "" + }, + connectionTest: { stateDescription: "not tested" }, + excludedModelIds_draft: [] + }) + ); + + return; + } + + const aiProvider = aiProvidersManagements.selectors + .aiProviders(getState()) + ?.find(aiProvider => aiProvider.name === providerName); + + assert(aiProvider !== undefined); + + const { apiKeyByProviderName, excludedModelIdsByProviderName } = + aiProvidersManagements.protectedSelectors.persistedAiConfig(getState()); + + dispatch( + actions.opened({ + providerName_current: aiProvider.name, + providerOrigin: aiProvider.origin, + formValues: { + name: aiProvider.name, + providerType: aiProvider.providerType, + apiBase: aiProvider.apiBase, + apiKey: apiKeyByProviderName[aiProvider.name] ?? "" + }, + // What we already know from talking to the provider with the saved + // configuration, so that the user doesn't have to test it again. + connectionTest: getConnectionTestFromRuntime({ aiProvider }), + excludedModelIds_draft: + excludedModelIdsByProviderName[aiProvider.name] ?? [] + }) + ); + }, + close: + () => + (...args): void => { + const [dispatch] = args; + + dispatch(actions.closed()); + }, + changeValue: + (params: ChangeValueParams) => + (...args): void => { + const [dispatch, getState] = args; + + const form = selectors.main(getState()); + + if (!form.isOpen || form.isSubmitting) { + return; + } + + assert( + form.providerOrigin === "created by user" || + (params.key === "apiKey" && form.canEditApiKey), + "only the API key of a provider configured by the admin can be changed" + ); + + dispatch(actions.formValueChanged(params)); + }, + /** Picking a type prefills the API base with the one of that provider. */ + changeProviderType: + (params: { providerType: AiConfig.SupportedAiProviderType }) => + (...args): void => { + const { providerType } = params; + + const [dispatch, getState] = args; + + const form = selectors.main(getState()); + + if (!form.isOpen || form.isSubmitting) { + return; + } + + dispatch(thunks.changeValue({ key: "providerType", value: providerType })); + dispatch( + thunks.changeValue({ + key: "apiBase", + value: providerTypeDefaultApiBase[providerType] + }) + ); + + // A provider name is its identifier. Update an automatically suggested + // name when the protocol changes, without replacing a custom name. + if (!form.isEditing) { + const providerNames = new Set( + (aiProvidersManagements.selectors.aiProviders(getState()) ?? []).map( + aiProvider => aiProvider.name + ) + ); + + const nameWasSuggested = + form.formValues.providerType !== undefined && + form.formValues.name === + getAvailableProviderName({ + baseName: + providerTypeDisplayName[form.formValues.providerType], + providerNames + }); + + if (form.formValues.name.trim() !== "" && !nameWasSuggested) { + return; + } + + dispatch( + thunks.changeValue({ + key: "name", + value: getAvailableProviderName({ + baseName: providerTypeDisplayName[providerType], + providerNames + }) + }) + ); + } + }, + changeSelectedModelIds: + (params: { selectedModelIds: string[] }) => + (...args): void => { + const { selectedModelIds } = params; + + const [dispatch, getState] = args; + + const form = selectors.main(getState()); + + if (!form.isOpen || form.isSubmitting) { + return; + } + + const { availableModels } = form; + + assert( + availableModels !== undefined, + "models can only be selected once they are known" + ); + assert( + selectedModelIds.every(modelId => + availableModels.some(availableModel => availableModel.id === modelId) + ), + "a model that the provider doesn't expose can't be selected" + ); + + dispatch( + actions.excludedModelIdsChanged({ + excludedModelIds: availableModels + .map(({ id }) => id) + .filter(modelId => !selectedModelIds.includes(modelId)) + }) + ); + + if (!form.isModelSelectionSavedImmediately) { + return; + } + + const aiProvider = aiProvidersManagements.selectors + .aiProviders(getState()) + ?.find(aiProvider => aiProvider.name === form.providerName_current); + + assert(aiProvider !== undefined); + + const { models } = aiProvider; + + assert(models.stateDescription === "loaded"); + + dispatch( + aiProvidersManagements.thunks.setSelectedModelIds({ + providerName: aiProvider.name, + // The provider may list other models since we tested it + modelIds: selectedModelIds.filter(modelId => + models.availableModels.some( + availableModel => availableModel.id === modelId + ) + ) + }) + ); + }, + testConnection: + () => + async (...args): Promise => { + const [dispatch, getState] = args; + + const form = selectors.main(getState()); + + if (!form.isOpen || !form.canTestConnection) { + return; + } + + const { formValues, providerName_current } = form; + const { providerType } = formValues; + + assert(providerType !== undefined); + + dispatch(actions.connectionTestStarted()); + + const connectionTest = await (async (): Promise< + State.ConnectionTest & { + stateDescription: "succeeded" | "failed"; + } + > => { + // Nothing the user can type in: the provider is tested with what the + // admin configured, which doesn't involve any unsaved value. + if ( + form.providerOrigin === "configured by admin" && + !form.canEditApiKey + ) { + assert(providerName_current !== undefined); + + // Obtains the key when needed (token exchange) and calls the provider, + // even when its models are pinned + await dispatch( + aiProvidersManagements.thunks.refreshProvider({ + providerName: providerName_current + }) + ); + + const aiProvider = aiProvidersManagements.selectors + .aiProviders(getState()) + ?.find(aiProvider => aiProvider.name === providerName_current); + + assert(aiProvider !== undefined); + + const connectionTest = getConnectionTestFromRuntime({ aiProvider }); + + return connectionTest.stateDescription === "succeeded" + ? connectionTest + : { stateDescription: "failed" }; + } + + let availableModels: AiModel[]; + + try { + availableModels = await fetchAiModels({ + protocol: providerType, + apiBase: formValues.apiBase.trim(), + apiKey: formValues.apiKey.trim() || undefined + }); + } catch { + return { stateDescription: "failed" }; + } + + const aiProvider = aiProvidersManagements.selectors + .aiProviders(getState()) + ?.find(aiProvider => aiProvider.name === providerName_current); + + return { + stateDescription: "succeeded", + // The admin may have pinned the list of models + availableModels: + aiProvider?.origin === "configured by admin" && + aiProvider.modelIds !== undefined + ? aiProvider.modelIds.map(id => ({ id })) + : availableModels + }; + })(); + + const form_now = selectors.main(getState()); + + // The user may have kept typing, or closed the form, while we were fetching: + // a result that no longer describes what is on screen must be dropped. + if ( + !form_now.isOpen || + form_now.providerName_current !== providerName_current || + !same(form_now.formValues, formValues) + ) { + return; + } + + dispatch( + connectionTest.stateDescription === "succeeded" + ? actions.connectionTestSucceeded({ + availableModels: connectionTest.availableModels + }) + : actions.connectionTestFailed() + ); + }, + submit: + () => + async (...args): Promise => { + const [dispatch, getState] = args; + + const form = selectors.main(getState()); + + if (!form.isOpen || !form.canSubmit) { + return; + } + + const { formValues, connectionTest, selectedModelIds_draft } = form; + const { providerType } = formValues; + + assert(providerType !== undefined); + + dispatch(actions.submissionStarted()); + + const providerName = formValues.name.trim(); + + const availableModels = + connectionTest.stateDescription === "succeeded" + ? connectionTest.availableModels + : undefined; + + try { + if (form.providerOrigin === "configured by admin") { + // Nothing else of such a provider is saved in the user's config + if (form.canEditApiKey) { + await dispatch( + aiProvidersManagements.thunks.setApiKey({ + providerName, + apiKey: formValues.apiKey, + availableModels + }) + ); + } + } else { + await dispatch( + aiProvidersManagements.thunks.createOrUpdateUserProvider({ + providerName_current: form.providerName_current, + providerName, + providerType, + apiBase: formValues.apiBase.trim().replace(/\/+$/, ""), + apiKey: formValues.apiKey.trim(), + availableModels + }) + ); + } + + // The models can only be selected when they are known. Otherwise the saved + // exclusions are left as they are. + if (form.availableModels !== undefined) { + dispatch( + aiProvidersManagements.thunks.setSelectedModelIds({ + providerName, + modelIds: selectedModelIds_draft + }) + ); + } + } catch { + dispatch(actions.submissionFailed()); + + return; + } + + dispatch(actions.submissionSucceeded()); + } +} satisfies Thunks; + +const providerTypeDisplayName: Record = { + deepseek: "DeepSeek", + openai: "OpenAI", + "openai-compatible": "OpenAI Compatible", + mistral: "Mistral", + anthropic: "Anthropic" +}; + +function getAvailableProviderName(params: { + baseName: string; + providerNames: ReadonlySet; +}): string { + const { baseName, providerNames } = params; + + let suffix = 1; + let providerName = baseName; + + while (providerNames.has(providerName)) { + suffix += 1; + providerName = `${baseName} ${suffix}`; + } + + return providerName; +} + +function getConnectionTestFromRuntime(params: { + aiProvider: AiProviderWithRuntime; +}): State.ConnectionTest { + const { aiProvider } = params; + + if (aiProvider.auth.stateDescription === "error") { + return { stateDescription: "failed" }; + } + + // Whether the provider could be reached, even if its models are pinned + switch (aiProvider.modelsListing.stateDescription) { + case "loaded": + return { + stateDescription: "succeeded", + // The models offered: the ones pinned by the admin, if any + availableModels: + aiProvider.models.stateDescription === "loaded" + ? aiProvider.models.availableModels + : aiProvider.modelsListing.availableModels + }; + case "error": + return { stateDescription: "failed" }; + default: + return { stateDescription: "not tested" }; + } +} diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiContext.test.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiContext.test.ts new file mode 100644 index 000000000..13a3f0f3b --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiContext.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from "vitest"; +import { symToStr } from "tsafe/symToStr"; +import { createAiContext } from "./aiContext"; +import type { AiProviderWithRuntime } from "./aiProviders"; + +function createConfiguredAiProvider( + params: Partial< + Omit, "origin"> + > & { name: string } +): Extract { + return { + origin: "configured by admin", + providerType: "openai-compatible", + apiBase: "https://corporate.example.com/v1", + documentation: undefined, + logoUrl: undefined, + authentification: { type: "none" }, + modelIds: undefined, + auth: { stateDescription: "not required" }, + models: { stateDescription: "loaded", availableModels: [] }, + modelsListing: { stateDescription: "loaded", availableModels: [] }, + selectedModelIds: [], + ...params + }; +} + +function createUserAiProvider( + params: Partial< + Omit, "origin"> + > & { name: string } +): Extract { + return { + origin: "created by user", + providerType: "mistral", + apiBase: "https://api.mistral.ai/v1", + isNameConflicting: false, + auth: { stateDescription: "authenticated", apiKey: "key of my llm" }, + models: { stateDescription: "loaded", availableModels: [] }, + modelsListing: { stateDescription: "loaded", availableModels: [] }, + selectedModelIds: [], + ...params + }; +} + +describe(symToStr({ createAiContext }), () => { + it("exposes the models the user ticked, prefixed by the provider name", () => { + const got = createAiContext({ + aiProviders: [ + createConfiguredAiProvider({ + name: "Corporate", + selectedModelIds: ["gpt-5", "meta-llama/Llama-3"] + }) + ], + defaultModel: undefined + }); + + expect(got).toStrictEqual({ + enabled: true, + models: ["Corporate/gpt-5", "Corporate/meta-llama/Llama-3"], + defaultModel: "Corporate/gpt-5", + providers: [ + { + name: "Corporate", + apiBase: "https://corporate.example.com/v1", + apiKey: undefined, + models: ["gpt-5", "meta-llama/Llama-3"], + type: "openai-compatible" + } + ] + }); + }); + + it("carries the API key of an authenticated provider", () => { + const got = createAiContext({ + aiProviders: [ + createUserAiProvider({ name: "My LLM", selectedModelIds: ["mistral"] }) + ], + defaultModel: undefined + }); + + expect(got.providers).toStrictEqual([ + { + name: "My LLM", + apiBase: "https://api.mistral.ai/v1", + apiKey: "key of my llm", + models: ["mistral"], + type: "mistral" + } + ]); + }); + + it("is disabled when the user ticked nothing", () => { + const got = createAiContext({ + aiProviders: [createConfiguredAiProvider({ name: "Corporate" })], + defaultModel: undefined + }); + + expect(got.enabled).toBe(false); + expect(got.providers).toStrictEqual([]); + }); + + it("leaves out the providers we couldn't authenticate", () => { + const got = createAiContext({ + aiProviders: [ + createConfiguredAiProvider({ + name: "Needs a key", + auth: { stateDescription: "api-key not provided" }, + selectedModelIds: ["gpt-5"] + }), + createConfiguredAiProvider({ + name: "Broken", + auth: { stateDescription: "error" }, + selectedModelIds: ["gpt-5"] + }) + ], + defaultModel: undefined + }); + + expect(got.enabled).toBe(false); + expect(got.models).toStrictEqual([]); + }); + + it("leaves out a user created provider whose name the admin took", () => { + const got = createAiContext({ + aiProviders: [ + createConfiguredAiProvider({ + name: "Corporate", + selectedModelIds: ["gpt-5"] + }), + createUserAiProvider({ + name: "Corporate", + isNameConflicting: true, + selectedModelIds: ["mistral"] + }) + ], + defaultModel: undefined + }); + + expect(got.models).toStrictEqual(["Corporate/gpt-5"]); + }); + + it("flattens the default model", () => { + const got = createAiContext({ + aiProviders: [ + createConfiguredAiProvider({ + name: "Corporate", + selectedModelIds: ["gpt-5"] + }) + ], + defaultModel: { providerName: "Corporate", modelId: "gpt-5" } + }); + + expect(got.defaultModel).toBe("Corporate/gpt-5"); + }); + + it("falls back to the first exposed model when the default one isn't exposed", () => { + const got = createAiContext({ + aiProviders: [ + createConfiguredAiProvider({ + name: "Needs a key", + auth: { stateDescription: "api-key not provided" }, + selectedModelIds: ["gpt-5"] + }), + createConfiguredAiProvider({ + name: "Corporate", + selectedModelIds: ["meta-llama/Llama-3"] + }) + ], + defaultModel: { providerName: "Needs a key", modelId: "gpt-5" } + }); + + expect(got.defaultModel).toBe("Corporate/meta-llama/Llama-3"); + }); + + it("has no default model when nothing is exposed", () => { + const got = createAiContext({ + aiProviders: [ + createConfiguredAiProvider({ + name: "Needs a key", + auth: { stateDescription: "api-key not provided" }, + selectedModelIds: ["gpt-5"] + }) + ], + defaultModel: { providerName: "Needs a key", modelId: "gpt-5" } + }); + + expect(got.defaultModel).toBe(undefined); + }); +}); diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiContext.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiContext.ts new file mode 100644 index 000000000..66a36328e --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiContext.ts @@ -0,0 +1,68 @@ +import type { XOnyxiaContext } from "core/ports/OnyxiaApi"; +import { stringifyModel, type AiProviderWithRuntime } from "./aiProviders"; + +export const emptyAiContext: XOnyxiaContext["ai"] = { + enabled: false, + models: [], + defaultModel: undefined, + providers: [] +}; + +/** Maps the providers onto the shape the launch context (`.ai`) is contracted to have. */ +export function createAiContext(params: { + aiProviders: AiProviderWithRuntime[]; + defaultModel: { providerName: string; modelId: string } | undefined; +}): XOnyxiaContext["ai"] { + const { aiProviders, defaultModel } = params; + + // Only the providers we can actually call, and that the user ticked at least one + // model on, are worth injecting. + const aiProviders_usable = aiProviders.filter(aiProvider => { + if (aiProvider.origin === "created by user" && aiProvider.isNameConflicting) { + return false; + } + + if (aiProvider.selectedModelIds.length === 0) { + return false; + } + + switch (aiProvider.auth.stateDescription) { + case "not required": + case "authenticated": + return true; + default: + return false; + } + }); + + const models = aiProviders_usable + .map(aiProvider => + aiProvider.selectedModelIds.map(modelId => + stringifyModel({ providerName: aiProvider.name, modelId }) + ) + ) + .flat(); + + const defaultModel_str = + defaultModel === undefined ? undefined : stringifyModel(defaultModel); + + return { + enabled: models.length > 0, + models, + // When the selected model can't be injected, the first one that can stands in. + defaultModel: + defaultModel_str !== undefined && models.includes(defaultModel_str) + ? defaultModel_str + : models[0], + providers: aiProviders_usable.map(aiProvider => ({ + name: aiProvider.name, + apiBase: aiProvider.apiBase, + apiKey: + aiProvider.auth.stateDescription === "authenticated" + ? aiProvider.auth.apiKey + : undefined, + models: aiProvider.selectedModelIds, + type: aiProvider.providerType + })) + }; +} diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiProviders.test.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiProviders.test.ts new file mode 100644 index 000000000..ad0f8c58b --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiProviders.test.ts @@ -0,0 +1,386 @@ +import { describe, it, expect } from "vitest"; +import { symToStr } from "tsafe/symToStr"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +import { + createAiProviders, + createAiProvidersWithRuntime, + getDefaultModel, + parseModel, + type AiProviderWithRuntime, + type AiProviderRuntime +} from "./aiProviders"; +import { + createEmptyPersistedAiConfig, + type PersistedAiConfig +} from "./persistedAiConfig"; + +function createAiConfig(providers: AiConfig.Provider[]): AiConfig { + return { + disable: false, + disallowUserToAddProviders: false, + description: undefined, + providers + }; +} + +function createConfiguredProvider( + params: Partial & { name: string } +): AiConfig.Provider { + return { + providerType: "openai-compatible", + apiBase: "https://corporate.example.com/v1", + documentation: undefined, + logoUrl: undefined, + authentification: { type: "none" }, + models: undefined, + ...params + }; +} + +const loadedModels: AiProviderRuntime["models"] = { + stateDescription: "loaded", + availableModels: [{ id: "gpt-5" }, { id: "meta-llama/Llama-3" }] +}; + +function createProviders( + params: Parameters[0] & { + runtimeByProviderName: Record; + } +) { + const { runtimeByProviderName, ...providerParams } = params; + const persistedAiConfig = providerParams.persistedAiConfig; + return createAiProvidersWithRuntime({ + aiProviders: createAiProviders(providerParams), + runtimeByProviderName, + persistedAiConfig + }); +} + +describe(symToStr({ createAiProviders }), () => { + it("needs no fetch when the instance config pins the model list", () => { + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([ + createConfiguredProvider({ name: "Corporate", models: ["gpt-5"] }) + ]), + persistedAiConfig: createEmptyPersistedAiConfig(), + runtimeByProviderName: {} + }); + + expect(aiProvider.models).toStrictEqual({ + stateDescription: "loaded", + availableModels: [{ id: "gpt-5" }] + }); + }); + + it("considers a provider that expects no key as authenticated by definition", () => { + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([createConfiguredProvider({ name: "Corporate" })]), + persistedAiConfig: createEmptyPersistedAiConfig(), + runtimeByProviderName: {} + }); + + expect(aiProvider.auth).toStrictEqual({ stateDescription: "not required" }); + }); + + it("reflects the runtime for a provider that has a key to obtain", () => { + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([ + createConfiguredProvider({ + name: "Corporate", + authentification: { + type: "api-key", + obtentionMethod: "user-provided" + } + }) + ]), + persistedAiConfig: createEmptyPersistedAiConfig(), + runtimeByProviderName: { + Corporate: { + auth: { stateDescription: "authenticated", apiKey: "a key" }, + models: { stateDescription: "not loaded" } + } + } + }); + + expect(aiProvider.auth).toStrictEqual({ + stateDescription: "authenticated", + apiKey: "a key" + }); + }); + + it("authenticates a user created provider with the key it was created with", () => { + const persistedAiConfig: PersistedAiConfig = { + ...createEmptyPersistedAiConfig(), + customProviders: [ + { + name: "My LLM", + providerType: "mistral", + apiBase: "https://api.mistral.ai/v1" + } + ], + apiKeyByProviderName: { "My LLM": "key of my llm" } + }; + + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([]), + persistedAiConfig, + runtimeByProviderName: {} + }); + + expect(aiProvider.auth).toStrictEqual({ + stateDescription: "authenticated", + apiKey: "key of my llm" + }); + }); + + it("expects no authentication from a user created provider without a key", () => { + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([]), + persistedAiConfig: { + ...createEmptyPersistedAiConfig(), + customProviders: [ + { + name: "My LLM", + providerType: "openai-compatible", + apiBase: "https://llm.example.com/v1" + } + ] + }, + runtimeByProviderName: {} + }); + + expect(aiProvider.auth).toStrictEqual({ stateDescription: "not required" }); + }); + + it("selects the exposed models the user didn't exclude", () => { + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([createConfiguredProvider({ name: "Corporate" })]), + persistedAiConfig: { + ...createEmptyPersistedAiConfig(), + excludedModelIdsByProviderName: { + Corporate: ["meta-llama/Llama-3", "a model that is gone"] + } + }, + runtimeByProviderName: { + Corporate: { + auth: { stateDescription: "not loaded" }, + models: loadedModels + } + } + }); + + expect(aiProvider.selectedModelIds).toStrictEqual(["gpt-5"]); + }); + + it("selects every model of a provider the user never filtered", () => { + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([createConfiguredProvider({ name: "Corporate" })]), + persistedAiConfig: createEmptyPersistedAiConfig(), + runtimeByProviderName: { + Corporate: { + auth: { stateDescription: "not loaded" }, + models: loadedModels + } + } + }); + + expect(aiProvider.selectedModelIds).toStrictEqual([ + "gpt-5", + "meta-llama/Llama-3" + ]); + }); + + it("selects the models a provider starts exposing after the user filtered", () => { + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([createConfiguredProvider({ name: "Corporate" })]), + persistedAiConfig: { + ...createEmptyPersistedAiConfig(), + excludedModelIdsByProviderName: { Corporate: ["gpt-5"] } + }, + runtimeByProviderName: { + Corporate: { + auth: { stateDescription: "not loaded" }, + models: { + stateDescription: "loaded", + availableModels: [ + { id: "gpt-5" }, + { id: "meta-llama/Llama-3" }, + { id: "a new model" } + ] + } + } + } + }); + + expect(aiProvider.selectedModelIds).toStrictEqual([ + "meta-llama/Llama-3", + "a new model" + ]); + }); + + it("selects nothing while the models are being fetched", () => { + const [aiProvider] = createProviders({ + aiConfig: createAiConfig([createConfiguredProvider({ name: "Corporate" })]), + persistedAiConfig: createEmptyPersistedAiConfig(), + runtimeByProviderName: { + Corporate: { + auth: { stateDescription: "not loaded" }, + models: { stateDescription: "fetching" } + } + } + }); + + expect(aiProvider.selectedModelIds).toStrictEqual([]); + }); + + it("flags a user created provider whose name the admin later took", () => { + const aiProviders = createProviders({ + aiConfig: createAiConfig([createConfiguredProvider({ name: "Corporate" })]), + persistedAiConfig: { + ...createEmptyPersistedAiConfig(), + customProviders: [ + { + name: "Corporate", + providerType: "openai", + apiBase: "https://api.openai.com/v1" + }, + { + name: "My LLM", + providerType: "openai", + apiBase: "https://api.openai.com/v1" + } + ] + }, + runtimeByProviderName: {} + }); + + const got = aiProviders + .filter(aiProvider => aiProvider.origin === "created by user") + .map(aiProvider => ({ + name: aiProvider.name, + isNameConflicting: aiProvider.isNameConflicting + })); + + expect(got).toStrictEqual([ + { name: "Corporate", isNameConflicting: true }, + { name: "My LLM", isNameConflicting: false } + ]); + }); + + it("lists the admin configured providers before the user created ones", () => { + const aiProviders = createProviders({ + aiConfig: createAiConfig([createConfiguredProvider({ name: "Corporate" })]), + persistedAiConfig: { + ...createEmptyPersistedAiConfig(), + customProviders: [ + { + name: "My LLM", + providerType: "openai", + apiBase: "https://api.openai.com/v1" + } + ] + }, + runtimeByProviderName: {} + }); + + expect(aiProviders.map(aiProvider => aiProvider.origin)).toStrictEqual([ + "configured by admin", + "created by user" + ]); + }); +}); + +describe(symToStr({ getDefaultModel }), () => { + const aiProviders: AiProviderWithRuntime[] = [ + { + origin: "configured by admin", + name: "Corporate", + providerType: "openai-compatible", + apiBase: "https://corporate.example.com/v1", + documentation: undefined, + logoUrl: undefined, + authentification: { type: "none" }, + modelIds: undefined, + auth: { stateDescription: "not required" }, + models: loadedModels, + modelsListing: loadedModels, + selectedModelIds: ["gpt-5"] + }, + { + origin: "created by user", + name: "My LLM", + providerType: "openai", + apiBase: "https://api.openai.com/v1", + isNameConflicting: false, + auth: { stateDescription: "not required" }, + models: loadedModels, + modelsListing: loadedModels, + selectedModelIds: ["meta-llama/Llama-3"] + } + ]; + + it("falls back to the first selected model when the user elected none", () => { + expect( + getDefaultModel({ aiProviders, defaultModel_persisted: null }) + ).toStrictEqual({ providerName: "Corporate", modelId: "gpt-5" }); + }); + + it("returns undefined when no model is selected at all", () => { + expect( + getDefaultModel({ + aiProviders: aiProviders.map(aiProvider => ({ + ...aiProvider, + selectedModelIds: [] + })), + defaultModel_persisted: null + }) + ).toBe(undefined); + }); + + it("returns the elected model when it is still selected", () => { + const defaultModel_persisted = { providerName: "Corporate", modelId: "gpt-5" }; + + expect(getDefaultModel({ aiProviders, defaultModel_persisted })).toStrictEqual( + defaultModel_persisted + ); + }); + + it("falls back to the first selected model when the elected one no longer is", () => { + expect( + getDefaultModel({ + aiProviders, + defaultModel_persisted: { + providerName: "Corporate", + modelId: "meta-llama/Llama-3" + } + }) + ).toStrictEqual({ providerName: "Corporate", modelId: "gpt-5" }); + }); + + it("falls back to the first selected model when its provider is gone", () => { + expect( + getDefaultModel({ + aiProviders, + defaultModel_persisted: { providerName: "Gone", modelId: "gpt-5" } + }) + ).toStrictEqual({ providerName: "Corporate", modelId: "gpt-5" }); + }); +}); + +describe(symToStr({ parseModel }), () => { + it("splits at the first separator, a model id may contain one", () => { + expect(parseModel({ model: "Corporate/meta-llama/Llama-3" })).toStrictEqual({ + providerName: "Corporate", + modelId: "meta-llama/Llama-3" + }); + }); + + it("returns undefined when there is no separator", () => { + expect(parseModel({ model: "Corporate" })).toBe(undefined); + }); + + it("returns undefined when either side is empty", () => { + expect(parseModel({ model: "/gpt-5" })).toBe(undefined); + expect(parseModel({ model: "Corporate/" })).toBe(undefined); + }); +}); diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiProviders.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiProviders.ts new file mode 100644 index 000000000..eb179dea3 --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/aiProviders.ts @@ -0,0 +1,292 @@ +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +import type { AiModel } from "core/tools/fetchAiModels"; +import type { PersistedAiConfig } from "./persistedAiConfig"; + +/** Omit applied to each member of a union, so that it stays discriminated. */ +type DistributiveOmit = T extends unknown ? Omit : never; + +export type AiProvider = AiProvider.ConfiguredByAdmin | AiProvider.CreatedByUser; + +export namespace AiProvider { + export type Common = { + /** Acts as the provider id, unique across all providers. */ + name: string; + providerType: AiConfig.SupportedAiProviderType; + apiBase: string; + /** + * What the user unticked in the models multi select. Every other model the + * provider exposes is selected, including the ones it starts exposing later. + */ + excludedModelIds: string[]; + }; + + /** Provisioned by the instance configuration (the `AI` env). */ + export type ConfiguredByAdmin = Common & { + origin: "configured by admin"; + documentation: AiConfig.Documentation | undefined; + logoUrl: AiConfig.LogoUrl | undefined; + authentification: AiConfig.Provider["authentification"]; + /** Model ids pinned by the instance configuration, when provided. */ + modelIds: string[] | undefined; + }; + + /** Added by the user from the account tab, stored in their user configs. */ + export type CreatedByUser = Common & { + origin: "created by user"; + /** + * True when the instance configuration was given the same name after the fact. + * Such a provider is left visible so that the user can rename or delete it, but + * it is kept out of the launch context where names must be unique. + */ + isNameConflicting: boolean; + }; +} + +/** Everything about a provider that can only be known by talking to it. */ +export type AiProviderRuntime = { + auth: AiProviderRuntime.Auth; + models: AiProviderRuntime.Models; +}; + +export namespace AiProviderRuntime { + export type Auth = + | { stateDescription: "not required" } + | { stateDescription: "not loaded" } + | { stateDescription: "fetching" } + /** The user has to provide an API key before we can call it. */ + | { stateDescription: "api-key not provided" } + | { stateDescription: "error" } + | { stateDescription: "authenticated"; apiKey: string }; + + export type Models = + | { stateDescription: "not loaded" } + | { stateDescription: "fetching" } + | { stateDescription: "error" } + | { stateDescription: "loaded"; availableModels: AiModel[] }; +} + +/** The representation consumed by the UI and the launch context. */ +export type AiProviderWithRuntime = DistributiveOmit & { + /** + * The available models the user didn't exclude. Empty as long as the models + * aren't known. + */ + selectedModelIds: string[]; + auth: AiProviderRuntime.Auth; + /** The models offered to the user: the ones pinned by the admin, if any. */ + models: AiProviderRuntime.Models; + /** + * The outcome of asking the provider for its models, even when the admin pinned + * them: it tells whether the provider can be reached. + */ + modelsListing: AiProviderRuntime.Models; +}; + +export function createInitialAiProviderRuntime(): AiProviderRuntime { + return { + auth: { stateDescription: "not loaded" }, + models: { stateDescription: "not loaded" } + }; +} + +export function createAiProviders(params: { + aiConfig: AiConfig; + persistedAiConfig: PersistedAiConfig; +}): AiProvider[] { + const { aiConfig, persistedAiConfig } = params; + + const aiProviders_configuredByAdmin = getConfiguredProviders({ aiConfig }).map( + (provider_config): AiProvider.ConfiguredByAdmin => { + return { + origin: "configured by admin", + name: provider_config.name, + providerType: provider_config.providerType, + apiBase: provider_config.apiBase, + documentation: provider_config.documentation, + logoUrl: provider_config.logoUrl, + authentification: provider_config.authentification, + modelIds: provider_config.models, + excludedModelIds: + persistedAiConfig.excludedModelIdsByProviderName[ + provider_config.name + ] ?? [] + }; + } + ); + + const providerNames_configuredByAdmin = new Set( + aiProviders_configuredByAdmin.map(aiProvider => aiProvider.name) + ); + + const aiProviders_createdByUser = persistedAiConfig.customProviders.map( + (customProvider): AiProvider.CreatedByUser => { + return { + origin: "created by user", + name: customProvider.name, + providerType: customProvider.providerType, + apiBase: customProvider.apiBase, + isNameConflicting: providerNames_configuredByAdmin.has( + customProvider.name + ), + excludedModelIds: + persistedAiConfig.excludedModelIdsByProviderName[ + customProvider.name + ] ?? [] + }; + } + ); + + return [...aiProviders_configuredByAdmin, ...aiProviders_createdByUser]; +} + +/** Combines persisted provider definitions with their volatile execution state. */ +export function createAiProvidersWithRuntime(params: { + aiProviders: AiProvider[]; + runtimeByProviderName: Record; + persistedAiConfig: PersistedAiConfig; +}): AiProviderWithRuntime[] { + const { aiProviders, runtimeByProviderName, persistedAiConfig } = params; + + return aiProviders.map(aiProvider => { + const runtime = + runtimeByProviderName[aiProvider.name] ?? createInitialAiProviderRuntime(); + // The models pinned by the admin are offered whether or not the provider can be + // reached from the browser: the services may reach it when we can't. + const models = + aiProvider.origin === "configured by admin" && + aiProvider.modelIds !== undefined + ? { + stateDescription: "loaded" as const, + availableModels: aiProvider.modelIds.map(id => ({ id })) + } + : runtime.models; + const auth = (() => { + if (aiProvider.origin === "configured by admin") { + return aiProvider.authentification.type === "none" + ? ({ stateDescription: "not required" } as const) + : runtime.auth; + } + + const apiKey = persistedAiConfig.apiKeyByProviderName[aiProvider.name]; + return apiKey === undefined || apiKey === "" + ? ({ stateDescription: "not required" } as const) + : ({ stateDescription: "authenticated", apiKey } as const); + })(); + + const { excludedModelIds, ...rest } = aiProvider; + + return { + ...rest, + auth, + models, + modelsListing: runtime.models, + selectedModelIds: + models.stateDescription === "loaded" + ? getSelectedModelIds({ + availableModels: models.availableModels, + excludedModelIds + }) + : [] + }; + }); +} + +export function getSelectedModelIds(params: { + availableModels: AiModel[]; + excludedModelIds: string[]; +}): string[] { + const { availableModels, excludedModelIds } = params; + + return availableModels + .map(({ id }) => id) + .filter(modelId => !excludedModelIds.includes(modelId)); +} + +/** Inverse of `getSelectedModelIds`, for the models listed by the provider. */ +export function getExcludedModelIds(params: { + availableModels: AiModel[]; + selectedModelIds: string[]; +}): string[] { + const { availableModels, selectedModelIds } = params; + + return availableModels + .map(({ id }) => id) + .filter(modelId => !selectedModelIds.includes(modelId)); +} + +/** The instance config accepts a single provider as well as an array of them. */ +function getConfiguredProviders(params: { aiConfig: AiConfig }): AiConfig.Provider[] { + const { aiConfig } = params; + + return Array.isArray(aiConfig.providers) ? aiConfig.providers : [aiConfig.providers]; +} + +/** + * The default model is picked among the models the user ticked, so a selection change + * can invalidate it. Rather than trying to keep the persisted value in sync on every + * mutation, we validate it on read. When the user elected none, or when what they + * elected is no longer selected, the first selected model stands in for it. + */ +export function getDefaultModel(params: { + aiProviders: AiProviderWithRuntime[]; + defaultModel_persisted: PersistedAiConfig["defaultModel"]; +}): { providerName: string; modelId: string } | undefined { + const { aiProviders, defaultModel_persisted } = params; + + if ( + defaultModel_persisted !== null && + aiProviders.some( + aiProvider => + aiProvider.name === defaultModel_persisted.providerName && + aiProvider.selectedModelIds.includes(defaultModel_persisted.modelId) + ) + ) { + return defaultModel_persisted; + } + + const aiProvider_first = aiProviders.find( + aiProvider => aiProvider.selectedModelIds.length !== 0 + ); + + if (aiProvider_first === undefined) { + return undefined; + } + + return { + providerName: aiProvider_first.name, + modelId: aiProvider_first.selectedModelIds[0] + }; +} + +/** `/`, the form the launch context and the UI selects use. */ +export function stringifyModel(params: { + providerName: string; + modelId: string; +}): string { + const { providerName, modelId } = params; + + return `${providerName}/${modelId}`; +} + +/** Inverse of `stringifyModel`. */ +export function parseModel(params: { + model: string; +}): { providerName: string; modelId: string } | undefined { + const { model } = params; + + // A provider name can't contain a "/" but a model id can, so the first one separates. + const index = model.indexOf("/"); + + if (index === -1) { + return undefined; + } + + const providerName = model.slice(0, index); + const modelId = model.slice(index + 1); + + if (providerName === "" || modelId === "") { + return undefined; + } + + return { providerName, modelId }; +} diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/index.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/index.ts new file mode 100644 index 000000000..34d379ebb --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/index.ts @@ -0,0 +1,12 @@ +export { + stringifyModel, + parseModel, + getSelectedModelIds, + type AiProviderWithRuntime +} from "./aiProviders"; +export { emptyAiContext } from "./aiContext"; +export { + getProviderConnectionState, + type ProviderConnectionState +} from "./providerConnectionState"; +export { supportedAiProviderTypes } from "./supportedAiProviderTypes"; diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/persistedAiConfig.test.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/persistedAiConfig.test.ts new file mode 100644 index 000000000..3f3ecf055 --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/persistedAiConfig.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import { symToStr } from "tsafe/symToStr"; +import { + parseAiConfigStr, + removeProviderFromPersistedAiConfig, + renameProviderInPersistedAiConfig, + serializeAiConfig, + type PersistedAiConfig +} from "./persistedAiConfig"; + +const aiConfig: PersistedAiConfig = { + customProviders: [ + { + name: "My LLM", + providerType: "openai-compatible", + apiBase: "https://llm.example.com/v1" + } + ], + apiKeyByProviderName: { "My LLM": "key of my llm", Corporate: "key of corporate" }, + excludedModelIdsByProviderName: { + "My LLM": ["mistral-small", "mistral-large"], + Corporate: ["gpt-5"] + }, + defaultModel: { providerName: "My LLM", modelId: "mistral-small" } +}; + +describe(symToStr({ parseAiConfigStr }), () => { + it("round trips through serialization", () => { + const got = parseAiConfigStr({ + aiConfigStr: serializeAiConfig({ aiConfig }) + }); + + expect(got).toStrictEqual(aiConfig); + }); + + it("returns undefined when nothing was ever saved", () => { + expect(parseAiConfigStr({ aiConfigStr: null })).toBe(undefined); + }); + + it("returns undefined instead of throwing when what is stored is not JSON", () => { + expect(parseAiConfigStr({ aiConfigStr: "{ not json" })).toBe(undefined); + }); + + it("returns undefined when what is stored has the wrong shape", () => { + expect( + parseAiConfigStr({ + aiConfigStr: JSON.stringify({ ...aiConfig, customProviders: "nope" }) + }) + ).toBe(undefined); + }); + + it("returns undefined when a provider type we don't support is stored", () => { + expect( + parseAiConfigStr({ + aiConfigStr: JSON.stringify({ + ...aiConfig, + customProviders: [ + { + name: "My LLM", + providerType: "not-a-provider-type", + apiBase: "https://llm.example.com/v1" + } + ] + }) + }) + ).toBe(undefined); + }); +}); + +describe(symToStr({ renameProviderInPersistedAiConfig }), () => { + it("moves every entry that refers to the provider, and only those", () => { + const got = renameProviderInPersistedAiConfig({ + aiConfig, + providerName_current: "My LLM", + providerName_new: "My renamed LLM" + }); + + const expected: PersistedAiConfig = { + customProviders: [ + { + name: "My renamed LLM", + providerType: "openai-compatible", + apiBase: "https://llm.example.com/v1" + } + ], + apiKeyByProviderName: { + "My renamed LLM": "key of my llm", + Corporate: "key of corporate" + }, + excludedModelIdsByProviderName: { + "My renamed LLM": ["mistral-small", "mistral-large"], + Corporate: ["gpt-5"] + }, + defaultModel: { providerName: "My renamed LLM", modelId: "mistral-small" } + }; + + expect(got).toStrictEqual(expected); + }); + + it("is a no op when the name doesn't change", () => { + const got = renameProviderInPersistedAiConfig({ + aiConfig, + providerName_current: "My LLM", + providerName_new: "My LLM" + }); + + expect(got).toStrictEqual(aiConfig); + }); + + it("leaves the default model alone when it belongs to another provider", () => { + const got = renameProviderInPersistedAiConfig({ + aiConfig, + providerName_current: "Corporate", + providerName_new: "Corporate LLM" + }); + + expect(got.defaultModel).toStrictEqual({ + providerName: "My LLM", + modelId: "mistral-small" + }); + }); +}); + +describe(symToStr({ removeProviderFromPersistedAiConfig }), () => { + it("leaves no orphan entry behind", () => { + const got = removeProviderFromPersistedAiConfig({ + aiConfig, + providerName: "My LLM" + }); + + const expected: PersistedAiConfig = { + customProviders: [], + apiKeyByProviderName: { Corporate: "key of corporate" }, + excludedModelIdsByProviderName: { Corporate: ["gpt-5"] }, + defaultModel: null + }; + + expect(got).toStrictEqual(expected); + }); + + it("keeps the default model when it belongs to another provider", () => { + const got = removeProviderFromPersistedAiConfig({ + aiConfig, + providerName: "Corporate" + }); + + expect(got.defaultModel).toStrictEqual({ + providerName: "My LLM", + modelId: "mistral-small" + }); + }); +}); diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/persistedAiConfig.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/persistedAiConfig.ts new file mode 100644 index 000000000..96da6c0a2 --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/persistedAiConfig.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; +import { assert, type Equals, id } from "tsafe"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +import { supportedAiProviderTypes } from "./supportedAiProviderTypes"; + +/** + * The user's own AI configuration, serialized into the single `aiConfigStr` user config + * entry (persisted in the secret manager). + * + * + * Providers are keyed by name, names are unique across admin configured providers and + * user created ones alike. + */ +export type PersistedAiConfig = { + customProviders: PersistedAiConfig.CustomProvider[]; + /** + * API keys typed in by the user. Holds the keys of the user created providers as + * well as the keys of the admin configured providers that expect the user to bring + * their own. + */ + apiKeyByProviderName: Record; + /** + * Model ids the user unticked in the multi select of each provider. We keep what + * was filtered out rather than what was kept so that the models a provider starts + * exposing afterwards are selected by default. + */ + excludedModelIdsByProviderName: Record; + /** null, and not undefined, so that it round trips through JSON. */ + defaultModel: { providerName: string; modelId: string } | null; +}; + +export namespace PersistedAiConfig { + export type CustomProvider = { + name: string; + providerType: AiConfig.SupportedAiProviderType; + apiBase: string; + }; +} + +const zPersistedAiConfig = (() => { + type TargetType = PersistedAiConfig; + + const zTargetType = z.object({ + customProviders: z.array( + z.object({ + name: z.string().min(1), + providerType: z.enum(supportedAiProviderTypes), + apiBase: z.string().min(1) + }) + ), + apiKeyByProviderName: z.record(z.string(), z.string()), + excludedModelIdsByProviderName: z.record(z.string(), z.array(z.string())), + defaultModel: z + .object({ providerName: z.string(), modelId: z.string() }) + .nullable() + }); + + type InferredType = z.infer; + + assert>(); + + return id>(zTargetType); +})(); + +export function createEmptyPersistedAiConfig(): PersistedAiConfig { + return { + customProviders: [], + apiKeyByProviderName: {}, + excludedModelIdsByProviderName: {}, + defaultModel: null + }; +} + +/** + * Returns undefined when there is nothing usable to restore, either because the user + * never saved anything or because what is stored can't be read back. The caller is + * expected to tell the user about the latter instead of silently starting over. + */ +export function parseAiConfigStr(params: { + aiConfigStr: string | null; +}): PersistedAiConfig | undefined { + const { aiConfigStr } = params; + + if (aiConfigStr === null) { + return undefined; + } + + let parsedValue: unknown; + + try { + parsedValue = JSON.parse(aiConfigStr); + } catch { + return undefined; + } + + // A config we can't read back is a recoverable condition, not an exception: the tab + // must still open, so we don't let the ZodError escape. + const result = zPersistedAiConfig.safeParse(parsedValue); + + if (!result.success) { + return undefined; + } + + return result.data; +} + +export function serializeAiConfig(params: { aiConfig: PersistedAiConfig }): string { + const { aiConfig } = params; + + return JSON.stringify(aiConfig); +} + +/** + * Providers are keyed by name, so renaming one means moving every entry that refers to + * it. Kept here, as a single pure function, so that the thunks never have to reach into + * the shape of the persisted config. + */ +export function renameProviderInPersistedAiConfig(params: { + aiConfig: PersistedAiConfig; + providerName_current: string; + providerName_new: string; +}): PersistedAiConfig { + const { aiConfig, providerName_current, providerName_new } = params; + + if (providerName_current === providerName_new) { + return aiConfig; + } + + const renameKey = (record: Record): Record => { + const { [providerName_current]: value, ...rest } = record; + + return value === undefined ? rest : { ...rest, [providerName_new]: value }; + }; + + return { + customProviders: aiConfig.customProviders.map(customProvider => + customProvider.name === providerName_current + ? { ...customProvider, name: providerName_new } + : customProvider + ), + apiKeyByProviderName: renameKey(aiConfig.apiKeyByProviderName), + excludedModelIdsByProviderName: renameKey( + aiConfig.excludedModelIdsByProviderName + ), + defaultModel: + aiConfig.defaultModel?.providerName === providerName_current + ? { ...aiConfig.defaultModel, providerName: providerName_new } + : aiConfig.defaultModel + }; +} + +/** Symmetrical to the rename: dropping a provider must not leave orphan entries. */ +export function removeProviderFromPersistedAiConfig(params: { + aiConfig: PersistedAiConfig; + providerName: string; +}): PersistedAiConfig { + const { aiConfig, providerName } = params; + + const removeKey = (record: Record): Record => { + const { [providerName]: _removed, ...rest } = record; + + return rest; + }; + + return { + customProviders: aiConfig.customProviders.filter( + customProvider => customProvider.name !== providerName + ), + apiKeyByProviderName: removeKey(aiConfig.apiKeyByProviderName), + excludedModelIdsByProviderName: removeKey( + aiConfig.excludedModelIdsByProviderName + ), + defaultModel: + aiConfig.defaultModel?.providerName === providerName + ? null + : aiConfig.defaultModel + }; +} diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/providerConnectionState.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/providerConnectionState.ts new file mode 100644 index 000000000..6909888c5 --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/providerConnectionState.ts @@ -0,0 +1,18 @@ +/** What the user is told about a provider, the same on its card and in its dialog. */ +export type ProviderConnectionState = "connected" | "setup required" | "connection error"; + +export function getProviderConnectionState(params: { + connection: "not tested" | "testing" | "failed" | "succeeded"; +}): ProviderConnectionState { + const { connection } = params; + + switch (connection) { + case "failed": + return "connection error"; + case "succeeded": + return "connected"; + case "not tested": + case "testing": + return "setup required"; + } +} diff --git a/web/src/core/usecases/aiProvidersManagements/decoupledLogic/supportedAiProviderTypes.ts b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/supportedAiProviderTypes.ts new file mode 100644 index 000000000..fc94c4554 --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/decoupledLogic/supportedAiProviderTypes.ts @@ -0,0 +1,16 @@ +import { assert, type Equals } from "tsafe"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; + +export const supportedAiProviderTypes = [ + "openai-compatible", + "openai", + "anthropic", + "mistral", + "deepseek" +] as const; + +// The instance config is the source of truth, this array only mirrors it so that the +// value can be enumerated (zod schema, provider creation form). +assert< + Equals<(typeof supportedAiProviderTypes)[number], AiConfig.SupportedAiProviderType> +>(); diff --git a/web/src/core/usecases/aiProvidersManagements/index.ts b/web/src/core/usecases/aiProvidersManagements/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/aiProvidersManagements/selectors.ts b/web/src/core/usecases/aiProvidersManagements/selectors.ts new file mode 100644 index 000000000..90f18dd30 --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/selectors.ts @@ -0,0 +1,123 @@ +import { createSelector } from "clean-architecture"; +import type { State as RootState } from "core/bootstrap"; +import { getRootContext } from "core/rootContext"; +import * as userConfigs from "core/usecases/userConfigs"; +import { name } from "./state"; +import { + createAiProviders, + createAiProvidersWithRuntime, + getDefaultModel, + type AiProvider, + type AiProviderWithRuntime +} from "./decoupledLogic/aiProviders"; +import { + createEmptyPersistedAiConfig, + parseAiConfigStr +} from "./decoupledLogic/persistedAiConfig"; +import { createAiContext, emptyAiContext } from "./decoupledLogic/aiContext"; + +const state = (rootState: RootState) => rootState[name]; + +const stateDescription = createSelector(state, state => state.stateDescription); + +/** undefined unless the use case failed to load. */ +const errorReason = createSelector(state, state => + state.stateDescription === "error" ? state.reason : undefined +); + +/** + * Unsaved edits take precedence over userConfigs, including while userConfigs performs + * its own optimistic update or rolls back a failed write. + */ +const persistedAiConfig = createSelector( + (rootState: RootState) => { + const { oidc } = getRootContext(); + + if (!oidc.isUserLoggedIn) { + return undefined; + } + + return userConfigs.selectors.userConfigs(rootState).aiConfigStr; + }, + state, + (aiConfigStr, state) => + (state.stateDescription === "ready" ? state.unsavedConfig : undefined) ?? + (aiConfigStr === undefined ? undefined : parseAiConfigStr({ aiConfigStr })) ?? + createEmptyPersistedAiConfig() +); + +/** undefined until the use case has been loaded. */ +const aiProviders_withoutRuntime = createSelector( + persistedAiConfig, + stateDescription, + (persistedAiConfig, stateDescription): AiProvider[] | undefined => + stateDescription !== "ready" + ? undefined + : createAiProviders({ + aiConfig: getRootContext().aiConfig, + persistedAiConfig + }) +); + +const aiProviderRuntimes = createSelector(state, state => + state.stateDescription === "ready" ? state.runtimeByProviderName : undefined +); + +/** Providers enriched at read time with their volatile runtime state. */ +const aiProviders = createSelector( + aiProviders_withoutRuntime, + aiProviderRuntimes, + persistedAiConfig, + ( + aiProviders, + runtimeByProviderName, + persistedAiConfig + ): AiProviderWithRuntime[] | undefined => + aiProviders === undefined || runtimeByProviderName === undefined + ? undefined + : createAiProvidersWithRuntime({ + aiProviders, + runtimeByProviderName, + persistedAiConfig + }) +); + +/** + * The model the user elected among everything they ticked, undefined when they elected + * none or when what they had elected is no longer selected. + */ +const defaultModel = createSelector( + aiProviders, + persistedAiConfig, + (aiProviders, persistedAiConfig) => + aiProviders === undefined + ? undefined + : getDefaultModel({ + aiProviders, + defaultModel_persisted: persistedAiConfig.defaultModel + }) +); + +const aiContext = createSelector( + aiProviders, + defaultModel, + (aiProviders, defaultModel) => + aiProviders === undefined + ? emptyAiContext + : createAiContext({ aiProviders, defaultModel }) +); + +export const selectors = { + stateDescription, + errorReason, + configSaveState: createSelector(state, state => + state.stateDescription === "ready" ? state.configSaveState : "idle" + ), + aiProviders, + defaultModel +}; + +export const protectedSelectors = { + aiContext, + persistedAiConfig +}; diff --git a/web/src/core/usecases/aiProvidersManagements/state.ts b/web/src/core/usecases/aiProvidersManagements/state.ts new file mode 100644 index 000000000..f5ded111a --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/state.ts @@ -0,0 +1,140 @@ +import { createUsecaseActions } from "clean-architecture"; +import { id } from "tsafe/id"; +import { assert } from "tsafe/assert"; +import type { AiModel } from "core/tools/fetchAiModels"; +import { + createInitialAiProviderRuntime, + type AiProviderRuntime +} from "./decoupledLogic/aiProviders"; +import type { PersistedAiConfig } from "./decoupledLogic/persistedAiConfig"; + +export const name = "aiProvidersManagements"; + +/** Providers are derived from configuration and runtime data. Unsaved edits stay in + * memory until the background writer has persisted the latest configuration. */ +export type State = State.NotLoaded | State.Loading | State.Error | State.Ready; + +export declare namespace State { + export type NotLoaded = { stateDescription: "not loaded" }; + + export type Loading = { stateDescription: "loading" }; + + export type Error = { + stateDescription: "error"; + /** + * "unreadable config": the user's persisted config could not be read back, it + * has to be reset before anything can be shown. + */ + reason: "unreadable config" | "loading failed"; + }; + + export type Ready = { + stateDescription: "ready"; + /** Keyed by provider name, absent until a provider has been talked to. */ + runtimeByProviderName: Record; + unsavedConfig: PersistedAiConfig | undefined; + configSaveState: "idle" | "pending" | "error"; + }; +} + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id(id({ stateDescription: "not loaded" })), + reducers: { + configChanged: (state, { payload }: { payload: PersistedAiConfig }) => { + assert(state.stateDescription === "ready"); + state.unsavedConfig = payload; + state.configSaveState = "pending"; + }, + configSaveStarted: state => { + assert(state.stateDescription === "ready"); + state.configSaveState = "pending"; + }, + configSaved: (state, { payload }: { payload: PersistedAiConfig }) => { + assert(state.stateDescription === "ready"); + if (JSON.stringify(state.unsavedConfig) !== JSON.stringify(payload)) return; + state.unsavedConfig = undefined; + state.configSaveState = "idle"; + }, + configSaveFailed: state => { + assert(state.stateDescription === "ready"); + state.configSaveState = "error"; + }, + loadingStarted: () => id({ stateDescription: "loading" }), + loadingFailed: ( + _state, + { payload }: { payload: { reason: State.Error["reason"] } } + ) => id({ stateDescription: "error", reason: payload.reason }), + loaded: () => + id({ + stateDescription: "ready", + runtimeByProviderName: {}, + unsavedConfig: undefined, + configSaveState: "idle" + }), + providerAuthChanged: ( + state, + { + payload + }: { payload: { providerName: string; auth: AiProviderRuntime["auth"] } } + ) => { + const { providerName, auth } = payload; + + assert(state.stateDescription === "ready"); + + getOrCreateRuntime({ state, providerName }).auth = auth; + }, + providerModelsChanged: ( + state, + { + payload + }: { payload: { providerName: string; models: AiProviderRuntime["models"] } } + ) => { + const { providerName, models } = payload; + + assert(state.stateDescription === "ready"); + + getOrCreateRuntime({ state, providerName }).models = models; + }, + userProviderCreated: ( + state, + { + payload + }: { + payload: { providerName: string; availableModels: AiModel[] | undefined }; + } + ) => { + const { providerName, availableModels } = payload; + + assert(state.stateDescription === "ready"); + + state.runtimeByProviderName[providerName] = { + auth: { stateDescription: "not loaded" }, + models: + availableModels === undefined + ? { stateDescription: "not loaded" } + : { stateDescription: "loaded", availableModels } + }; + }, + userProviderDeleted: ( + state, + { payload }: { payload: { providerName: string } } + ) => { + const { providerName } = payload; + + assert(state.stateDescription === "ready"); + + delete state.runtimeByProviderName[providerName]; + } + } +}); + +function getOrCreateRuntime(params: { + state: State.Ready; + providerName: string; +}): AiProviderRuntime { + const { state, providerName } = params; + + return (state.runtimeByProviderName[providerName] ??= + createInitialAiProviderRuntime()); +} diff --git a/web/src/core/usecases/aiProvidersManagements/thunks.ts b/web/src/core/usecases/aiProvidersManagements/thunks.ts new file mode 100644 index 000000000..3c61058a3 --- /dev/null +++ b/web/src/core/usecases/aiProvidersManagements/thunks.ts @@ -0,0 +1,756 @@ +import type { Thunks } from "core/bootstrap"; +import { assert } from "tsafe/assert"; +import { id } from "tsafe/id"; +import { Mutex } from "async-mutex"; +import type { XOnyxiaContext } from "core/ports/OnyxiaApi"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +import { fetchAiModels, type AiModel } from "core/tools/fetchAiModels"; +import { exchangeOpenWebUiToken } from "core/tools/exchangeOpenWebUiToken"; +import * as userConfigs from "core/usecases/userConfigs"; +import { actions, name } from "./state"; +import { protectedSelectors, selectors } from "./selectors"; +import { + type AiProviderRuntime, + type AiProviderWithRuntime, + getExcludedModelIds +} from "./decoupledLogic/aiProviders"; +import { emptyAiContext } from "./decoupledLogic/aiContext"; +import { + createEmptyPersistedAiConfig, + parseAiConfigStr, + removeProviderFromPersistedAiConfig, + renameProviderInPersistedAiConfig, + serializeAiConfig, + type PersistedAiConfig +} from "./decoupledLogic/persistedAiConfig"; + +export const thunks = { + saveConfig: + () => + async (...[dispatch, getState]): Promise => { + return globalContext.mutex.runExclusive(async () => { + while (true) { + const state = getState()[name]; + if ( + state.stateDescription !== "ready" || + state.unsavedConfig === undefined + ) + return true; + const aiConfig = state.unsavedConfig; + dispatch(actions.configSaveStarted()); + try { + await dispatch( + userConfigs.thunks.changeValue({ + key: "aiConfigStr", + value: serializeAiConfig({ aiConfig }) + }) + ); + } catch { + dispatch(actions.configSaveFailed()); + return false; + } + dispatch(actions.configSaved(aiConfig)); + } + }); + }, + isAvailable: + () => + (...args): boolean => { + const [, , { aiConfig, oidc }] = args; + + return !aiConfig.disable && oidc.isUserLoggedIn; + }, + canUserCreateProviders: + () => + (...args): boolean => { + const [, , { aiConfig }] = args; + + return !aiConfig.disallowUserToAddProviders; + }, + /** + * Idempotent, and safe to call concurrently: both the account tab and the launcher + * ask for it, whoever comes second waits for the first instead of starting over. + */ + load: + () => + async (...args): Promise => { + const [dispatch, getState] = args; + + if (!dispatch(thunks.isAvailable())) { + return; + } + + if (globalContext.prLoad !== undefined) { + return globalContext.prLoad; + } + + if (selectors.stateDescription(getState()) === "ready") { + return; + } + + const prLoad = (async () => { + dispatch(actions.loadingStarted()); + + { + const { aiConfigStr } = userConfigs.selectors.userConfigs(getState()); + + // Starting over from an empty config would overwrite what is stored + // on the first edit: the user has to agree to it, see `resetConfig`. + if ( + aiConfigStr !== null && + parseAiConfigStr({ aiConfigStr }) === undefined + ) { + dispatch(actions.loadingFailed({ reason: "unreadable config" })); + return; + } + } + + dispatch(actions.loaded()); + + const aiProviders = selectors.aiProviders(getState()); + + assert(aiProviders !== undefined); + + await Promise.all( + aiProviders.map(aiProvider => + dispatch( + thunks.refreshProvider({ providerName: aiProvider.name }) + ) + ) + ); + })(); + + globalContext.prLoad = prLoad; + + try { + await prLoad; + } catch { + dispatch(actions.loadingFailed({ reason: "loading failed" })); + } finally { + globalContext.prLoad = undefined; + } + }, + /** + * Discards a persisted config that can't be read back, then loads again. Every user + * created provider, API key and selection it held is lost. + */ + resetConfig: + () => + async (...args): Promise => { + const [dispatch, getState] = args; + + assert(selectors.errorReason(getState()) === "unreadable config"); + + await dispatch( + userConfigs.thunks.changeValue({ + key: "aiConfigStr", + value: serializeAiConfig({ aiConfig: createEmptyPersistedAiConfig() }) + }) + ); + + await dispatch(thunks.load()); + }, + /** + * Obtains the provider's API key, then the models it exposes. Concurrent calls for a + * same provider share the same in-flight request. + */ + refreshProvider: + (params: { providerName: string }) => + async (...args): Promise => { + const { providerName } = params; + + const [dispatch] = args; + + await runOncePerProvider({ + providerName, + run: async () => { + const auth = await dispatch( + privateThunks.refreshProviderAuth({ providerName }) + ); + + if (auth === undefined) { + return; + } + + await dispatch( + privateThunks.refreshProviderModels({ + providerName, + apiKey: + auth.stateDescription === "authenticated" + ? auth.apiKey + : undefined + }) + ); + } + }); + }, + /** Refreshes only the exchanged token, leaving the model list untouched. */ + refreshToken: + (params: { providerName: string }) => + async (...args): Promise => { + const { providerName } = params; + + const [dispatch] = args; + + const aiProvider = dispatch(privateThunks.getAiProvider({ providerName })); + + assert( + aiProvider !== undefined && isAuthenticatedByTokenExchange(aiProvider) + ); + + await runOncePerProvider({ + providerName, + run: async () => { + await dispatch(privateThunks.refreshProviderAuth({ providerName })); + } + }); + }, + /** The models the user ticked in a provider's multi select, persisted as the ones left out. */ + setSelectedModelIds: + (params: { providerName: string; modelIds: string[] }) => + (...args): void => { + const { providerName, modelIds } = params; + + const [dispatch] = args; + + const aiProvider = dispatch(privateThunks.getAiProvider({ providerName })); + + assert(aiProvider !== undefined); + + const { models } = aiProvider; + + assert(models.stateDescription === "loaded"); + assert( + modelIds.every(modelId => + models.availableModels.some( + availableModel => availableModel.id === modelId + ) + ), + "a model that the provider doesn't expose can't be selected" + ); + + dispatch( + privateThunks.updateConfigInMemory({ + mutate: aiConfig => ({ + ...aiConfig, + // Excluded models the provider no longer lists are dropped + excludedModelIdsByProviderName: { + ...aiConfig.excludedModelIdsByProviderName, + [providerName]: getExcludedModelIds({ + availableModels: models.availableModels, + selectedModelIds: modelIds + }) + } + }) + }) + ); + void dispatch(thunks.saveConfig()); + }, + /** + * The single model elected among everything the user ticked, across all providers. + * We don't have to clear it when a selection changes: it is validated on read. + */ + setDefaultModel: + (params: { + defaultModel: { providerName: string; modelId: string } | undefined; + }) => + (...args): void => { + const { defaultModel } = params; + + const [dispatch] = args; + + if (defaultModel !== undefined) { + const aiProvider = dispatch( + privateThunks.getAiProvider({ + providerName: defaultModel.providerName + }) + ); + + assert(aiProvider !== undefined); + assert( + aiProvider.selectedModelIds.includes(defaultModel.modelId), + "the default model has to be one of the selected models" + ); + } + + dispatch( + privateThunks.updateConfigInMemory({ + mutate: aiConfig => ({ + ...aiConfig, + defaultModel: defaultModel ?? null + }) + }) + ); + void dispatch(thunks.saveConfig()); + }, + /** + * The key the user brings for an admin configured provider that expects one. + * `availableModels` are the models listed by a connection test made with this very + * key: when provided, they are trusted instead of being fetched again. + */ + setApiKey: + (params: { + providerName: string; + apiKey: string; + availableModels: AiModel[] | undefined; + }) => + async (...args): Promise => { + const { providerName, apiKey, availableModels } = params; + + const [dispatch] = args; + + const aiProvider = dispatch(privateThunks.getAiProvider({ providerName })); + + assert(aiProvider !== undefined); + assert(aiProvider.origin === "configured by admin"); + + const { authentification } = aiProvider; + + assert( + authentification.type === "api-key" && + authentification.obtentionMethod === "user-provided", + "this provider doesn't accept a user provided API key" + ); + + await dispatch( + privateThunks.mutatePersistedAiConfig({ + mutate: aiConfig => ({ + ...aiConfig, + apiKeyByProviderName: { + ...aiConfig.apiKeyByProviderName, + [providerName]: apiKey.trim() + } + }) + }) + ); + + if (availableModels === undefined) { + await dispatch(thunks.refreshProvider({ providerName })); + return; + } + + // No network involved: a user provided key is read from the persisted config + const auth = await dispatch( + privateThunks.refreshProviderAuth({ providerName }) + ); + + // Without a key, the provider can't be used whatever the test said + if (auth === undefined) { + return; + } + + dispatch( + actions.providerModelsChanged({ + providerName, + models: { stateDescription: "loaded", availableModels } + }) + ); + }, + /** + * Creates a provider, or updates the one named `providerName_current`. Model listing + * is optional: an unreachable provider must still be saved so it can be fixed later. + */ + createOrUpdateUserProvider: + (params: { + providerName_current: string | undefined; + providerName: string; + providerType: AiConfig.SupportedAiProviderType; + apiBase: string; + apiKey: string; + availableModels: AiModel[] | undefined; + }) => + async (...args): Promise => { + const { + providerName_current, + providerName, + providerType, + apiBase, + apiKey, + availableModels + } = params; + + const [dispatch, getState] = args; + + assert(providerName !== "" && !providerName.includes("/")); + + if (providerName_current === undefined) { + assert( + dispatch(thunks.canUserCreateProviders()), + "the instance configuration doesn't let the user add providers" + ); + } + + const aiProviders = selectors.aiProviders(getState()); + + assert(aiProviders !== undefined); + + // The name is the provider's id, it has to stay unique. + assert( + !aiProviders.some( + aiProvider => + aiProvider.name === providerName && + aiProvider.name !== providerName_current + ), + `there is already a provider named ${providerName}` + ); + + await dispatch( + privateThunks.mutatePersistedAiConfig({ + mutate: aiConfig => { + const aiConfig_renamed = + providerName_current === undefined + ? aiConfig + : renameProviderInPersistedAiConfig({ + aiConfig, + providerName_current, + providerName_new: providerName + }); + + return { + ...aiConfig_renamed, + customProviders: [ + ...aiConfig_renamed.customProviders.filter( + customProvider => customProvider.name !== providerName + ), + { name: providerName, providerType, apiBase } + ], + apiKeyByProviderName: { + ...aiConfig_renamed.apiKeyByProviderName, + [providerName]: apiKey.trim() + } + }; + } + }) + ); + + if ( + providerName_current !== undefined && + providerName_current !== providerName + ) { + dispatch( + actions.userProviderDeleted({ providerName: providerName_current }) + ); + } + + dispatch(actions.userProviderCreated({ providerName, availableModels })); + }, + deleteUserProvider: + (params: { providerName: string }) => + async (...args): Promise => { + const { providerName } = params; + + const [dispatch] = args; + + const aiProvider = dispatch(privateThunks.getAiProvider({ providerName })); + + assert(aiProvider !== undefined); + assert(aiProvider.origin === "created by user"); + + await dispatch( + privateThunks.mutatePersistedAiConfig({ + mutate: aiConfig => + removeProviderFromPersistedAiConfig({ aiConfig, providerName }) + }) + ); + + dispatch(actions.userProviderDeleted({ providerName })); + } +} satisfies Thunks; + +const privateThunks = { + getAiProvider: + (params: { providerName: string }) => + (...[, getState]): AiProviderWithRuntime | undefined => + selectors + .aiProviders(getState()) + ?.find(aiProvider => aiProvider.name === params.providerName), + /** undefined when the user hasn't provided any key for this provider. */ + getUserProvidedApiKey: + (params: { providerName: string }) => + (...[, getState]): string | undefined => { + const apiKey = + protectedSelectors.persistedAiConfig(getState()).apiKeyByProviderName[ + params.providerName + ]; + + return apiKey === undefined || apiKey === "" ? undefined : apiKey; + }, + /** + * Returns the provider's authentication once settled, or undefined when it didn't + * settle in a state we can call the provider with. + */ + refreshProviderAuth: + (params: { providerName: string }) => + async (...args): Promise => { + const { providerName } = params; + + const [dispatch, , { onyxiaApi, paramsOfBootstrapCore }] = args; + + const aiProvider = dispatch(privateThunks.getAiProvider({ providerName })); + + if (aiProvider === undefined) { + return undefined; + } + + // A user created provider is authenticated by the key it was created with, + // and an admin configured provider that needs no key is authenticated by + // definition: in both cases there is nothing to obtain. + if ( + aiProvider.origin === "created by user" || + aiProvider.authentification.type === "none" + ) { + return aiProvider.auth; + } + + const { authentification } = aiProvider; + + const apiKey_userProvided = dispatch( + privateThunks.getUserProvidedApiKey({ providerName }) + ); + + if (authentification.obtentionMethod === "user-provided") { + const auth: AiProviderRuntime["auth"] = + apiKey_userProvided === undefined + ? { stateDescription: "api-key not provided" } + : { + stateDescription: "authenticated", + apiKey: apiKey_userProvided + }; + + dispatch(actions.providerAuthChanged({ providerName, auth })); + + if (auth.stateDescription !== "authenticated") { + // What was listed with a key that is gone no longer tells anything + dispatch( + actions.providerModelsChanged({ + providerName, + models: { stateDescription: "not loaded" } + }) + ); + + return undefined; + } + + return auth; + } + + dispatch( + actions.providerAuthChanged({ + providerName, + auth: { stateDescription: "fetching" } + }) + ); + + // OIDC is already authenticated at this point: `autoLogin` redirects as + // needed, so token exchange never transitions through "api-key not provided". + const auth = await (async (): Promise => { + const { oidcParams } = await onyxiaApi.getAvailableRegionsAndOidcParams(); + + assert(oidcParams !== undefined); + + const { createOidc, mergeOidcParams } = await import( + "core/adapters/oidc" + ); + + const oidc = await createOidc({ + ...mergeOidcParams({ + oidcParams: oidcParams, + oidcParams_partial: authentification.oidcParams + }), + disableDPoP: true, + autoLogin: true, + transformBeforeRedirectForKeycloakTheme: + paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, + getCurrentLang: paramsOfBootstrapCore.getCurrentLang, + enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs + }); + + const { accessToken } = await oidc.getTokens(); + + let apiKey: string; + + try { + apiKey = await exchangeOpenWebUiToken({ + apiBase: aiProvider.apiBase, + oidcAccessToken: accessToken + }); + } catch { + return { stateDescription: "error" }; + } + + return { + stateDescription: "authenticated", + apiKey + }; + })(); + + dispatch(actions.providerAuthChanged({ providerName, auth })); + + return auth.stateDescription === "authenticated" ? auth : undefined; + }, + refreshProviderModels: + (params: { providerName: string; apiKey: string | undefined }) => + async (...args): Promise => { + const { providerName, apiKey } = params; + + const [dispatch] = args; + + const aiProvider = dispatch(privateThunks.getAiProvider({ providerName })); + + if (aiProvider === undefined) { + return; + } + + // NOTE: Called even when the admin pinned the models: it's how we know + // whether the provider can be reached. + + dispatch( + actions.providerModelsChanged({ + providerName, + models: { stateDescription: "fetching" } + }) + ); + + try { + const availableModels = await fetchAiModels({ + protocol: aiProvider.providerType, + apiBase: aiProvider.apiBase, + apiKey + }); + + dispatch( + actions.providerModelsChanged({ + providerName, + models: { stateDescription: "loaded", availableModels } + }) + ); + } catch { + dispatch( + actions.providerModelsChanged({ + providerName, + models: { stateDescription: "error" } + }) + ); + } + }, + /** + * Apply edits synchronously in memory. The serialized writer saves the latest + * snapshot and coalesces edits received while a previous write is in flight. + */ + updateConfigInMemory: + (params: { mutate: (aiConfig: PersistedAiConfig) => PersistedAiConfig }) => + (...[dispatch, getState]): void => { + dispatch( + actions.configChanged( + params.mutate(protectedSelectors.persistedAiConfig(getState())) + ) + ); + }, + mutatePersistedAiConfig: + (params: { mutate: (aiConfig: PersistedAiConfig) => PersistedAiConfig }) => + async (...[dispatch]): Promise => { + dispatch(privateThunks.updateConfigInMemory(params)); + const isSaved = await dispatch(thunks.saveConfig()); + if (!isSaved) { + throw new Error("Could not save AI configuration"); + } + } +} satisfies Thunks; + +export const protectedThunks = { + /** + * The launch context, as `.ai` of the XOnyxia context. Loads the use case if that + * hasn't happened yet: nothing is initialized at bootstrap. + */ + getAiContext: + () => + async (...args): Promise => { + const [dispatch, getState] = args; + + if (!dispatch(thunks.isAvailable())) { + return emptyAiContext; + } + + const wasLoaded = selectors.stateDescription(getState()) === "ready"; + + await dispatch(thunks.load()); + + if (selectors.stateDescription(getState()) !== "ready") { + return emptyAiContext; + } + + renew_exchanged_keys: { + if (!wasLoaded) { + // They were just obtained by `load()`. + break renew_exchanged_keys; + } + + const aiProviders = selectors.aiProviders(getState()); + + assert(aiProviders !== undefined); + + // Keys obtained by exchange are short lived: what we injected into a + // service launched an hour ago isn't what we should inject now. + await Promise.all( + aiProviders.filter(isAuthenticatedByTokenExchange).map(aiProvider => + dispatch( + thunks.refreshToken({ + providerName: aiProvider.name + }) + ) + ) + ); + } + + // A refresh started elsewhere, from the account tab, may still be in flight. + await Promise.all(globalContext.prRefreshByProviderName.values()); + + return protectedSelectors.aiContext(getState()); + } +} satisfies Thunks; + +const globalContext = { + prLoad: id | undefined>(undefined), + prRefreshByProviderName: new Map>(), + mutex: new Mutex() +}; + +/** + * Talking to a same provider twice at once is pointless: a call made while another one + * is in flight waits for it instead. + */ +async function runOncePerProvider(params: { + providerName: string; + run: () => Promise; +}): Promise { + const { providerName, run } = params; + + const pr_pending = globalContext.prRefreshByProviderName.get(providerName); + + if (pr_pending !== undefined) { + return pr_pending; + } + + const pr = run(); + + globalContext.prRefreshByProviderName.set(providerName, pr); + + try { + await pr; + } finally { + globalContext.prRefreshByProviderName.delete(providerName); + } +} + +function isAuthenticatedByTokenExchange(aiProvider: AiProviderWithRuntime): boolean { + if (aiProvider.origin !== "configured by admin") { + return false; + } + + const { authentification } = aiProvider; + + return ( + authentification.type === "api-key" && + authentification.obtentionMethod === "open-webui-oidc-token-exchange" + ); +} diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 3aba184a3..a37531abf 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -1,3 +1,6 @@ +import * as aiProvidersManagements from "./aiProvidersManagements"; +import * as aiAccountUiController from "./aiAccountUiController"; +import * as aiProviderFormUiController from "./aiProviderFormUiController"; import * as autoLogoutCountdown from "./autoLogoutCountdown"; import * as catalog from "./catalog"; import * as clusterEventsMonitor from "./clusterEventsMonitor"; @@ -26,6 +29,9 @@ import * as s3ProfilesCreationUiController from "./s3ProfilesCreationUiControlle import * as s3ExplorerUiController from "./s3ExplorerUiController"; export const usecases = { + aiProvidersManagements, + aiAccountUiController, + aiProviderFormUiController, 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..4294bf2a6 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,66 @@ describe(symToStr({ computeHelmValues }), () => { expect(got).toStrictEqual(expected); }); + it("Injects the AI context and providers using the current contract", () => { + const provider = { + name: "OpenAI", + type: "openai", + apiBase: "https://api.openai.com/v1", + apiKey: "sk-test", + models: ["gpt-4.1", "gpt-4.1-mini"] + }; + const ai = { + enabled: true, + defaultModel: "OpenAI/gpt-4.1", + listModels: ["OpenAI/gpt-4.1", "OpenAI/gpt-4.1-mini"], + providers: [provider] + }; + const xOnyxiaContext = { ai, s3: undefined }; + const providerProperties: Record = { + name: { type: "string", default: "" }, + type: { type: "string", default: "" }, + apiBase: { type: "string", default: "" }, + apiKey: { type: "string", default: "" }, + models: { type: "array", default: [], items: { type: "string" } } + }; + const got = computeHelmValues({ + helmValuesSchema: { + type: "object", + properties: { + ai: { + type: "object", + default: {}, + properties: { + enabled: { type: "boolean", default: false }, + defaultModel: { type: "string", default: "" }, + listModels: { + type: "array", + default: [], + items: { type: "string" } + }, + providers: { + type: "array", + default: [], + items: { type: "object", properties: providerProperties } + } + }, + "x-onyxia": { overwriteDefaultWith: "{{ai}}" } + }, + 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({ ai, providers: [provider] }); + }); + it("Use default", () => { const xOnyxiaContext = { a: { @@ -1011,4 +1071,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 b969a518b..1b836b4f0 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -1,5 +1,7 @@ import type { Thunks } from "core/bootstrap"; import { assert, type Equals, is } from "tsafe/assert"; +import * as aiProvidersManagements from "core/usecases/aiProvidersManagements"; +import { emptyAiContext } from "core/usecases/aiProvidersManagements/decoupledLogic"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import * as projectManagement from "core/usecases/projectManagement"; import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; @@ -812,6 +814,11 @@ export const protectedThunks = { useCertManager: region.certManager?.useCertManager, certManagerClusterIssuer: region.certManager?.certManagerClusterIssuer }, + ai: !doInjectPersonalInfos + ? emptyAiContext + : await dispatch( + aiProvidersManagements.protectedThunks.getAiContext() + ), 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 03e4d0967..41aadc95a 100644 --- a/web/src/core/usecases/userConfigs.ts +++ b/web/src/core/usecases/userConfigs.ts @@ -34,6 +34,7 @@ export type UserConfigs = Id< isCommandBarEnabled: boolean; userProfileStr: string | null; s3BookmarksStr: string | null; + aiConfigStr: string | null; } >; @@ -76,6 +77,12 @@ export const { reducer, actions } = createUsecaseActions({ { payload }: { payload: { key: keyof UserConfigs } } ) => { state[payload.key].isBeingChanged = false; + }, + changeFailed: (state, { payload }: { payload: ChangeValueParams }) => { + const wrap = state[payload.key]; + + wrap.value = payload.value; + wrap.isBeingChanged = false; } } }); @@ -93,20 +100,32 @@ export const thunks = { assert(oidc.isUserLoggedIn); - if (getState()[name][params.key].value === params.value) { + const previousValue = getState()[name][params.key].value; + + if (previousValue === params.value) { return; } dispatch(actions.changeStarted(params)); - const dirPath = await dispatch(privateThunks.getDirPath()); - - await secretsManager.put({ - path: pathJoin(dirPath, params.key), - secret: { value: params.value } - }); - - dispatch(actions.changeCompleted(params)); + try { + const dirPath = await dispatch(privateThunks.getDirPath()); + + await secretsManager.put({ + path: pathJoin(dirPath, params.key), + secret: { value: params.value } + }); + + dispatch(actions.changeCompleted(params)); + } catch (error) { + dispatch( + actions.changeFailed({ + key: params.key, + value: previousValue + } as ChangeValueParams) + ); + throw error; + } }, resetHelperDialogs: () => @@ -172,7 +191,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 514b46358..a4f06175f 100644 --- a/web/src/env.ts +++ b/web/src/env.ts @@ -1279,6 +1279,11 @@ export const { env, injectEnvsTransferableToKeycloakTheme } = createParsedEnvs([ return envValue === "true"; } }, + { + envName: "AI", + isUsedInKeycloakTheme: false, + validateAndParseOrGetDefault: ({ envValue }) => envValue + }, { envName: "VAULT_DOCUMENTATION_LINK", isUsedInKeycloakTheme: false, diff --git a/web/src/ui/App/App.tsx b/web/src/ui/App/App.tsx index 5556bca14..a059af514 100644 --- a/web/src/ui/App/App.tsx +++ b/web/src/ui/App/App.tsx @@ -38,7 +38,8 @@ triggerCoreBootstrap({ enableOidcDebugLogs: env.OIDC_DEBUG_LOGS, disableDisplayAllCatalog: env.DISABLE_DISPLAY_ALL_CATALOG, getIsDarkModeEnabled: () => evtTheme.state.isDarkModeEnabled, - S3_envValue: env.S3 + S3_envValue: env.S3, + AI_envValue: env.AI }); export function App() { diff --git a/web/src/ui/assets/img/ai-providers/anthropic-dark.svg b/web/src/ui/assets/img/ai-providers/anthropic-dark.svg new file mode 100644 index 000000000..c389642f8 --- /dev/null +++ b/web/src/ui/assets/img/ai-providers/anthropic-dark.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/web/src/ui/assets/img/ai-providers/anthropic-light.svg b/web/src/ui/assets/img/ai-providers/anthropic-light.svg new file mode 100644 index 000000000..9b306ddc8 --- /dev/null +++ b/web/src/ui/assets/img/ai-providers/anthropic-light.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/web/src/ui/assets/img/ai-providers/deepseek.svg b/web/src/ui/assets/img/ai-providers/deepseek.svg new file mode 100644 index 000000000..3fc230240 --- /dev/null +++ b/web/src/ui/assets/img/ai-providers/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/web/src/ui/assets/img/ai-providers/mistral.svg b/web/src/ui/assets/img/ai-providers/mistral.svg new file mode 100644 index 000000000..8e03e244b --- /dev/null +++ b/web/src/ui/assets/img/ai-providers/mistral.svg @@ -0,0 +1 @@ +Mistral \ No newline at end of file diff --git a/web/src/ui/assets/img/ai-providers/openai-dark.svg b/web/src/ui/assets/img/ai-providers/openai-dark.svg new file mode 100644 index 000000000..275968ac1 --- /dev/null +++ b/web/src/ui/assets/img/ai-providers/openai-dark.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/web/src/ui/assets/img/ai-providers/openai-light.svg b/web/src/ui/assets/img/ai-providers/openai-light.svg new file mode 100644 index 000000000..318403d61 --- /dev/null +++ b/web/src/ui/assets/img/ai-providers/openai-light.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file 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 4e49203ee..cf40da535 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,111 @@ export const translations: Translations<"de"> = { "expires in": ({ howMuchTime }) => `Diese Anmeldedaten sind für die nächsten ${howMuchTime} gültig` }, + AccountAiTab: { + "ai providers title": "KI-Anbieter", + "default model": "Standardmodell auswählen", + retry: "Erneut versuchen", + "provided by organization": "Von Ihrer Organisation bereitgestellt", + manage: "Verwalten", + "custom providers section title": "Benutzerdefinierte KI-Anbieter", + "add custom ai provider": "Benutzerdefinierten KI-Anbieter hinzufügen", + "save failed": "Ihre Änderungen konnten nicht gespeichert werden.", + "save failed details": + "Ihre Änderungen bleiben auf dieser Seite erhalten. Versuchen Sie es gleich noch einmal.", + "api-key not provided": "API-Schlüssel erforderlich.", + "api-key not provided details": + "Dieser Anbieter benötigt Ihren eigenen API-Schlüssel, bevor er verwendet werden kann.", + "gateway error": "Das KI-Gateway konnte nicht initialisiert werden.", + "gateway error details": + "Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", + "unreadable config title": "Ihre KI-Konfiguration kann nicht gelesen werden.", + "unreadable config": + "Beim Zurücksetzen werden Ihre eigenen Anbieter, API-Schlüssel und Modellauswahlen gelöscht.", + "reset config": "Meine KI-Konfiguration zurücksetzen", + "refresh failed": "Die Zugangsdaten konnten nicht erneuert werden.", + "refresh failed details": + "Versuchen Sie es erneut oder wenden Sie sich an Ihren Administrator, falls das Problem weiterhin besteht.", + "connection failed": "Verbindung fehlgeschlagen.", + "connection failed details": + "Bitte überprüfen Sie Ihre Zugangsdaten oder den Endpunkt." + }, + 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" + }, + ProviderCard: { + connected: "Verbunden", + "setup required": "Einrichtung erforderlich", + "connection error": "Verbindungsfehler" + }, + ModelsSelection: { + "model label": "Modell", + "no models available": "Keine Modelle verfügbar", + "no matching models": "Keine passenden Modelle", + "deselect all": "Alle abwählen", + "more models": ({ count }) => `+${count} Modelle` + }, + ManageProvidersDialog: { + "dialog title": "Anbieter verwalten", + "close aria label": "Schließen", + "provider selector aria label": "Anbieter", + "documentation title": "Dokumentation", + "connection details title": "Verbindungsdetails", + "connection details helper": + "Anzeigen und verwalten, wie Onyxia eine Verbindung zu diesem Anbieter herstellt.", + "manage models title": "Modelle verwalten", + "manage models helper": "Wählen Sie die in Ihren Diensten verfügbaren Modelle.", + "api base url": "API-Basis-URL", + "api key": "API-Schlüssel", + "refresh credentials": "Anmeldedaten aktualisieren", + "test connection": "Verbindung testen", + "delete provider": "Anbieter löschen", + cancel: "Abbrechen", + "save changes": "Änderungen speichern" + }, + CustomProviderFormDialog: { + "invalid name": + "Wählen Sie einen eindeutigen Anbieternamen ohne Schrägstrich (/).", + "invalid api base": "Geben Sie eine gültige HTTP(S)-URL ein.", + "deepseek provider option": "DeepSeek", + "add custom provider title": "Benutzerdefinierte KI-Anbieter", + "edit custom provider title": "KI-Anbieter bearbeiten", + "custom provider section title": "Benutzerdefinierte KI-Anbieter", + "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.", + "provider test": "Verbindung testen", + "provider testing": "Verbindung wird getestet...", + "provider test success": + "Verbindung erfolgreich. Der Anbieter ist einsatzbereit.", + "provider save": "Hinzufügen", + "provider update": "Speichern", + "provider cancel": "Abbrechen", + "close aria label": "Schließen", + "submission error": "Dieser Anbieter konnte nicht gespeichert werden.", + "submission error details": "Bitte versuchen Sie es gleich noch einmal.", + "provider test error": "Verbindung fehlgeschlagen.", + "provider test error details": + "Bitte überprüfen Sie Ihre Zugangsdaten oder den Endpunkt." + }, 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 13c79f1e7..dd82bfa3f 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,106 @@ export const translations: Translations<"en"> = { "expires in": ({ howMuchTime }) => `These credentials are valid for the next ${howMuchTime}` }, + AccountAiTab: { + "ai providers title": "AI Providers", + "default model": "Choose a default model", + retry: "Retry", + "provided by organization": "Provided by your organization", + manage: "Manage", + "custom providers section title": "Custom AI providers", + "add custom ai provider": "Add a new custom AI provider", + "save failed": "Unable to save your changes.", + "save failed details": + "Your changes are kept on this page. Try again in a moment.", + "api-key not provided": "API key required.", + "api-key not provided details": + "This provider needs your own API key before it can be used.", + "gateway error": "Unable to initialize the AI gateway.", + "gateway error details": "Check your connection and try again.", + "unreadable config title": "Your AI configuration can't be read.", + "unreadable config": + "Resetting it deletes your custom providers, API keys and model selections.", + "reset config": "Reset my AI configuration", + "refresh failed": "Unable to renew the credentials.", + "refresh failed details": + "Try again, or contact your administrator if the problem persists.", + "connection failed": "Connection failed.", + "connection failed details": "Please check your credentials or endpoint." + }, + 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" + }, + ProviderCard: { + connected: "Connected", + "setup required": "Setup required", + "connection error": "Connection error" + }, + ModelsSelection: { + "model label": "Selected models", + "no models available": "No models available", + "no matching models": "No matching models", + "deselect all": "Deselect all", + "more models": ({ count }) => `+${count} Models` + }, + ManageProvidersDialog: { + "dialog title": "Manage providers", + "close aria label": "Close", + "provider selector aria label": "Provider", + "documentation title": "Documentation", + "connection details title": "Connection details", + "connection details helper": + "View and manage how Onyxia connects to this provider.", + "manage models title": "Manage models", + "manage models helper": "Choose the models available in your services.", + "api base url": "API Base URL", + "api key": "API Key", + "refresh credentials": "Refresh credentials", + "test connection": "Test connection", + "delete provider": "Delete provider", + cancel: "Cancel", + "save changes": "Save changes" + }, + CustomProviderFormDialog: { + "invalid name": "Choose a unique provider name without a slash (/).", + "invalid api base": "Enter a valid HTTP(S) URL.", + "deepseek provider option": "DeepSeek", + "add custom provider title": "Add a new custom AI provider", + "edit custom provider title": "Edit AI provider", + "custom provider section title": "Configure AI provider", + "custom provider label field": "Define a custom name", + "custom provider type field": "Provider API", + "openai provider option": "OpenAI (native)", + "openai compatible provider option": "OpenAI-compatible", + "mistral provider option": "Mistral (native)", + "anthropic provider option": "Anthropic (native)", + "credentials section title": "Provider Credentials", + "credentials section subtitle": + "Enter your credentials to save them with your account settings. Leave the API key empty if the provider requires no authentication.", + "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.", + "provider test": "Test connection", + "provider testing": "Testing connection...", + "provider test success": "Connection successful. Your provider is ready to use.", + "provider save": "Add Custom AI Providers", + "provider update": "Save changes", + "provider cancel": "Cancel", + "close aria label": "Close", + "submission error": "Unable to save this provider.", + "submission error details": "Please try again in a moment.", + "provider test error": "Connection failed.", + "provider test error details": "Please check your credentials or endpoint." + }, 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 95126cb78..5e0b842af 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,106 @@ export const translations: Translations<"es"> = { "expires in": ({ howMuchTime }) => `Estas credenciales son válidas por los próximos ${howMuchTime}` }, + AccountAiTab: { + "ai providers title": "Proveedores de IA", + "default model": "Elegir un modelo predeterminado", + retry: "Reintentar", + "provided by organization": "Proporcionado por su organización", + manage: "Gestionar", + "custom providers section title": "Proveedores de IA personalizados", + "add custom ai provider": "Añadir un proveedor de IA personalizado", + "save failed": "No se pudieron guardar los cambios.", + "save failed details": + "Sus cambios se conservan en esta página. Inténtelo de nuevo en un momento.", + "api-key not provided": "Se requiere una clave API.", + "api-key not provided details": + "Este proveedor necesita su propia clave API antes de poder usarse.", + "gateway error": "No se pudo inicializar la pasarela de IA.", + "gateway error details": "Compruebe su conexión e inténtelo de nuevo.", + "unreadable config title": "No se puede leer su configuración de IA.", + "unreadable config": + "Al restablecerla se eliminan sus proveedores personalizados, claves API y selecciones de modelos.", + "reset config": "Restablecer mi configuración de IA", + "refresh failed": "No se pudieron renovar las credenciales.", + "refresh failed details": + "Inténtelo de nuevo o contacte con su administrador si el problema persiste.", + "connection failed": "Error de conexión.", + "connection failed details": "Compruebe sus credenciales o el endpoint." + }, + 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" + }, + ProviderCard: { + connected: "Conectado", + "setup required": "Configuración requerida", + "connection error": "Error de conexión" + }, + ModelsSelection: { + "model label": "Modelo", + "no models available": "No hay modelos disponibles", + "no matching models": "Ningún modelo coincide", + "deselect all": "Deseleccionar todo", + "more models": ({ count }) => `+${count} modelos` + }, + ManageProvidersDialog: { + "dialog title": "Gestionar proveedores", + "close aria label": "Cerrar", + "provider selector aria label": "Proveedor", + "documentation title": "Documentación", + "connection details title": "Detalles de conexión", + "connection details helper": + "Consulta y gestiona cómo se conecta Onyxia a este proveedor.", + "manage models title": "Gestionar modelos", + "manage models helper": "Elige los modelos disponibles en tus servicios.", + "api base url": "URL base de la API", + "api key": "Clave API", + "refresh credentials": "Actualizar credenciales", + "test connection": "Probar la conexión", + "delete provider": "Eliminar proveedor", + cancel: "Cancelar", + "save changes": "Guardar cambios" + }, + CustomProviderFormDialog: { + "invalid name": "Elija un nombre de proveedor único sin barra (/).", + "invalid api base": "Introduzca una URL HTTP(S) válida.", + "deepseek provider option": "DeepSeek", + "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 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.", + "provider test": "Probar conexión", + "provider testing": "Probando conexión...", + "provider test success": "Conexión exitosa. El proveedor está listo para usar.", + "provider save": "Añadir", + "provider update": "Guardar", + "provider cancel": "Cancelar", + "close aria label": "Cerrar", + "submission error": "No se pudo guardar este proveedor.", + "submission error details": "Inténtelo de nuevo en un momento.", + "provider test error": "Error de conexión.", + "provider test error details": "Compruebe sus credenciales o el endpoint." + }, 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 288690dca..6cab87a12 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,107 @@ export const translations: Translations<"fi"> = { "expires in": ({ howMuchTime }) => `Nämä käyttöoikeudet ovat voimassa seuraavat ${howMuchTime}` }, + AccountAiTab: { + "ai providers title": "Tekoälypalveluntarjoajat", + "default model": "Valitse oletusmalli", + retry: "Yritä uudelleen", + "provided by organization": "Organisaatiosi tarjoama", + manage: "Hallinnoi", + "custom providers section title": "Mukautetut tekoälyntarjoajat", + "add custom ai provider": "Lisää mukautettu tekoälyntarjoaja", + "save failed": "Muutoksia ei voitu tallentaa.", + "save failed details": + "Muutoksesi säilyvät tällä sivulla. Yritä hetken kuluttua uudelleen.", + "api-key not provided": "API-avain vaaditaan.", + "api-key not provided details": + "Tämä palveluntarjoaja tarvitsee oman API-avaimesi ennen kuin sitä voi käyttää.", + "gateway error": "Tekoälyyhdyskäytävää ei voitu alustaa.", + "gateway error details": "Tarkista yhteytesi ja yritä uudelleen.", + "unreadable config title": "Tekoälymääritystäsi ei voida lukea.", + "unreadable config": + "Nollaus poistaa omat palveluntarjoajasi, API-avaimesi ja mallivalintasi.", + "reset config": "Nollaa tekoälymääritykseni", + "refresh failed": "Tunnistetietoja ei voitu uusia.", + "refresh failed details": + "Yritä uudelleen tai ota yhteyttä ylläpitäjään, jos ongelma jatkuu.", + "connection failed": "Yhteys epäonnistui.", + "connection failed details": "Tarkista tunnistetietosi tai päätepiste." + }, + 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" + }, + ProviderCard: { + connected: "Yhdistetty", + "setup required": "Määritys vaaditaan", + "connection error": "Yhteysvirhe" + }, + ModelsSelection: { + "model label": "Malli", + "no models available": "Malleja ei ole saatavilla", + "no matching models": "Ei vastaavia malleja", + "deselect all": "Poista kaikki valinnat", + "more models": ({ count }) => `+${count} mallia` + }, + ManageProvidersDialog: { + "dialog title": "Hallitse palveluntarjoajia", + "close aria label": "Sulje", + "provider selector aria label": "Palveluntarjoaja", + "documentation title": "Dokumentaatio", + "connection details title": "Yhteyden tiedot", + "connection details helper": + "Tarkastele ja hallitse Onyxian yhteyttä tähän palveluntarjoajaan.", + "manage models title": "Hallitse malleja", + "manage models helper": "Valitse palveluissasi käytettävissä olevat mallit.", + "api base url": "API:n perus-URL", + "api key": "API-avain", + "refresh credentials": "Päivitä tunnistetiedot", + "test connection": "Testaa yhteys", + "delete provider": "Poista palveluntarjoaja", + cancel: "Peruuta", + "save changes": "Tallenna muutokset" + }, + CustomProviderFormDialog: { + "invalid name": + "Valitse yksilöllinen palveluntarjoajan nimi ilman kauttaviivaa (/).", + "invalid api base": "Anna kelvollinen HTTP(S)-URL.", + "deepseek provider option": "DeepSeek", + "add custom provider title": "Mukautetut tekoälyntarjoajat", + "edit custom provider title": "Muokkaa tekoälyntarjoajaa", + "custom provider section title": "Mukautetut tekoälyntarjoajat", + "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.", + "provider test": "Testaa yhteys", + "provider testing": "Testataan yhteyttä...", + "provider test success": "Yhteys onnistui. Palveluntarjoaja on käyttövalmis.", + "provider save": "Lisää", + "provider update": "Tallenna", + "provider cancel": "Peruuta", + "close aria label": "Sulje", + "submission error": "Tätä palveluntarjoajaa ei voitu tallentaa.", + "submission error details": "Yritä hetken kuluttua uudelleen.", + "provider test error": "Yhteys epäonnistui.", + "provider test error details": "Tarkista tunnistetietosi tai päätepiste." + }, 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 23b526f97..950d91143 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,106 @@ export const translations: Translations<"fr"> = { "expires in": ({ howMuchTime }) => `Ces identifiants sont valables pour les ${howMuchTime} prochaines` }, + AccountAiTab: { + "ai providers title": "Fournisseurs d'IA", + "default model": "Choisir un modèle par défaut", + retry: "Réessayer", + "provided by organization": "Fourni par votre organisation", + manage: "Gérer", + "custom providers section title": "Providers IA personnalisés", + "add custom ai provider": "Ajouter un provider IA personnalisé", + "save failed": "Impossible d’enregistrer les modifications.", + "save failed details": + "Vos modifications sont conservées sur cette page. Réessayez dans un instant.", + "api-key not provided": "Clé API requise.", + "api-key not provided details": + "Ce provider nécessite votre propre clé API avant de pouvoir être utilisé.", + "gateway error": "Impossible d'initialiser la passerelle IA.", + "gateway error details": "Vérifiez votre connexion puis réessayez.", + "unreadable config title": "Votre configuration IA est illisible.", + "unreadable config": + "La réinitialiser supprime vos providers personnalisés, vos clés API et vos sélections de modèles.", + "reset config": "Réinitialiser ma configuration IA", + "refresh failed": "Impossible de renouveler les identifiants.", + "refresh failed details": + "Réessayez, ou contactez votre administrateur si le problème persiste.", + "connection failed": "Échec de la connexion.", + "connection failed details": "Vérifiez vos identifiants ou l’URL de l’API." + }, + 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é" + }, + ProviderCard: { + connected: "Connecté", + "setup required": "Configuration requise", + "connection error": "Erreur de connexion" + }, + ModelsSelection: { + "model label": "Modèles présélectionnés", + "no models available": "Aucun modèle disponible", + "no matching models": "Aucun modèle correspondant", + "deselect all": "Tout désélectionner", + "more models": ({ count }) => `+${count} modèles` + }, + ManageProvidersDialog: { + "dialog title": "Gérer les providers", + "close aria label": "Fermer", + "provider selector aria label": "Provider", + "documentation title": "Documentation", + "connection details title": "Détails de connexion", + "connection details helper": + "Consultez et gérez la manière dont Onyxia se connecte à ce provider.", + "manage models title": "Gérer les modèles", + "manage models helper": "Choisissez les modèles disponibles dans vos services.", + "api base url": "URL de base de l'API", + "api key": "Clé API", + "refresh credentials": "Rafraîchir les identifiants", + "test connection": "Tester la connexion", + "delete provider": "Supprimer le provider", + cancel: "Annuler", + "save changes": "Enregistrer les modifications" + }, + CustomProviderFormDialog: { + "invalid name": "Choisissez un nom de provider unique, sans barre oblique (/).", + "invalid api base": "Saisissez une URL HTTP(S) valide.", + "deepseek provider option": "DeepSeek", + "add custom provider title": "Ajouter un provider IA personnalisé", + "edit custom provider title": "Modifier le provider IA", + "custom provider section title": "Configurer le provider IA", + "custom provider label field": "Nom", + "custom provider type field": "API du provider", + "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 pour les enregistrer dans les paramètres de votre compte. Laissez la clé API vide si le provider ne nécessite pas d’authentification.", + "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.", + "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 save": "Ajouter", + "provider update": "Enregistrer", + "provider cancel": "Annuler", + "close aria label": "Fermer", + "submission error": "Impossible d’enregistrer ce provider.", + "submission error details": "Réessayez dans un instant.", + "provider test error": "Échec de la connexion.", + "provider test error details": "Vérifiez vos identifiants ou l’URL de l’API." + }, 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 ddd296668..79ee53300 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,106 @@ export const translations: Translations<"it"> = { "expires in": ({ howMuchTime }) => `Queste credenziali sono valide per i prossimi ${howMuchTime}` }, + AccountAiTab: { + "ai providers title": "Provider di IA", + "default model": "Scegli un modello predefinito", + retry: "Riprova", + "provided by organization": "Fornito dalla tua organizzazione", + manage: "Gestisci", + "custom providers section title": "Provider IA personalizzati", + "add custom ai provider": "Aggiungi un provider IA personalizzato", + "save failed": "Impossibile salvare le modifiche.", + "save failed details": + "Le modifiche restano su questa pagina. Riprova tra un momento.", + "api-key not provided": "Chiave API richiesta.", + "api-key not provided details": + "Questo provider richiede la tua chiave API prima di poter essere utilizzato.", + "gateway error": "Impossibile inizializzare il gateway IA.", + "gateway error details": "Controlla la connessione e riprova.", + "unreadable config title": "Impossibile leggere la configurazione IA.", + "unreadable config": + "Reimpostarla elimina i provider personalizzati, le chiavi API e le selezioni dei modelli.", + "reset config": "Reimposta la mia configurazione IA", + "refresh failed": "Impossibile rinnovare le credenziali.", + "refresh failed details": + "Riprova o contatta l'amministratore se il problema persiste.", + "connection failed": "Connessione non riuscita.", + "connection failed details": "Controlla le credenziali o l'endpoint." + }, + 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" + }, + ProviderCard: { + connected: "Connesso", + "setup required": "Configurazione richiesta", + "connection error": "Errore di connessione" + }, + ModelsSelection: { + "model label": "Modello", + "no models available": "Nessun modello disponibile", + "no matching models": "Nessun modello corrispondente", + "deselect all": "Deseleziona tutto", + "more models": ({ count }) => `+${count} modelli` + }, + ManageProvidersDialog: { + "dialog title": "Gestisci provider", + "close aria label": "Chiudi", + "provider selector aria label": "Provider", + "documentation title": "Documentazione", + "connection details title": "Dettagli di connessione", + "connection details helper": + "Visualizza e gestisci il modo in cui Onyxia si connette a questo provider.", + "manage models title": "Gestisci modelli", + "manage models helper": "Scegli i modelli disponibili nei tuoi servizi.", + "api base url": "URL di base API", + "api key": "Chiave API", + "refresh credentials": "Aggiorna credenziali", + "test connection": "Verifica connessione", + "delete provider": "Elimina provider", + cancel: "Annulla", + "save changes": "Salva modifiche" + }, + CustomProviderFormDialog: { + "invalid name": "Scegli un nome di provider univoco senza barra (/).", + "invalid api base": "Inserisci un URL HTTP(S) valido.", + "deepseek provider option": "DeepSeek", + "add custom provider title": "Provider IA personalizzati", + "edit custom provider title": "Modifica provider IA", + "custom provider section title": "Provider IA personalizzati", + "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.", + "provider test": "Testa connessione", + "provider testing": "Test della connessione...", + "provider test success": "Connessione riuscita. Il provider è pronto all'uso.", + "provider save": "Aggiungi", + "provider update": "Salva", + "provider cancel": "Annulla", + "close aria label": "Chiudi", + "submission error": "Impossibile salvare questo provider.", + "submission error details": "Riprova tra un momento.", + "provider test error": "Connessione non riuscita.", + "provider test error details": "Controlla le credenziali o l'endpoint." + }, AccountVaultTab: { "credentials section title": "Credenziali Vault", "credentials section helper": ({ vaultDocHref, mySecretLink }) => ( @@ -524,9 +625,10 @@ export const translations: Translations<"it"> = { la nostra documentazione - {". \u00a0"} - Configurare il tuo Vault CLI locale - {"."} + .   + + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 48c86bf8c..218df2521 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,107 @@ export const translations: Translations<"nl"> = { "expires in": ({ howMuchTime }) => `Deze inloggegevens zijn geldig voor de komende ${howMuchTime}` }, + AccountAiTab: { + "ai providers title": "AI-providers", + "default model": "Kies een standaardmodel", + retry: "Opnieuw proberen", + "provided by organization": "Aangeboden door uw organisatie", + manage: "Beheren", + "custom providers section title": "Aangepaste AI-providers", + "add custom ai provider": "Aangepaste AI-provider toevoegen", + "save failed": "Kan uw wijzigingen niet opslaan.", + "save failed details": + "Uw wijzigingen blijven op deze pagina bewaard. Probeer het zo opnieuw.", + "api-key not provided": "API-sleutel vereist.", + "api-key not provided details": + "Deze provider heeft uw eigen API-sleutel nodig voordat hij gebruikt kan worden.", + "gateway error": "Kan de AI-gateway niet initialiseren.", + "gateway error details": "Controleer uw verbinding en probeer het opnieuw.", + "unreadable config title": "Uw AI-configuratie kan niet worden gelezen.", + "unreadable config": + "Opnieuw instellen verwijdert uw eigen providers, API-sleutels en modelselecties.", + "reset config": "Mijn AI-configuratie opnieuw instellen", + "refresh failed": "Kan de inloggegevens niet vernieuwen.", + "refresh failed details": + "Probeer het opnieuw of neem contact op met uw beheerder als het probleem aanhoudt.", + "connection failed": "Verbinding mislukt.", + "connection failed details": "Controleer uw inloggegevens of het endpoint." + }, + 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" + }, + ProviderCard: { + connected: "Verbonden", + "setup required": "Configuratie vereist", + "connection error": "Verbindingsfout" + }, + ModelsSelection: { + "model label": "Model", + "no models available": "Geen modellen beschikbaar", + "no matching models": "Geen overeenkomende modellen", + "deselect all": "Alles deselecteren", + "more models": ({ count }) => `+${count} modellen` + }, + ManageProvidersDialog: { + "dialog title": "Providers beheren", + "close aria label": "Sluiten", + "provider selector aria label": "Provider", + "documentation title": "Documentatie", + "connection details title": "Verbindingsgegevens", + "connection details helper": + "Bekijk en beheer hoe Onyxia verbinding maakt met deze provider.", + "manage models title": "Modellen beheren", + "manage models helper": "Kies de modellen die beschikbaar zijn in je diensten.", + "api base url": "API-basis-URL", + "api key": "API-sleutel", + "refresh credentials": "Inloggegevens vernieuwen", + "test connection": "Verbinding testen", + "delete provider": "Provider verwijderen", + cancel: "Annuleren", + "save changes": "Wijzigingen opslaan" + }, + CustomProviderFormDialog: { + "invalid name": "Kies een unieke providernaam zonder schuine streep (/).", + "invalid api base": "Voer een geldige HTTP(S)-URL in.", + "deepseek provider option": "DeepSeek", + "add custom provider title": "Aangepaste AI-providers", + "edit custom provider title": "AI-provider bewerken", + "custom provider section title": "Aangepaste AI-providers", + "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.", + "provider test": "Verbinding testen", + "provider testing": "Verbinding testen...", + "provider test success": + "Verbinding geslaagd. De provider is klaar voor gebruik.", + "provider save": "Toevoegen", + "provider update": "Opslaan", + "provider cancel": "Annuleren", + "close aria label": "Sluiten", + "submission error": "Kan deze provider niet opslaan.", + "submission error details": "Probeer het zo opnieuw.", + "provider test error": "Verbinding mislukt.", + "provider test error details": "Controleer uw inloggegevens of het endpoint." + }, 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 8f943ac53..2d1e62ed9 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,108 @@ export const translations: Translations<"no"> = { "expires in": ({ howMuchTime }) => `Disse legitimasjonene er gyldige for de neste ${howMuchTime}` }, + AccountAiTab: { + "ai providers title": "KI-leverandører", + "default model": "Velg en standardmodell", + retry: "Prøv igjen", + "provided by organization": "Levert av organisasjonen din", + manage: "Administrer", + "custom providers section title": "Tilpassede AI-leverandører", + "add custom ai provider": "Legg til tilpasset AI-leverandør", + "save failed": "Kunne ikke lagre endringene.", + "save failed details": + "Endringene dine beholdes på denne siden. Prøv igjen om litt.", + "api-key not provided": "API-nøkkel kreves.", + "api-key not provided details": + "Denne leverandøren trenger din egen API-nøkkel før den kan brukes.", + "gateway error": "Kunne ikke initialisere AI-gatewayen.", + "gateway error details": "Sjekk tilkoblingen din og prøv igjen.", + "unreadable config title": "AI-konfigurasjonen din kan ikke leses.", + "unreadable config": + "Tilbakestilling sletter dine egne leverandører, API-nøkler og modellvalg.", + "reset config": "Tilbakestill AI-konfigurasjonen min", + "refresh failed": "Kunne ikke fornye påloggingsinformasjonen.", + "refresh failed details": + "Prøv igjen, eller kontakt administratoren hvis problemet vedvarer.", + "connection failed": "Tilkoblingen mislyktes.", + "connection failed details": + "Kontroller påloggingsinformasjonen eller endepunktet." + }, + 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" + }, + ProviderCard: { + connected: "Tilkoblet", + "setup required": "Oppsett kreves", + "connection error": "Tilkoblingsfeil" + }, + ModelsSelection: { + "model label": "Modell", + "no models available": "Ingen modeller tilgjengelig", + "no matching models": "Ingen samsvarende modeller", + "deselect all": "Fjern alle valg", + "more models": ({ count }) => `+${count} modeller` + }, + ManageProvidersDialog: { + "dialog title": "Administrer leverandører", + "close aria label": "Lukk", + "provider selector aria label": "Leverandør", + "documentation title": "Dokumentasjon", + "connection details title": "Tilkoblingsdetaljer", + "connection details helper": + "Vis og administrer hvordan Onyxia kobler til denne leverandøren.", + "manage models title": "Administrer modeller", + "manage models helper": "Velg modellene som er tilgjengelige i tjenestene dine.", + "api base url": "API-grunn-URL", + "api key": "API-nøkkel", + "refresh credentials": "Oppdater legitimasjon", + "test connection": "Test tilkoblingen", + "delete provider": "Slett leverandør", + cancel: "Avbryt", + "save changes": "Lagre endringer" + }, + CustomProviderFormDialog: { + "invalid name": "Velg et unikt leverandørnavn uten skråstrek (/).", + "invalid api base": "Skriv inn en gyldig HTTP(S)-URL.", + "deepseek provider option": "DeepSeek", + "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 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.", + "provider test": "Test tilkobling", + "provider testing": "Tester tilkobling...", + "provider test success": "Tilkobling vellykket. Leverandøren er klar til bruk.", + "provider save": "Legg til", + "provider update": "Lagre", + "provider cancel": "Avbryt", + "close aria label": "Lukk", + "submission error": "Kunne ikke lagre denne leverandøren.", + "submission error details": "Prøv igjen om litt.", + "provider test error": "Tilkoblingen mislyktes.", + "provider test error details": + "Kontroller påloggingsinformasjonen eller endepunktet." + }, 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 5e0a19f78..aff7f3f2f 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,98 @@ export const translations: Translations<"zh-CN"> = { ), "expires in": ({ howMuchTime }) => `这些凭证在接下来的 ${howMuchTime} 内有效` }, + AccountAiTab: { + "ai providers title": "AI 提供商", + "default model": "选择默认模型", + retry: "重试", + "provided by organization": "由您的组织提供", + manage: "管理", + "custom providers section title": "自定义 AI 提供商", + "add custom ai provider": "添加自定义 AI 提供商", + "save failed": "无法保存您的更改。", + "save failed details": "您的更改保留在此页面上。请稍后重试。", + "api-key not provided": "需要 API 密钥。", + "api-key not provided details": "此提供商需要您自己的 API 密钥才能使用。", + "gateway error": "无法初始化 AI 网关。", + "gateway error details": "请检查您的网络连接后重试。", + "unreadable config title": "无法读取您的 AI 配置。", + "unreadable config": "重置将删除您的自定义提供商、API 密钥和模型选择。", + "reset config": "重置我的 AI 配置", + "refresh failed": "无法更新凭据。", + "refresh failed details": "请重试,如果问题仍然存在,请联系管理员。", + "connection failed": "连接失败。", + "connection failed details": "请检查您的凭据或端点。" + }, + ConfirmCustomProviderDeletionDialog: { + "dialog title": "删除此自定义 AI 提供商", + "dialog body": "这将永久删除该提供商及浏览器中存储的凭据。此操作无法撤销。", + cancel: "取消", + "delete provider": "删除提供商" + }, + ProviderValueField: { + copy: "复制", + copied: "已复制" + }, + ProviderCard: { + connected: "已连接", + "setup required": "需要设置", + "connection error": "连接错误" + }, + ModelsSelection: { + "model label": "模型", + "no models available": "没有可用的模型", + "no matching models": "没有匹配的模型", + "deselect all": "取消全选", + "more models": ({ count }) => `+${count}个模型` + }, + ManageProvidersDialog: { + "dialog title": "管理提供商", + "close aria label": "关闭", + "provider selector aria label": "提供商", + "documentation title": "文档", + "connection details title": "连接详情", + "connection details helper": "查看和管理 Onyxia 连接此提供商的方式。", + "manage models title": "管理模型", + "manage models helper": "选择服务中可用的模型。", + "api base url": "API 基础 URL", + "api key": "API 密钥", + "refresh credentials": "刷新凭据", + "test connection": "测试连接", + "delete provider": "删除提供商", + cancel: "取消", + "save changes": "保存更改" + }, + CustomProviderFormDialog: { + "invalid name": "请选择一个不含斜杠 (/) 的唯一提供商名称。", + "invalid api base": "请输入有效的 HTTP(S) URL。", + "deepseek provider option": "DeepSeek", + "add custom provider title": "自定义 AI 提供商", + "edit custom provider title": "编辑 AI 提供商", + "custom provider section title": "自定义 AI 提供商", + "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": "验证凭证并自动发现可用模型。", + "provider test": "测试连接", + "provider testing": "正在测试连接...", + "provider test success": "连接成功。提供商已准备就绪。", + "provider save": "添加", + "provider update": "保存", + "provider cancel": "取消", + "close aria label": "关闭", + "submission error": "无法保存此提供商。", + "submission error details": "请稍后重试。", + "provider test error": "连接失败。", + "provider test error details": "请检查您的凭据或端点。" + }, 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 cdc38feed..0a9781ebb 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -57,6 +57,13 @@ 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/dialogs/ManageProvidersDialog/ProviderValueField").I18n + | import("ui/pages/account/AccountAiTab/shared/ModelsSelection").I18n + | import("ui/pages/account/AccountAiTab/ProviderCard").I18n + | import("ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ManageProvidersDialog").I18n + | import("ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialog").I18n + | import("ui/pages/account/AccountAiTab/dialogs/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..a71e5379f --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/AccountAiTab.tsx @@ -0,0 +1,372 @@ +import { useTranslation } from "ui/i18n"; +import { declareComponentKeys } from "i18nifty"; +import { useCoreState, getCoreSync, getCore } from "core"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Stack, Box } from "@mui/material"; +import { ProviderCard } from "./ProviderCard"; +import { FormSelectField } from "./shared/FormFields"; +import { AiAlert } from "./shared/AiAlert"; +import { Text } from "onyxia-ui/Text"; +import { LocalizedMarkdown } from "ui/shared/Markdown"; +import { AddCustomProviderButton } from "./AddCustomProviderButton"; +import { providerTypeLogoUrl } from "./shared/providerTypeLogoUrl"; +import { ManageProvidersDialog } from "./dialogs/ManageProvidersDialog"; +import { CustomProviderFormDialog } from "./dialogs/CustomProviderFormDialog"; +import { + ConfirmCustomProviderDeletionDialog, + type Props as ConfirmProps +} from "./dialogs/ConfirmCustomProviderDeletionDialog"; +import { Evt, type UnpackEvt } from "evt"; +import { useConst } from "powerhooks/useConst"; +import { Deferred } from "evt/tools/Deferred"; +import { withLoader } from "ui/tools/withLoader"; +import { tss } from "tss"; + +export type Props = { className?: string }; + +export const AccountAiTab = withLoader({ + loader: async () => { + const { + functions: { aiAccountUiController: account } + } = await getCore(); + + await account.load(); + }, + FallbackComponent: () => null, + Component +}); + +function Component(props: Props) { + const { + functions: { aiAccountUiController: account, aiProviderFormUiController: form } + } = getCoreSync(); + + const state = useCoreState("aiAccountUiController", "main"); + const formState = useCoreState("aiProviderFormUiController", "main"); + + const { t } = useTranslation({ AccountAiTab }); + const { t: tForm } = useTranslation("CustomProviderFormDialog"); + const { classes, cx } = useStyles(); + + const evtOpen = useConst(() => Evt.create>()); + + function confirmProviderDeletion(): Promise { + const confirmation = new Deferred(); + evtOpen.post({ resolveDoProceed: confirmation.resolve }); + return confirmation.pr; + } + + if (!state.isReady) { + if (state.isConfigUnreadable) + return ( + account.resetConfig() + }} + /> + ); + if (state.stateDescription === "error") + return ( + account.load() }} + /> + ); + return ; + } + return ( + + {state.configSaveState === "error" && ( + account.retrySave() }} + /> + )} + + {t("ai providers title")} + {state.description !== undefined && ( + + {state.description} + + )} + + {/* As wide as a provider card */} + + account.setDefaultModel({ model })} + options={state.defaultModelOptionGroups.map( + ({ providerName, options }) => ({ + groupLabel: providerName, + options: options.map(({ value, modelId }) => ({ + value, + label: modelId, + // Once picked, the provider is needed too + selectedLabel: value + })) + }) + )} + /> + + + {state.providers.map(provider => { + const isDisabled = + provider.operationState === "pending" || + (provider.origin === "created by user" && + provider.isNameConflicting); + + return ( + id + ) + : [], + selectedModels: provider.selectedModelIds, + onSelectedModelsChange: modelIds => + account.setSelectedModelIds({ + providerName: provider.name, + modelIds + }) + }} + manageLabel={t("manage")} + onManage={() => form.open({ providerName: provider.name })} + /> + ); + })} + {account.canUserCreateProviders() && ( + form.open({ providerName: undefined })} + /> + )} + + {/* `CustomProviderFormDialog` is only for the providers being created */} + {formState.isOpen && + formState.isEditing && + (() => { + const provider = state.providers.find( + provider => provider.name === formState.providerName_current + ); + + if (provider === undefined) { + return null; + } + + const { connectionTest, formValues } = formState; + + const isCreatedByUser = provider.origin === "created by user"; + + return ( + name)} + provider={{ + name: provider.name, + subtitle: + provider.origin === "configured by admin" + ? t("provided by organization") + : t("custom providers section title"), + state: formState.connectionState, + configuration: isCreatedByUser + ? { + name: formValues.name, + providerType: formValues.providerType, + supportedProviderTypes: + formState.supportedProviderTypes, + nameError: + !formState.isNameValid && + formValues.name !== "" + ? tForm("invalid name") + : undefined + } + : undefined, + apiBase: isCreatedByUser + ? formValues.apiBase + : provider.apiBase, + isApiBaseEditable: isCreatedByUser, + apiBaseError: + !formState.isApiBaseValid && formValues.apiBase !== "" + ? tForm("invalid api base") + : undefined, + apiKey: formState.canEditApiKey + ? formState.formValues.apiKey + : provider.auth.stateDescription === "authenticated" + ? provider.auth.apiKey + : undefined, + isApiKeyEditable: formState.canEditApiKey, + availableModels: formState.availableModels?.map( + ({ id }) => id + ), + selectedModelIds: formState.selectedModelIds_draft, + isModelSelectionDisabled: + formState.isSubmitting || + provider.operationState === "pending" || + (provider.origin === "created by user" && + provider.isNameConflicting), + connectionError: (() => { + if ( + provider.auth.stateDescription === + "api-key not provided" + ) { + return { + title: t("api-key not provided"), + message: t("api-key not provided details") + }; + } + if (provider.operationState === "error") { + return { + title: t("refresh failed"), + message: t("refresh failed details") + }; + } + if (connectionTest.stateDescription === "failed") { + return { + title: t("connection failed"), + message: t("connection failed details") + }; + } + if (formState.hasSubmissionFailed) { + return { + title: t("save failed"), + message: t("save failed details") + }; + } + return undefined; + })(), + canTestConnection: formState.canTestConnection, + isTestingConnection: + connectionTest.stateDescription === "testing", + canRefreshCredentials: provider.canRefreshToken, + isRefreshingCredentials: + provider.operationState === "pending" || + provider.auth.stateDescription === "fetching", + canSave: formState.canSubmit, + canDelete: + isCreatedByUser && + provider.operationState !== "pending", + documentation: + provider.origin === "configured by admin" + ? provider.documentation + : undefined + }} + onProviderChange={providerName => form.open({ providerName })} + onClose={() => form.close()} + onNameChange={name => + form.changeValue({ key: "name", value: name }) + } + onProviderTypeChange={providerType => + form.changeProviderType({ providerType }) + } + onApiBaseChange={apiBase => + form.changeValue({ key: "apiBase", value: apiBase }) + } + onApiKeyChange={apiKey => + form.changeValue({ key: "apiKey", value: apiKey }) + } + onSelectedModelsChange={selectedModelIds => + form.changeSelectedModelIds({ selectedModelIds }) + } + onRefreshCredentials={() => + account.refreshToken({ providerName: provider.name }) + } + onTestConnection={() => form.testConnection()} + onSave={() => form.submit()} + onDelete={async () => { + const isConfirmed = await confirmProviderDeletion(); + + if (!isConfirmed) { + return; + } + + form.close(); + await account.deleteUserProvider({ + providerName: provider.name + }); + }} + /> + ); + })()} + + + + ); +} + +const useStyles = tss.withName({ AccountAiTab }).create(({ theme }) => { + return { + // The grid adapts to the width of the tab, not to the one of the window + root: { + containerType: "inline-size" + }, + providerGrid: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: theme.spacing(2), + // Below, two cards side by side would be too narrow + "@container (max-width: 760px)": { + gridTemplateColumns: "1fr" + } + }, + description: { + ...theme.typography.variants["body 1"].style, + color: theme.colors.useCases.typography.textSecondary, + "& > p": { + margin: 0 + } + }, + addCustomProviderButton: { + // Always on its own row, below the providers, across the whole tab + gridColumn: "1 / -1" + } + }; +}); + +const { i18n } = declareComponentKeys< + | "ai providers title" + | "default model" + | "save failed" + | "save failed details" + | "retry" + | "api-key not provided" + | "api-key not provided details" + | "provided by organization" + | "manage" + | "gateway error" + | "gateway error details" + | "custom providers section title" + | "add custom ai provider" + | "unreadable config title" + | "unreadable config" + | "reset config" + | "refresh failed" + | "refresh failed details" + | "connection failed" + | "connection failed details" +>()({ AccountAiTab }); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/account/AccountAiTab/AddCustomProviderButton.stories.tsx b/web/src/ui/pages/account/AccountAiTab/AddCustomProviderButton.stories.tsx new file mode 100644 index 000000000..1e14fe443 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/AddCustomProviderButton.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import { AddCustomProviderButton } from "./AddCustomProviderButton"; + +const meta = { + title: "Pages/Account/IA/AddCustomProviderButton", + component: AddCustomProviderButton, + args: { + label: "Add a new custom AI provider", + onClick: action("onClick") + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/web/src/ui/pages/account/AccountAiTab/AddCustomProviderButton.tsx b/web/src/ui/pages/account/AccountAiTab/AddCustomProviderButton.tsx new file mode 100644 index 000000000..47c30c8c8 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/AddCustomProviderButton.tsx @@ -0,0 +1,50 @@ +import { getIconUrlByName } from "lazy-icons"; +import { Icon } from "onyxia-ui/Icon"; +import { Text } from "onyxia-ui/Text"; +import { tss } from "tss"; + +type Props = { + className?: string; + label: string; + onClick: () => void; +}; + +/** Laid out like a `ProviderCard` so that it can take place in the providers grid. */ +export function AddCustomProviderButton(props: Props) { + const { className, label, onClick } = props; + + const { classes, cx } = useStyles(); + + return ( + + ); +} + +const useStyles = tss.withName({ AddCustomProviderButton }).create(({ theme }) => ({ + root: { + display: "flex", + alignItems: "center", + gap: theme.spacing(4), + width: "100%", + boxSizing: "border-box", + padding: theme.spacing(4), + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + borderRadius: theme.spacing(3), + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: "transparent", + textAlign: "left", + cursor: "pointer", + "&:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + "&:focus-visible": { + outline: `2px solid ${theme.colors.useCases.buttons.actionActive}`, + outlineOffset: 2 + } + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/ProviderCard.stories.tsx b/web/src/ui/pages/account/AccountAiTab/ProviderCard.stories.tsx new file mode 100644 index 000000000..7dc863712 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ProviderCard.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import { useState } from "react"; +import type { ThemedAssetUrl } from "onyxia-ui"; +import { ProviderCard, type ProviderState } from "./ProviderCard"; +import { providerTypeLogoUrl } from "./shared/providerTypeLogoUrl"; + +const models = [ + "gemma4-26b-moe", + "gwen3-6-35b-moe", + "qwen3-vl", + "qwen3-embedding-8b", + "chandra-ocr-2", + "qwen3-8-27b" +]; + +const onManage = action("Manage button clicked"); + +function ProviderCardStory(props: { + state: ProviderState; + name: string; + subtitle: string; + logoUrl: ThemedAssetUrl; +}) { + const [selectedModels, setSelectedModels] = useState(models.slice(0, 3)); + + return ( + + ); +} + +const meta = { + title: "Pages/Account/IA/ProviderCard", + component: ProviderCardStory, + decorators: [ + Story => ( +
+ +
+ ) + ], + args: { + state: "connected", + name: "SSPCloud LLM", + subtitle: "Provided by your organization", + logoUrl: "https://minio.lab.sspcloud.fr/ddecrulle/public/sspcloud-llm.png" + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Connected: Story = {}; + +export const SetupRequired: Story = { + args: { state: "setup required" } +}; + +export const ConnectionError: Story = { + args: { state: "connection error" } +}; + +export const CustomProvider: Story = { + args: { + name: "My Mistral", + subtitle: "Custom AI providers", + logoUrl: providerTypeLogoUrl["mistral"] + } +}; diff --git a/web/src/ui/pages/account/AccountAiTab/ProviderCard.tsx b/web/src/ui/pages/account/AccountAiTab/ProviderCard.tsx new file mode 100644 index 000000000..33b1c9ee5 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/ProviderCard.tsx @@ -0,0 +1,244 @@ +import { getIconUrlByName } from "lazy-icons"; +import { type ThemedAssetUrl, useResolveThemedAssetUrl } from "onyxia-ui"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { tss } from "tss"; +import { getFieldStyle } from "./shared/fieldStyle"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; +import { ModelsSelection } from "./shared/ModelsSelection"; + +import type { ProviderConnectionState } from "core/usecases/aiProvidersManagements/decoupledLogic"; + +export type ProviderState = ProviderConnectionState; + +type Props = { + className?: string; + name: string; + subtitle: string; + state: ProviderState; + modelSelector: { + isDisabled: boolean; + models: string[]; + selectedModels: string[]; + onSelectedModelsChange: (models: string[]) => void; + }; + manageLabel: string; + onManage: () => void; + /** Nothing is displayed in place of the logo when undefined */ + logoUrl?: ThemedAssetUrl; +}; + +export function ProviderCard(props: Props) { + const { + className, + name, + subtitle, + state, + modelSelector, + manageLabel, + onManage, + logoUrl + } = props; + + const { classes, cx } = useStyles(); + const { resolveThemedAssetUrl } = useResolveThemedAssetUrl(); + + return ( +
+
+
+ {logoUrl !== undefined && ( + + )} +
+ + {name} + + + {subtitle} + +
+ +
+
+ + +
+
+
+ ); +} + +/** Below this width, the card stacks its content */ +const narrowCardWidth = 420; + +const useStyles = tss.withName({ ProviderCard }).create(({ theme }) => ({ + // Its layout adapts to its own width, whatever the grid it is in + root: { + containerType: "inline-size", + boxSizing: "border-box", + padding: theme.spacing(4), + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + borderRadius: theme.spacing(3), + backgroundColor: theme.colors.useCases.surfaces.surface1 + }, + summary: { + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + gap: theme.spacing(4) + }, + header: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2.5), + minWidth: 0 + }, + logo: { + width: 48, + height: 48, + flexShrink: 0, + objectFit: "cover", + borderRadius: theme.spacing(2.5) + }, + identity: { + flex: 1, + minWidth: 0 + }, + ellipsis: { + margin: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" + }, + footer: { + display: "flex", + alignItems: "flex-end", + gap: theme.spacing(2), + [`@container (max-width: ${narrowCardWidth}px)`]: { + alignItems: "stretch", + flexDirection: "column" + } + }, + modelSelector: { + flex: 1, + minWidth: 0 + }, + manageButton: { + flexShrink: 0, + [`@container (max-width: ${narrowCardWidth}px)`]: { + alignSelf: "flex-end" + } + } +})); + +export function ProviderStateChip(props: { className?: string; state: ProviderState }) { + const { className, state } = props; + + const { classes, cx } = useStyles_ProviderStateChip(); + const { t } = useTranslation({ ProviderCard }); + + const stateLabel: Record = { + connected: t("connected"), + "setup required": t("setup required"), + "connection error": t("connection error") + }; + + return ( +
+ + + {stateLabel[state]} + +
+ ); +} + +const useStyles_ProviderStateChip = tss + .withName({ ProviderStateChip }) + .create(({ theme }) => ({ + status: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + flexShrink: 0, + padding: `${theme.spacing(1)}px ${theme.spacing(2.5)}px`, + ...getFieldStyle({ theme }).pill, + color: theme.colors.useCases.typography.textPrimary + }, + statusConnected: { + backgroundColor: theme.colors.useCases.alertSeverity.success.background + }, + statusSetupRequired: { + backgroundColor: theme.colors.useCases.alertSeverity.warning.background + }, + statusConnectionError: { + backgroundColor: theme.colors.useCases.alertSeverity.error.background + }, + statusDot: { + width: 8, + height: 8, + borderRadius: "50%" + }, + statusDotConnected: { + backgroundColor: theme.colors.useCases.alertSeverity.success.main + }, + statusDotSetupRequired: { + backgroundColor: theme.colors.useCases.alertSeverity.warning.main + }, + statusDotConnectionError: { + backgroundColor: theme.colors.useCases.alertSeverity.error.main + }, + statusLabel: { + whiteSpace: "nowrap", + // Only the colored dot is left when the card is narrow + [`@container (max-width: ${narrowCardWidth}px)`]: { + display: "none" + } + } + })); + +const { i18n } = declareComponentKeys< + "connected" | "setup required" | "connection error" +>()({ ProviderCard }); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/ConfirmCustomProviderDeletionDialog.tsx b/web/src/ui/pages/account/AccountAiTab/dialogs/ConfirmCustomProviderDeletionDialog.tsx new file mode 100644 index 000000000..ade5bc209 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/ConfirmCustomProviderDeletionDialog.tsx @@ -0,0 +1,78 @@ +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(({ theme }) => ({ + paper: { + borderRadius: theme.spacing(2.5) + } + })); diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialog.stories.tsx b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialog.stories.tsx new file mode 100644 index 000000000..ae3e165b4 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialog.stories.tsx @@ -0,0 +1,367 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import { Button } from "onyxia-ui/Button"; +import { useEffect, useRef, useState } from "react"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +import { providerTypeDefaultApiBase } from "core/usecases/aiProviderFormUiController/decoupledLogic/providerTypeDefaultApiBase"; +import { CustomProviderFormDialogView } from "./CustomProviderFormDialog"; +import type { AiModel, FormTest, FormValues } from "./types"; + +type MockedProps = { + /** Undefined to create a provider, otherwise the provider being edited. */ + editedProvider: + | { values: FormValues; availableModels: AiModel[]; selectedModels: string[] } + | undefined; + /** Names of the providers that already exist, a new name has to be unique. */ + existingProviderNames: string[]; + supportedProtocols: AiConfig.SupportedAiProviderType[]; + connectionTestResult: "success" | "failure"; + submissionResult: "success" | "failure"; + /** Delay of the simulated network calls, in milliseconds. */ + latency: number; +}; + +/** + * Mimics `aiProviderFormUiController` so that every interaction of the dialog + * can be tried without a core: validation, protocol prefill, connection test, model + * selection and submission. + */ +function MockedCustomProviderFormDialog(props: MockedProps) { + const [isOpen, setIsOpen] = useState(true); + // Like the `open` thunk, reopening the dialog starts from a fresh form. + const [openCount, setOpenCount] = useState(0); + + if (!isOpen) { + return ( +
+ +
+ ); + } + + return setIsOpen(false)} />; +} + +function MockedForm(props: MockedProps & { onClose: () => void }) { + const { + editedProvider, + existingProviderNames, + supportedProtocols, + connectionTestResult, + submissionResult, + latency, + onClose + } = props; + + const [values, setValues] = useState( + editedProvider?.values ?? { name: "", protocol: "", apiBase: "", apiKey: "" } + ); + const [test, setTest] = useState( + editedProvider === undefined + ? { stateDescription: "idle" } + : { stateDescription: "success", models: editedProvider.availableModels } + ); + const [selectedModels, setSelectedModels] = useState( + editedProvider?.selectedModels ?? [] + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [hasSubmissionError, setHasSubmissionError] = useState(false); + + // The simulated calls resolve against the latest values, like the thunks do. + const valuesRef = useRef(values); + valuesRef.current = values; + + const timeoutIds = useRef([]); + + useEffect(() => () => timeoutIds.current.forEach(clearTimeout), []); + + const simulateLatency = (callback: () => void) => { + timeoutIds.current.push(window.setTimeout(callback, latency)); + }; + + const isEditing = editedProvider !== undefined; + + const getProtocol = (protocol: string) => + supportedProtocols.find(supportedProtocol => supportedProtocol === protocol); + + const nameIsValid = (() => { + const name = values.name.trim(); + + return ( + name !== "" && + !name.includes("/") && + !existingProviderNames + .filter(providerName => providerName !== editedProvider?.values.name) + .includes(name) + ); + })(); + + const apiBaseIsValid = (() => { + try { + const { protocol } = new URL(values.apiBase.trim()); + + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } + })(); + + const canTest = + values.protocol !== "" && + apiBaseIsValid && + !isSubmitting && + test.stateDescription !== "testing"; + + const canSave = + nameIsValid && + values.protocol !== "" && + apiBaseIsValid && + !isSubmitting && + test.stateDescription !== "testing"; + + const changeValues = (values_new: FormValues) => { + const doesInvalidateTest = (["protocol", "apiBase", "apiKey"] as const).some( + key => values_new[key] !== values[key] + ); + + setValues(values_new); + setHasSubmissionError(false); + + if (doesInvalidateTest) { + setTest({ stateDescription: "idle" }); + } + }; + + return ( + { + action("onClose")(); + onClose(); + }} + onFieldChange={(key, value) => { + action("onFieldChange")(key, value); + changeValues({ ...values, [key]: value }); + }} + onProtocolChange={protocol => { + action("onProtocolChange")(protocol); + + const protocol_previous = getProtocol(values.protocol); + + const wasNameSuggested = + values.name.trim() === "" || + (protocol_previous !== undefined && + values.name === + suggestProviderName({ + protocol: protocol_previous, + existingProviderNames + })); + + changeValues({ + ...values, + protocol, + apiBase: providerTypeDefaultApiBase[protocol], + name: + isEditing || !wasNameSuggested + ? values.name + : suggestProviderName({ protocol, existingProviderNames }) + }); + }} + onTest={() => { + action("onTest")(); + + const values_tested = values; + const protocol = getProtocol(values.protocol); + + if (protocol === undefined) { + return; + } + + setTest({ stateDescription: "testing" }); + + simulateLatency(() => { + // The user kept typing while we were "fetching": drop the result. + if (valuesRef.current !== values_tested) { + return; + } + + if (connectionTestResult === "failure") { + setTest({ stateDescription: "error" }); + return; + } + + const models = getMockedModels(protocol); + + setTest({ stateDescription: "success", models }); + setSelectedModels(selectedModels => + selectedModels.filter(modelId => + models.some(model => model.id === modelId) + ) + ); + }); + }} + onSelectedModelsChange={models => { + action("onSelectedModelsChange")(models); + setSelectedModels(models); + }} + onSave={() => { + action("onSave")({ values, selectedModels }); + + setIsSubmitting(true); + setHasSubmissionError(false); + + simulateLatency(() => { + setIsSubmitting(false); + + if (submissionResult === "failure") { + setHasSubmissionError(true); + return; + } + + onClose(); + }); + }} + /> + ); +} + +const providerDisplayName: Record = { + deepseek: "DeepSeek", + openai: "OpenAI", + "openai-compatible": "OpenAI Compatible", + mistral: "Mistral", + anthropic: "Anthropic" +}; + +function suggestProviderName(params: { + protocol: AiConfig.SupportedAiProviderType; + existingProviderNames: string[]; +}): string { + const { protocol, existingProviderNames } = params; + + const baseName = providerDisplayName[protocol]; + + let suffix = 1; + let providerName = baseName; + + while (existingProviderNames.includes(providerName)) { + suffix += 1; + providerName = `${baseName} ${suffix}`; + } + + return providerName; +} + +function getMockedModels(protocol: AiConfig.SupportedAiProviderType): AiModel[] { + const modelIds = (() => { + switch (protocol) { + case "openai": + return ["gpt-5", "gpt-5-mini", "gpt-4.1", "text-embedding-3-large"]; + case "anthropic": + return ["claude-opus-4-1", "claude-sonnet-4-5", "claude-haiku-4-5"]; + case "mistral": + return [ + "mistral-large-latest", + "mistral-small-latest", + "codestral-latest" + ]; + case "deepseek": + return ["deepseek-chat", "deepseek-reasoner"]; + case "openai-compatible": + return [ + "gemma4-26b-moe", + "qwen3-6-35b-moe", + "qwen3-embedding-8b", + "qwen3-vl" + ]; + } + })(); + + return modelIds.map(id => ({ id })); +} + +const meta = { + title: "Pages/Account/IA/CustomProviderFormDialog", + component: MockedCustomProviderFormDialog, + parameters: { + layout: "fullscreen" + }, + argTypes: { + connectionTestResult: { + control: "inline-radio", + options: ["success", "failure"] + }, + submissionResult: { + control: "inline-radio", + options: ["success", "failure"] + }, + latency: { + control: { type: "range", min: 0, max: 5000, step: 100 } + } + }, + args: { + editedProvider: undefined, + existingProviderNames: ["SSPCloud LLM", "OpenAI"], + supportedProtocols: [ + "deepseek", + "openai", + "openai-compatible", + "mistral", + "anthropic" + ], + connectionTestResult: "success", + submissionResult: "success", + latency: 1200 + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Create: Story = {}; + +export const Edit: Story = { + args: { + editedProvider: { + values: { + name: "Custom Provider 1", + protocol: "openai-compatible", + apiBase: "https://llm.lab.sspcloud.fr/api", + apiKey: "storybook-api-key" + }, + availableModels: getMockedModels("openai-compatible"), + selectedModels: ["gemma4-26b-moe", "qwen3-6-35b-moe"] + }, + existingProviderNames: ["SSPCloud LLM", "Custom Provider 1"] + } +}; + +export const ConnectionFailure: Story = { + args: { + connectionTestResult: "failure" + } +}; + +export const SubmissionFailure: Story = { + args: { + submissionResult: "failure" + } +}; diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialog.tsx b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialog.tsx new file mode 100644 index 000000000..dea176395 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialog.tsx @@ -0,0 +1,101 @@ +import { getCoreSync, useCoreState } from "core"; +import { declareComponentKeys } from "i18nifty"; +import { memo } from "react"; +import { CustomProviderFormDialogView } from "./CustomProviderFormDialogView"; + +export { CustomProviderFormDialogView } from "./CustomProviderFormDialogView"; +export type { ViewProps } from "./types"; + +export const CustomProviderFormDialog = memo(() => { + const form = useCoreState("aiProviderFormUiController", "main"); + + const { + functions: { aiProviderFormUiController } + } = getCoreSync(); + + // Existing providers, whatever their origin, are managed by `ManageProvidersDialog` + if (!form.isOpen || form.isEditing) { + return null; + } + + return ( + aiProviderFormUiController.close()} + onFieldChange={(key, value) => { + if (key !== "protocol") + aiProviderFormUiController.changeValue({ key, value }); + }} + onProtocolChange={protocol => + aiProviderFormUiController.changeProviderType({ + providerType: protocol + }) + } + onTest={() => aiProviderFormUiController.testConnection()} + onSelectedModelsChange={selectedModelIds => + aiProviderFormUiController.changeSelectedModelIds({ + selectedModelIds + }) + } + onSave={() => aiProviderFormUiController.submit()} + hasSubmissionError={form.hasSubmissionFailed} + nameIsValid={form.isNameValid} + apiBaseIsValid={form.isApiBaseValid} + isSubmitting={form.isSubmitting} + /> + ); +}); + +const { i18n } = declareComponentKeys< + | "submission error" + | "submission error details" + | "invalid name" + | "invalid api base" + | "deepseek provider option" + | "add custom provider title" + | "edit custom provider title" + | "custom provider section title" + | "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" + | "provider test" + | "provider testing" + | "provider test success" + | "provider test error" + | "provider test error details" + | "provider save" + | "provider update" + | "provider cancel" + | "close aria label" +>()({ CustomProviderFormDialog }); + +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialogView.tsx b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialogView.tsx new file mode 100644 index 000000000..005cf39dc --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/CustomProviderFormDialogView.tsx @@ -0,0 +1,140 @@ +import { Button } from "onyxia-ui/Button"; +import { memo, type FormEventHandler, useState } from "react"; +import { tss } from "tss"; +import { useTranslation } from "ui/i18n"; +import { CredentialsSection, ProviderSection, VerificationSection } from "./FormSections"; +import { SideDialog } from "../../shared/SideDialog"; +import { AiAlert } from "../../shared/AiAlert"; +import type { ViewProps } from "./types"; + +export const CustomProviderFormDialogView = memo((props: ViewProps) => { + const { + isEditing, + values, + test, + selectedModels, + canSave, + canTest, + supportedProtocols, + onClose, + onFieldChange, + onProtocolChange, + onTest, + onSelectedModelsChange, + onSave, + hasSubmissionError, + nameIsValid, + apiBaseIsValid, + isSubmitting + } = props; + + const { classes } = useStyles(); + const { t } = useTranslation("CustomProviderFormDialog"); + const [isApiBaseValidationVisible, setIsApiBaseValidationVisible] = useState(false); + + const onSubmit: FormEventHandler = event => { + event.preventDefault(); + + if (canSave) { + onSave(); + } + }; + + return ( + +
+
+ onFieldChange("name", value)} + onProtocolChange={onProtocolChange} + nameError={ + nameIsValid === false && values.name !== "" + ? t("invalid name") + : undefined + } + /> + + { + if (key === "apiBase") setIsApiBaseValidationVisible(false); + onFieldChange(key, value); + }} + onApiBaseBlur={() => setIsApiBaseValidationVisible(true)} + apiBaseError={ + isApiBaseValidationVisible && + apiBaseIsValid === false && + values.apiBase !== "" + ? t("invalid api base") + : undefined + } + /> + + + {hasSubmissionError && ( + + )} +
+ +
+ + +
+
+
+ ); +}); + +const useStyles = tss.withName({ CustomProviderFormDialogView }).create(({ theme }) => ({ + root: { + height: "100%", + minHeight: 0, + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), + color: theme.colors.useCases.typography.textPrimary + }, + body: { + border: 0, + padding: 0, + margin: 0, + flex: 1, + minHeight: 0, + overflowY: "auto", + display: "flex", + flexDirection: "column", + gap: theme.spacing(4), + paddingBottom: theme.spacing(6) + }, + footer: { + flex: "none", + display: "flex", + justifyContent: "flex-end", + gap: theme.spacing(1), + paddingTop: theme.spacing(3), + borderTop: `1px solid ${theme.colors.useCases.surfaces.surface3}` + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/FormSections.tsx b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/FormSections.tsx new file mode 100644 index 000000000..6728c1472 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/FormSections.tsx @@ -0,0 +1,321 @@ +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 { ModelsSelection } from "../../shared/ModelsSelection"; +import { providerTypeLogoUrl } from "../../shared/providerTypeLogoUrl"; +import { FormSelectField, FormTextField } from "../../shared/FormFields"; +import { AiAlert } from "../../shared/AiAlert"; +import type { FormTest } from "./types"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; + +export function ProviderSection(props: { + name: string; + protocol: string; + supportedProtocols: readonly AiConfig.SupportedAiProviderType[]; + onNameChange: (value: string) => void; + onProtocolChange: (value: AiConfig.SupportedAiProviderType) => void; + nameError?: string; +}) { + const { + name, + protocol, + supportedProtocols, + onNameChange, + onProtocolChange, + nameError + } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + + const protocolOptions = ( + [ + { value: "deepseek", label: t("deepseek provider option") }, + { 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") } + ] satisfies { value: AiConfig.SupportedAiProviderType; label: string }[] + ) + .filter(({ value }) => supportedProtocols.includes(value)) + .map(option => ({ ...option, iconUrl: providerTypeLogoUrl[option.value] })); + + return ( + + { + const protocol = supportedProtocols.find( + protocol => protocol === value + ); + + if (protocol === undefined) { + return; + } + + onProtocolChange(protocol); + }} + options={protocolOptions} + /> + + + ); +} + +export function CredentialsSection(props: { + apiBase: string; + apiKey: string; + onFieldChange: (key: "apiBase" | "apiKey", value: string) => void; + apiBaseError?: string; + onApiBaseBlur?: () => void; +}) { + const { apiBase, apiKey, onFieldChange, apiBaseError, onApiBaseBlur } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + + return ( + + onFieldChange("apiBase", value)} + autoComplete="url" + errorMessage={apiBaseError} + onBlur={onApiBaseBlur} + /> + onFieldChange("apiKey", value)} + autoComplete="off" + isSensitive={true} + /> + + ); +} + +export function VerificationSection(props: { + test: FormTest; + canTest: boolean; + onTest: () => void; + selectedModels: string[]; + onSelectedModelsChange: (models: string[]) => void; +}) { + const { test, canTest, onTest, selectedModels, onSelectedModelsChange } = props; + const { t } = useTranslation("CustomProviderFormDialog"); + const { classes, theme } = useStyles(); + + return ( + + {t("provider test")} + + } + > + model.id) + : [] + } + selectedModels={selectedModels} + disabled={test.stateDescription !== "success"} + onSelectedModelsChange={onSelectedModelsChange} + /> + + {test.stateDescription === "testing" && ( +
+ + {t("provider testing")} +
+ )} + + {test.stateDescription === "success" && ( + {t("provider test success")} + )} + + {test.stateDescription === "error" && ( + + )} +
+ ); +} + +function FormSection(props: { + title: string; + subtitle?: string; + action?: ReactNode; + children: ReactNode; +}) { + const { title, subtitle, action, children } = props; + const { classes, cx } = useStyles(); + + return ( +
+
+ + {action} +
+
+ {children} +
+
+ ); +} + +function SectionHeading(props: { title: string; subtitle: string | undefined }) { + const { title, subtitle } = props; + const { classes } = useStyles_SectionHeading(); + + return ( +
+ {title} + {subtitle !== undefined && ( + + {subtitle} + + )} +
+ ); +} + +function SuccessMessage(props: { children: ReactNode }) { + const { children } = props; + const { classes } = useStyles_SuccessMessage(); + + return ( +
+ + {children} +
+ ); +} + +const useStyles = tss + .withName({ CustomProviderFormSections: FormSection }) + .create(({ theme }) => ({ + section: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2.5), + paddingBottom: theme.spacing(4), + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + "&:last-child": { + borderBottom: "none", + paddingBottom: 0 + } + }, + fields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(3) + }, + sectionWithAction: { + gap: theme.spacing(4) + }, + headingRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(6) + }, + testButton: { + flex: "none", + ...theme.typography.variants["label 2"].style, + borderWidth: 0, + padding: `${theme.spacing(1)}px ${theme.spacing(2.5)}px`, + // Figma's `surface-action-secondary`: the inverse of the current surface + backgroundColor: theme.colors.useCases.typography.textPrimary, + color: theme.colors.useCases.surfaces.background, + "&:hover": { + backgroundColor: theme.colors.useCases.typography.textPrimary, + color: theme.colors.useCases.surfaces.background + }, + "& .MuiButton-startIcon": { + marginLeft: 0, + marginRight: theme.spacing(1) + }, + "& .MuiButton-startIcon > *": { + width: theme.spacing(3), + height: theme.spacing(3) + }, + "&.Mui-disabled": { + backgroundColor: theme.colors.useCases.typography.textPrimary, + color: theme.colors.useCases.surfaces.background, + opacity: 0.3 + } + }, + testingMessage: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px` + } + })); + +const useStyles_SectionHeading = tss.withName({ SectionHeading }).create(({ theme }) => ({ + root: { + minWidth: 0, + flex: 1, + display: "flex", + flexDirection: "column", + gap: theme.spacing(1) + } +})); + +const useStyles_SuccessMessage = tss.withName({ SuccessMessage }).create(({ theme }) => ({ + root: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px`, + borderRadius: theme.spacing(2), + boxSizing: "border-box", + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: alpha(theme.colors.useCases.alertSeverity.success.main, 0.2) + }, + dot: { + flex: "none", + width: theme.spacing(3), + height: theme.spacing(3), + borderRadius: "50%", + backgroundColor: theme.colors.useCases.alertSeverity.success.main + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/index.ts b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/index.ts new file mode 100644 index 000000000..4a0b24ab0 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/index.ts @@ -0,0 +1 @@ +export * from "./CustomProviderFormDialog"; diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/types.ts b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/types.ts new file mode 100644 index 000000000..0efee0a89 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/CustomProviderFormDialog/types.ts @@ -0,0 +1,36 @@ +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; + +export type AiModel = import("core/tools/fetchAiModels").AiModel; + +export type FormValues = { + name: string; + protocol: string; + apiBase: string; + apiKey: string; +}; + +export type FormTest = + | { stateDescription: "idle" } + | { stateDescription: "testing" } + | { stateDescription: "success"; models: AiModel[] } + | { stateDescription: "error" }; + +export type ViewProps = { + isEditing: boolean; + hasSubmissionError?: boolean; + nameIsValid?: boolean; + apiBaseIsValid?: boolean; + isSubmitting?: boolean; + values: FormValues; + test: FormTest; + selectedModels: string[]; + canSave: boolean; + canTest: boolean; + supportedProtocols: readonly AiConfig.SupportedAiProviderType[]; + onClose: () => void; + onFieldChange: (key: keyof FormValues, value: string) => void; + onProtocolChange: (protocol: AiConfig.SupportedAiProviderType) => void; + onTest: () => void; + onSelectedModelsChange: (models: string[]) => void; + onSave: () => void; +}; diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ManageProvidersDialog.stories.tsx b/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ManageProvidersDialog.stories.tsx new file mode 100644 index 000000000..b6dfa9e81 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ManageProvidersDialog.stories.tsx @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ManageProvidersDialog } from "./ManageProvidersDialog"; + +const meta = { + title: "Pages/Account/IA/ManageProvidersDialog", + component: ManageProvidersDialog, + parameters: { + layout: "fullscreen" + }, + args: { + providerNames: ["SSP Cloud LLM", "OpenAI"], + provider: { + name: "SSP Cloud LLM", + subtitle: "Provided by your organization", + state: "connected", + configuration: undefined, + apiBase: "https://llm.example.test/api", + isApiBaseEditable: false, + apiBaseError: undefined, + apiKey: "storybook-api-key", + isApiKeyEditable: false, + availableModels: [ + "gemma4-26b-moe", + "qwen3-6-35b-moe", + "llama-3.3-70b", + "mistral-small" + ], + selectedModelIds: ["gemma4-26b-moe", "qwen3-6-35b-moe"], + isModelSelectionDisabled: false, + connectionError: undefined, + canTestConnection: true, + isTestingConnection: false, + canRefreshCredentials: true, + isRefreshingCredentials: false, + canSave: true, + canDelete: false, + documentation: { + mainText: { + en: "SSPCloud LLM uses Open WebUI to give you access to AI models and its OpenAI-compatible API.", + fr: "SSPCloud LLM utilise Open WebUI pour vous donner accès à des modèles d'IA et à son API compatible OpenAI." + }, + links: [ + { + label: { + en: "Provider documentation", + fr: "Documentation du provider" + }, + url: "https://docs.sspcloud.fr" + }, + { + label: { + en: "API documentation", + fr: "Documentation de l'API" + }, + url: "https://docs.openwebui.com/getting-started/api-endpoints" + }, + { + label: { + en: "Models documentation", + fr: "Documentation des modèles" + }, + url: "https://llm.lab.sspcloud.fr" + } + ] + } + }, + onProviderChange: () => {}, + onClose: () => {}, + onNameChange: () => {}, + onProviderTypeChange: () => {}, + onApiBaseChange: () => {}, + onApiKeyChange: () => {}, + onSelectedModelsChange: () => {}, + onRefreshCredentials: () => {}, + onTestConnection: () => {}, + onSave: () => {}, + onDelete: () => {} + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const EditableCredentials: Story = { + args: { + provider: { + ...meta.args.provider, + name: "OpenAI", + subtitle: "Provided by your organization", + apiKey: "", + isApiKeyEditable: true, + availableModels: undefined, + selectedModelIds: [], + canRefreshCredentials: false, + documentation: undefined + } + } +}; + +export const CustomProvider: Story = { + args: { + provider: { + ...meta.args.provider, + name: "OpenAI", + subtitle: "Custom AI providers", + configuration: { + name: "OpenAI", + providerType: "openai", + supportedProviderTypes: [ + "openai", + "openai-compatible", + "mistral", + "anthropic", + "deepseek" + ], + nameError: undefined + }, + apiBase: "https://api.openai.com/v1", + isApiBaseEditable: true, + apiKey: "", + isApiKeyEditable: true, + canRefreshCredentials: false, + canDelete: true, + documentation: undefined + } + } +}; + +export const TestingConnection: Story = { + args: { + provider: { + ...meta.args.provider, + canTestConnection: false, + isTestingConnection: true + } + } +}; + +export const ConnectionFailed: Story = { + args: { + provider: { + ...meta.args.provider, + state: "connection error", + connectionError: { + title: "Connection failed.", + message: "Please check your credentials or endpoint." + } + } + } +}; diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ManageProvidersDialog.tsx b/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ManageProvidersDialog.tsx new file mode 100644 index 000000000..c83122d9d --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ManageProvidersDialog.tsx @@ -0,0 +1,466 @@ +import { Link, MenuItem, Select } from "@mui/material"; +import { Button } from "onyxia-ui/Button"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { Icon } from "onyxia-ui/Icon"; +import { Text } from "onyxia-ui/Text"; +import { getIconUrlByName } from "lazy-icons"; +import { tss } from "tss"; +import { declareComponentKeys, useResolveLocalizedString, useTranslation } from "ui/i18n"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +import { copyToClipboard } from "ui/tools/copyToClipboard"; +import { ModelsSelection } from "../../shared/ModelsSelection"; +import { ProviderValueField } from "./ProviderValueField"; +import { ProviderStateChip, type ProviderState } from "../../ProviderCard"; +import { ProviderSection } from "../CustomProviderFormDialog/FormSections"; +import { SideDialog } from "../../shared/SideDialog"; +import { AiAlert } from "../../shared/AiAlert"; + +export type ManagedProvider = { + name: string; + subtitle: string; + state: ProviderState; + /** Only for the providers created by the user, which can be redefined */ + configuration: + | { + name: string; + providerType: AiConfig.SupportedAiProviderType | undefined; + supportedProviderTypes: readonly AiConfig.SupportedAiProviderType[]; + nameError: string | undefined; + } + | undefined; + apiBase: string; + isApiBaseEditable: boolean; + apiBaseError: string | undefined; + apiKey: string | undefined; + isApiKeyEditable: boolean; + /** undefined as long as the connection hasn't been successfully tested */ + availableModels: string[] | undefined; + selectedModelIds: string[]; + isModelSelectionDisabled: boolean; + connectionError: { title: string; message: string } | undefined; + canTestConnection: boolean; + isTestingConnection: boolean; + canRefreshCredentials: boolean; + isRefreshingCredentials: boolean; + canSave: boolean; + canDelete: boolean; + /** Written by the admin in the instance configuration */ + documentation: AiConfig.Documentation | undefined; +}; + +/** Nothing is saved before `onSave`: the edits are held by the caller until then. */ +export function ManageProvidersDialog(props: { + providerNames: string[]; + provider: ManagedProvider; + onProviderChange: (providerName: string) => void; + onClose: () => void; + onNameChange: (name: string) => void; + onProviderTypeChange: (providerType: AiConfig.SupportedAiProviderType) => void; + onApiBaseChange: (apiBase: string) => void; + onApiKeyChange: (apiKey: string) => void; + onSelectedModelsChange: (modelIds: string[]) => void; + onRefreshCredentials: () => void | Promise; + onTestConnection: () => void | Promise; + onSave: () => void | Promise; + onDelete: () => void | Promise; +}) { + const { t } = useTranslation({ ManageProvidersDialog }); + const { classes, cx } = useStyles(); + const { resolveLocalizedString } = useResolveLocalizedString(); + const { provider } = props; + + const onClose = props.onClose; + + return ( + +
+
+ + + {provider.subtitle} + + {provider.canDelete && ( + + )} +
+ +
+ {provider.configuration !== undefined && ( + + )} +
+
+
+ + {t("connection details title")} + + +
+ + {t("connection details helper")} + +
+
+ copyToClipboard(provider.apiBase)} + /> + {(provider.apiKey !== undefined || + provider.isApiKeyEditable) && ( + + copyToClipboard(provider.apiKey ?? "") + } + /> + )} +
+ {provider.connectionError !== undefined && ( + + )} +
+ {/* NOTE: Only for the OIDC token exchange authentication */} + {provider.canRefreshCredentials && ( + + )} + +
+
+ +
+
+ {t("manage models title")} + + {t("manage models helper")} + +
+ +
+ {provider.documentation !== undefined && ( +
+
+ + {t("documentation title")} + + + {resolveLocalizedString( + provider.documentation.mainText + )} + +
+ {provider.documentation.links.length !== 0 && ( +
+ {provider.documentation.links.map(link => ( + + + {resolveLocalizedString(link.label)} + + + + ))} +
+ )} +
+ )} +
+ +
+ + +
+
+
+ ); +} + +/** Below this width, the provider header stacks its content */ +const narrowDialogWidth = 480; + +const useStyles = tss.withName({ ManageProvidersDialog }).create(({ theme }) => ({ + // The header adapts to the width of the dialog, not to the one of the window + root: { + containerType: "inline-size", + height: "100%", + minHeight: 0, + display: "flex", + flexDirection: "column" + }, + providerHeader: { + flex: "none", + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + paddingBottom: theme.spacing(3), + [`@container (max-width: ${narrowDialogWidth}px)`]: { + alignItems: "flex-start", + flexDirection: "column" + } + }, + providerSelect: { + minWidth: 0, + color: theme.colors.useCases.typography.textPrimary, + ...theme.typography.variants["object heading"].style, + // NOTE: As specific as MUI's rule, which reserves room for the icon + "& .MuiSelect-select.MuiInputBase-input": { + padding: 0, + paddingRight: theme.spacing(4) + } + }, + deleteButton: { + flex: "none", + borderWidth: 0, + backgroundColor: theme.colors.useCases.surfaces.surface2, + color: theme.colors.useCases.typography.textPrimary + }, + providerSubtitle: { + flex: 1, + minWidth: 0, + overflow: "hidden", + color: theme.colors.useCases.typography.textSecondary, + textAlign: "right", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + [`@container (max-width: ${narrowDialogWidth}px)`]: { + textAlign: "left" + } + }, + scrollableContent: { + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", + gap: theme.spacing(4), + overflowY: "auto", + paddingBottom: theme.spacing(3) + }, + section: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(3) + }, + sectionHeading: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + sectionTitle: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2) + }, + sectionHelper: { + color: theme.colors.useCases.typography.textSecondary + }, + documentationSection: { + paddingTop: theme.spacing(4), + borderTop: `1px solid ${theme.colors.useCases.surfaces.surface2}` + }, + documentationLinks: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: theme.spacing(2) + }, + documentationLink: { + maxWidth: "100%", + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + borderRadius: theme.spacing(1), + backgroundColor: theme.colors.useCases.surfaces.surfaceFocus1, + color: theme.colors.useCases.typography.textFocus, + ...theme.typography.variants["caption"].style, + fontWeight: 500, + "& svg, & img": { + flexShrink: 0 + } + }, + documentationLinkLabel: { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" + }, + fields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2) + }, + sectionActions: { + display: "flex", + flexWrap: "wrap", + justifyContent: "flex-end", + gap: theme.spacing(2) + }, + refreshCredentialsButton: { + borderWidth: 0, + backgroundColor: theme.colors.useCases.surfaces.surface2, + color: theme.colors.useCases.typography.textPrimary + }, + // Figma's `surface-action-secondary`: the inverse of the current surface + testConnectionButton: { + borderWidth: 0, + backgroundColor: theme.colors.useCases.typography.textPrimary, + color: theme.colors.useCases.surfaces.background, + "&:hover": { + backgroundColor: theme.colors.useCases.typography.textPrimary, + color: theme.colors.useCases.surfaces.background + }, + "&.Mui-disabled": { + backgroundColor: theme.colors.useCases.typography.textPrimary, + color: theme.colors.useCases.surfaces.background, + opacity: 0.3 + } + }, + // Disabled while testing, but not faded out: the loader has to stay visible + testConnectionButton_testing: { + "&.Mui-disabled": { + opacity: 1 + } + }, + testConnectionLoader: { + marginRight: theme.spacing(2), + "&&": { + color: "inherit" + } + }, + footer: { + flex: "none", + display: "flex", + justifyContent: "flex-end", + gap: theme.spacing(1), + paddingTop: theme.spacing(3), + borderTop: `1px solid ${theme.colors.useCases.surfaces.surface3}` + } +})); + +const { i18n } = declareComponentKeys< + | "dialog title" + | "close aria label" + | "provider selector aria label" + | "documentation title" + | "connection details title" + | "connection details helper" + | "manage models title" + | "manage models helper" + | "api base url" + | "api key" + | "refresh credentials" + | "test connection" + | "delete provider" + | "cancel" + | "save changes" +>()({ ManageProvidersDialog }); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ProviderValueField.tsx b/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ProviderValueField.tsx new file mode 100644 index 000000000..8a45d7714 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/ProviderValueField.tsx @@ -0,0 +1,218 @@ +import { memo, useEffect, useState } from "react"; +import { useTranslation } from "ui/i18n"; +import { tss } from "tss"; +import { getFieldStyle } from "../../shared/fieldStyle"; +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; + onChange?: (value: string) => void; + onSave?: () => void | Promise; + saveLabel?: string; + disabled?: boolean; + errorMessage?: string; +}; + +export const ProviderValueField = memo((props: Props) => { + const { + label, + value, + onRequestCopy, + isSensitiveInformation = false, + onChange, + onSave, + saveLabel, + disabled = false, + errorMessage + } = 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); + }); + + const isEditable = onChange !== undefined; + + return ( +
{ + event.preventDefault(); + onSave?.(); + }} + > + + {label} + +
+ {isEditable ? ( + onChange(event.target.value)} + autoComplete="off" + placeholder={label} + aria-label={label} + /> + ) : ( + + {isHidden ? "•".repeat(Math.max(value.length, 30)) : value} + + )} + {isSensitiveInformation && ( + + )} + + {onSave !== undefined && ( + + )} +
+ {errorMessage !== undefined && ( + + {errorMessage} + + )} +
+ ); +}); + +const { i18n } = declareComponentKeys<"copy" | "copied">()({ ProviderValueField }); +export type I18n = typeof i18n; + +const useStyles = tss.withName({ ProviderValueField }).create(({ theme }) => { + const fieldStyle = getFieldStyle({ theme }); + + return { + root: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1) + }, + codeFrame: { + ...fieldStyle.frame, + ...fieldStyle.padding, + display: "flex", + alignItems: "center", + gap: theme.spacing(2.5), + minWidth: 0 + }, + codeFrameEditable: { + "&:hover": fieldStyle.frame_hover, + "&:focus-within": fieldStyle.frame_focused + }, + codeFrameReadOnly: fieldStyle.frame_readOnly, + codeFrameError: { + "&&": fieldStyle.frame_error + }, + 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", + color: theme.colors.useCases.typography.textPrimary + }, + codeFrameInput: { + flex: 1, + minWidth: 0, + border: 0, + outline: 0, + padding: 0, + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: "transparent", + ...theme.typography.variants["label 1"].style, + "&::placeholder": fieldStyle.placeholder, + "&:disabled": { + color: theme.colors.useCases.typography.textDisabled + } + }, + codeFrameIconButton: fieldStyle.embeddedControl, + codeFrameButton: { + ...fieldStyle.embeddedControl, + 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 + } + } + }, + saveButton: { + flexShrink: 0 + }, + errorMessage: { + color: theme.colors.useCases.alertSeverity.error.main + } + }; +}); diff --git a/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/index.ts b/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/index.ts new file mode 100644 index 000000000..394f69c9b --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/dialogs/ManageProvidersDialog/index.ts @@ -0,0 +1 @@ +export * from "./ManageProvidersDialog"; 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..7549816ed --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/index.ts @@ -0,0 +1 @@ +export { AccountAiTab as default } from "ui/pages/account/AccountAiTab/AccountAiTab"; diff --git a/web/src/ui/pages/account/AccountAiTab/shared/AiAlert.stories.tsx b/web/src/ui/pages/account/AccountAiTab/shared/AiAlert.stories.tsx new file mode 100644 index 000000000..2dd5ce1ec --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/AiAlert.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import { AiAlert } from "./AiAlert"; + +const meta = { + title: "Pages/Account/IA/AiAlert", + component: AiAlert, + args: { + title: "Connection failed.", + message: "Please check your credentials or endpoint." + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithAction: Story = { + args: { + title: "Unable to save your changes.", + message: "Your changes are kept on this page. Try again in a moment.", + action: { label: "Retry", onClick: action("onClick") } + } +}; diff --git a/web/src/ui/pages/account/AccountAiTab/shared/AiAlert.tsx b/web/src/ui/pages/account/AccountAiTab/shared/AiAlert.tsx new file mode 100644 index 000000000..32eccf7d1 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/AiAlert.tsx @@ -0,0 +1,85 @@ +import { alpha } from "@mui/material/styles"; +import { getIconUrlByName } from "lazy-icons"; +import { Button } from "onyxia-ui/Button"; +import { Icon } from "onyxia-ui/Icon"; +import { Text } from "onyxia-ui/Text"; +import { tss } from "tss"; + +type Props = { + className?: string; + title: string; + message: string; + /** What the user can do about it, laid out on the right of the text */ + action?: { + label: string; + onClick: () => void; + }; +}; + +/** Figma's "Alert" (type Error): every error of the AI providers tab is shown with it. */ +export function AiAlert(props: Props) { + const { className, title, message, action } = props; + + const { classes, cx } = useStyles(); + + return ( +
+ +
+ + {title} + + {message} +
+ {action !== undefined && ( + + )} +
+ ); +} + +const useStyles = tss.withName({ AiAlert }).create(({ theme }) => { + const color = theme.colors.useCases.alertSeverity.error.main; + + return { + root: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(2.5), + padding: theme.spacing(2.5), + boxSizing: "border-box", + border: `1px solid ${color}`, + borderRadius: theme.spacing(2.5), + backgroundColor: alpha(color, 0.2), + color: theme.colors.useCases.typography.textPrimary + }, + icon: { + flex: "none", + color + }, + text: { + flex: 1, + minWidth: 0, + display: "flex", + flexDirection: "column", + overflowWrap: "break-word" + }, + title: { + fontWeight: 600 + }, + action: { + flex: "none", + alignSelf: "center" + } + }; +}); diff --git a/web/src/ui/pages/account/AccountAiTab/shared/FormFields.stories.tsx b/web/src/ui/pages/account/AccountAiTab/shared/FormFields.stories.tsx new file mode 100644 index 000000000..78dba21b1 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/FormFields.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { FormSelectField } from "./FormFields"; + +const meta = { + title: "Pages/Account/IA/FormSelectField", + component: FormSelectField, + render: args => { + const [value, setValue] = useState(args.value); + + return ( +
+ +
+ ); + }, + args: { + label: "Choose a default model", + placeholder: "Choose a default model", + value: "", + onChange: () => {}, + options: ["SSP Cloud LLM", "Personal OpenAI provider"].map(providerName => ({ + groupLabel: providerName, + options: ["gemma4-26b-moe", "qwen3-6-35b-moe", "qwen3-vl"].map(modelId => ({ + value: `${providerName}/${modelId}`, + label: modelId, + selectedLabel: `${providerName}/${modelId}` + })) + })) + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** The default model picker: the models grouped by provider */ +export const Grouped: Story = {}; + +export const Selected: Story = { + args: { value: "SSP Cloud LLM/qwen3-vl" } +}; diff --git a/web/src/ui/pages/account/AccountAiTab/shared/FormFields.tsx b/web/src/ui/pages/account/AccountAiTab/shared/FormFields.tsx new file mode 100644 index 000000000..86f68c92d --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/FormFields.tsx @@ -0,0 +1,273 @@ +import FormControl from "@mui/material/FormControl"; +import FormHelperText from "@mui/material/FormHelperText"; +import Input from "@mui/material/Input"; +import InputAdornment from "@mui/material/InputAdornment"; +import ListSubheader from "@mui/material/ListSubheader"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import { getIconUrlByName } from "lazy-icons"; +import { type ThemedAssetUrl, useResolveThemedAssetUrl } from "onyxia-ui"; +import { IconButton } from "onyxia-ui/IconButton"; +import { useId, useState } from "react"; +import { tss } from "tss"; +import { getFieldStyle } from "./fieldStyle"; + +export function FormTextField(props: { + label: string; + value: string; + onChange: (value: string) => void; + autoComplete: string; + isSensitive?: boolean; + errorMessage?: string; + onBlur?: () => void; +}) { + const { + label, + value, + onChange, + autoComplete, + isSensitive = false, + errorMessage, + onBlur + } = props; + const inputId = useId(); + const helperTextId = useId(); + const { classes } = useStyles(); + const [isValueVisible, setIsValueVisible] = useState(!isSensitive); + + return ( + + + onChange(event.target.value)} + onBlur={onBlur} + type={isValueVisible ? "text" : "password"} + fullWidth={true} + disableUnderline={true} + autoComplete={autoComplete} + inputProps={{ + "aria-describedby": + errorMessage === undefined ? undefined : helperTextId + }} + endAdornment={ + isSensitive && ( + + setIsValueVisible(!isValueVisible)} + /> + + ) + } + /> + {errorMessage !== undefined && ( + {errorMessage} + )} + + ); +} + +export type FormSelectOption = { + value: string; + label: string; + iconUrl?: ThemedAssetUrl; + /** What the field shows once the option is picked, `label` by default */ + selectedLabel?: string; +}; + +export type FormSelectOptionGroup = { + groupLabel: string; + options: FormSelectOption[]; +}; + +export function FormSelectField(props: { + label: string; + value: string; + onChange: (value: string) => void; + options: (FormSelectOption | FormSelectOptionGroup)[]; + /** Shown, as such, when the value matches no option */ + placeholder?: string; +}) { + const { label, value, onChange, options, placeholder } = props; + const labelId = useId(); + const { classes, cx } = useStyles(); + const { resolveThemedAssetUrl } = useResolveThemedAssetUrl(); + + const options_flat = options.flatMap(option => + "groupLabel" in option ? option.options : [option] + ); + + const renderOptionIcon = (option: FormSelectOption) => + option.iconUrl !== undefined && ( + + ); + + const renderOption = (option: FormSelectOption) => ( + + {renderOptionIcon(option)} + {option.label} + + ); + + return ( + + + + value={value} + displayEmpty={true} + onChange={event => onChange(event.target.value)} + labelId={labelId} + renderValue={selectedValue => { + const option = options_flat.find( + option => option.value === selectedValue + ); + + if (option === undefined) { + return placeholder === undefined ? ( + selectedValue + ) : ( + {placeholder} + ); + } + + return ( + <> + {renderOptionIcon(option)} + {option.selectedLabel ?? option.label} + + ); + }} + MenuProps={{ + slotProps: { paper: { className: classes.menu } }, + MenuListProps: { className: classes.menuList } + }} + > + {/* NOTE: Select wants its items as direct children, not in fragments */} + {options.flatMap(option => + "groupLabel" in option + ? [ + + {option.groupLabel} + , + ...option.options.map(renderOption) + ] + : [renderOption(option)] + )} + + + ); +} + +const useStyles = tss + .withName({ CustomProviderFormFields: FormTextField }) + .create(({ theme }) => { + const fieldStyle = getFieldStyle({ theme }); + + return { + control: { + gap: theme.spacing(1) + }, + label: { + ...theme.typography.variants["label 1"].style, + color: theme.colors.useCases.typography.textPrimary + }, + input: { + // NOTE: Override MUI's `label + .MuiInput-formControl` top margin + "&&": { + marginTop: 0 + }, + ...fieldStyle.frame, + paddingRight: theme.spacing(2.5), + "&:hover": fieldStyle.frame_hover, + "&.Mui-focused": fieldStyle.frame_focused, + "&.Mui-error": fieldStyle.frame_error, + "& .MuiInputBase-input": { + ...theme.typography.variants["label 1"].style, + ...fieldStyle.padding, + height: "auto", + color: theme.colors.useCases.typography.textPrimary, + "&::placeholder": fieldStyle.placeholder + } + }, + select: { + "& .MuiInputBase-root": { + ...fieldStyle.frame, + "&:hover": fieldStyle.frame_hover, + "&.Mui-focused": fieldStyle.frame_focused + }, + // The frame carries the border, MUI's outline is not used + "& .MuiOutlinedInput-notchedOutline": { + display: "none" + }, + // NOTE: As specific as MUI's rule, which reserves room for the icon + "& .MuiInputBase-root .MuiSelect-select.MuiInputBase-input": { + ...theme.typography.variants["label 1"].style, + ...fieldStyle.padding, + paddingRight: theme.spacing(6), + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + minHeight: "unset" + }, + "& .MuiSelect-icon": { + color: theme.colors.useCases.typography.textPrimary, + right: theme.spacing(2.5) + } + }, + menu: fieldStyle.menuPaper, + menuList: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), + padding: theme.spacing(2) + }, + // NOTE: In a scrolling flex column: their height is their content's + groupLabel: { + flexShrink: 0, + ...theme.typography.variants["label 1"].style, + padding: theme.spacing(2), + borderRadius: theme.spacing(2), + backgroundColor: theme.colors.useCases.surfaces.background, + color: theme.colors.useCases.typography.textTertiary + }, + placeholder: fieldStyle.placeholder, + menuItem: { + flexShrink: 0, + ...theme.typography.variants["label 1"].style, + minHeight: "auto", + gap: theme.spacing(2), + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + borderRadius: theme.spacing(2), + "&.Mui-selected, &.Mui-selected:hover, &.Mui-focusVisible": { + backgroundColor: theme.colors.useCases.surfaces.surfaceFocus1 + } + }, + optionIcon: { + width: theme.iconSizesInPxByName.default, + height: theme.iconSizesInPxByName.default, + flexShrink: 0, + objectFit: "contain" + } + }; + }); diff --git a/web/src/ui/pages/account/AccountAiTab/shared/ModelsSelection.stories.tsx b/web/src/ui/pages/account/AccountAiTab/shared/ModelsSelection.stories.tsx new file mode 100644 index 000000000..afdcfac98 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/ModelsSelection.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { ModelsSelection } from "./ModelsSelection"; + +const models = [ + "gemma4-26b-moe", + "gwen3-6-35b-moe", + "qwen3-vl", + "qwen3-embedding-8b", + "chandra-ocr-2", + "qwen3-8-27b" +]; + +const meta = { + title: "Pages/Account/IA/ModelSelection", + component: ModelsSelection, + render: args => { + const [selectedModels, setSelectedModels] = useState(args.selectedModels); + + return ( +
+ +
+ ); + }, + args: { + onSelectedModelsChange: () => {}, + models, + selectedModels: models.slice(0, 3), + disabled: false + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Interactive: Story = {}; + +export const NoModels: Story = { + args: { selectedModels: [], models: [] } +}; + +export const Disabled: Story = { + args: { disabled: true } +}; + +/** Enough models for the list to scroll */ +export const ManyModels: Story = { + args: { + models: Array.from({ length: 40 }, (_, i) => `model-${i + 1}`), + selectedModels: ["model-1", "model-2", "model-3", "model-4", "model-5"] + } +}; diff --git a/web/src/ui/pages/account/AccountAiTab/shared/ModelsSelection.tsx b/web/src/ui/pages/account/AccountAiTab/shared/ModelsSelection.tsx new file mode 100644 index 000000000..dbf45231b --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/ModelsSelection.tsx @@ -0,0 +1,226 @@ +import { memo, useId, useState } from "react"; +import { Autocomplete, Checkbox, Stack, TextField } from "@mui/material"; +import { Text } from "onyxia-ui/Text"; +import { useTranslation, declareComponentKeys } from "ui/i18n"; +import { tss } from "tss"; +import { getFieldStyle } from "./fieldStyle"; + +export type Props = { + className?: string; + models: string[]; + selectedModels: string[]; + disabled: boolean; + onSelectedModelsChange: (models: string[]) => void | Promise; +}; +export const ModelsSelection = memo((props: Props) => { + const { t } = useTranslation({ ModelsSelection }); + const { classes, cx } = useStyles(); + const labelId = useId(); + + // NOTE: The models are the source of truth, whatever the reason there is none + // (not fetched yet, fetch error...), nothing can be selected. + const hasNoModels = props.models.length === 0; + + const isReadOnly = props.disabled || hasNoModels; + + const [isOpen, setIsOpen] = useState(false); + + return ( + + + {t("model label")} + + setIsOpen(true)} + onClose={() => setIsOpen(false)} + limitTags={3} + getLimitTagsText={count => t("more models", { count })} + options={props.models} + value={props.selectedModels} + disabled={isReadOnly} + clearText={t("deselect all")} + noOptionsText={t("no matching models")} + slotProps={{ + paper: { className: classes.paper }, + listbox: { className: classes.listbox } + }} + onChange={(_event, modelIds) => { + props.onSelectedModelsChange(modelIds); + }} + renderOption={(optionProps, modelId, { selected }) => { + // React wants the key passed directly, not spread with the rest + const { key, ...optionProps_rest } = optionProps; + + return ( +
  • + + {modelId} +
  • + ); + }} + renderInput={params => ( + { + if (hasNoModels) { + return t("no models available"); + } + + return props.selectedModels.length === 0 + ? t("model label") + : undefined; + })()} + slotProps={{ + input: { + disableUnderline: true, + ...params.InputProps, + onMouseDown: event => { + params.InputProps.onMouseDown?.(event); + + // NOTE: MUI only opens on a press on the field itself, + // the tags cover most of it. + if ( + !isReadOnly && + event.target instanceof Element && + event.target.closest(".MuiAutocomplete-tag") !== + null && + event.target.closest(".MuiChip-deleteIcon") === + null + ) { + setIsOpen(true); + } + } + }, + htmlInput: { + ...params.inputProps, + "aria-labelledby": labelId + } + }} + /> + )} + /> +
    + ); +}); + +const useStyles = tss.withName({ ModelsSelection }).create(({ theme }) => { + const fieldStyle = getFieldStyle({ theme }); + + return { + root: { + gap: theme.spacing(1) + }, + autocomplete: { + "& .MuiFilledInput-root": { + ...fieldStyle.frame, + ...fieldStyle.padding, + flexWrap: "nowrap", + overflow: "hidden" + }, + "& .MuiFilledInput-root:hover, & .MuiFilledInput-root.Mui-focused": + fieldStyle.frame_hover, + "& .MuiFilledInput-root.Mui-disabled": fieldStyle.frame_readOnly, + "& .MuiInputBase-input::placeholder": fieldStyle.placeholder, + // NOTE: As specific as MUI's rules: the text input only takes the room left + "& .MuiFilledInput-root .MuiAutocomplete-input.MuiInputBase-input": { + minWidth: 0, + width: 0, + padding: 0 + }, + "& .MuiAutocomplete-tag": { + ...fieldStyle.pill, + ...fieldStyle.embeddedControl, + height: "auto", + marginLeft: 0, + marginRight: theme.spacing(1), + "& .MuiChip-label": { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1) + }, + backgroundColor: theme.colors.useCases.surfaces.surface2, + color: theme.colors.useCases.typography.textPrimary + }, + // NOTE: Read-only, not greyed out: the selected models must stay readable + "& .MuiAutocomplete-tag.Mui-disabled": { + opacity: 1 + }, + // A long model name is truncated only when there is no room left + "& .MuiChip-root.MuiAutocomplete-tag": { + flexShrink: 1, + minWidth: 0 + }, + "& span.MuiAutocomplete-tag": { + height: "auto", + flexShrink: 0, + marginRight: theme.spacing(1), + padding: 0, + borderRadius: 0, + ...theme.typography.variants["body 1"].style, + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: "transparent", + whiteSpace: "nowrap" + }, + "& .MuiAutocomplete-clearIndicator": { + visibility: "visible", + opacity: 1, + color: theme.colors.useCases.typography.textPrimary + }, + "& .MuiAutocomplete-popupIndicator": { + color: theme.colors.useCases.typography.textPrimary + }, + "& .MuiAutocomplete-popupIndicator.Mui-disabled": { + color: theme.colors.useCases.typography.textDisabled + } + }, + paper: fieldStyle.menuPaper, + listbox: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), + // NOTE: Doubled class, to win over MUI's listbox and option rules + "&&": { + padding: theme.spacing(2) + }, + "& .MuiAutocomplete-option.MuiAutocomplete-option": { + minHeight: "auto", + // In a scrolling flex column: its height is its content's, never less + flexShrink: 0, + alignItems: "center", + gap: theme.spacing(3), + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + borderRadius: theme.spacing(2), + ...theme.typography.variants["label 1"].style, + color: theme.colors.useCases.typography.textPrimary + }, + "& .MuiAutocomplete-option.Mui-focused, & .MuiAutocomplete-option[aria-selected='true'], & .MuiAutocomplete-option[aria-selected='true'].Mui-focused": + { + backgroundColor: theme.colors.useCases.surfaces.surfaceFocus1 + } + }, + checkbox: { + padding: 0 + } + }; +}); +const { i18n } = declareComponentKeys< + | "model label" + | "no models available" + | "no matching models" + | "deselect all" + | { K: "more models"; P: { count: number } } +>()({ ModelsSelection }); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/account/AccountAiTab/shared/SideDialog.tsx b/web/src/ui/pages/account/AccountAiTab/shared/SideDialog.tsx new file mode 100644 index 000000000..a066e90d8 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/SideDialog.tsx @@ -0,0 +1,107 @@ +import { useId, type ReactNode } from "react"; +import Drawer from "@mui/material/Drawer"; +import { alpha } from "@mui/material/styles"; +import { getIconUrlByName } from "lazy-icons"; +import { breakpointsValues } from "onyxia-ui"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Text } from "onyxia-ui/Text"; +import { tss } from "tss"; + +/** + * A panel floating on the right of the screen. The focus trap, the scroll lock, + * closing with Escape or by clicking outside are handled by MUI's Drawer. + */ +export function SideDialog(props: { + title: ReactNode; + closeLabel: string; + onClose: () => void; + children: ReactNode; +}) { + const { children, title, closeLabel, onClose } = props; + const { classes } = useStyles(); + const titleId = useId(); + + return ( + +
    + + {title} + + +
    + +
    {children}
    +
    + ); +} + +const useStyles = tss.withName({ SideDialog }).create(({ theme }) => ({ + backdrop: { + backgroundColor: alpha(theme.colors.useCases.surfaces.background, 0.7), + backdropFilter: "blur(1px)" + }, + // NOTE: Positioned by its insets, its size follows from the viewport it floats in + panel: { + top: theme.spacing(4), + right: theme.spacing(4), + bottom: theme.spacing(4), + height: "auto", + // The width of the mockup, as long as the screen is wide enough + width: 657, + maxWidth: "100%", + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), + boxSizing: "border-box", + padding: `${theme.spacing(4)}px ${theme.spacing(5)}px`, + borderRadius: theme.spacing(3), + backgroundColor: theme.colors.useCases.surfaces.surface1, + backgroundImage: "none", + boxShadow: theme.shadows[1], + [`@media (max-width: ${breakpointsValues.sm}px)`]: { + inset: 0, + borderRadius: 0, + padding: `${theme.spacing(4)}px ${theme.spacing(3)}px` + } + }, + header: { + flex: "none", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2) + }, + title: { + flex: 1, + minWidth: 0, + color: theme.colors.useCases.typography.textPrimary + }, + closeButton: { + flex: "none", + padding: 0 + }, + childrenWrapper: { + flex: 1, + minHeight: 0, + overflow: "hidden" + } +})); diff --git a/web/src/ui/pages/account/AccountAiTab/shared/fieldStyle.ts b/web/src/ui/pages/account/AccountAiTab/shared/fieldStyle.ts new file mode 100644 index 000000000..057a278bd --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/fieldStyle.ts @@ -0,0 +1,65 @@ +import type { Theme } from "ui/theme"; + +/** + * The look shared by every field of the AI providers tab (Figma's "CodeFrame"). + * No height is set: it follows from the padding and the line height of the content. + */ +export function getFieldStyle(params: { theme: Theme }) { + const { theme } = params; + + return { + frame: { + boxSizing: "border-box", + borderRadius: theme.spacing(2), + border: "2px solid transparent", + backgroundColor: theme.colors.useCases.surfaces.background, + color: theme.colors.useCases.typography.textPrimary, + transition: "background-color 160ms ease, border-color 160ms ease" + }, + padding: { + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + paddingLeft: theme.spacing(2.5), + paddingRight: theme.spacing(2.5) + }, + frame_hover: { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + frame_focused: { + borderColor: theme.colors.useCases.buttons.actionActive + }, + frame_error: { + borderColor: theme.colors.useCases.alertSeverity.error.main + }, + frame_readOnly: { + // NOTE: Not surface2: in dark mode it is darker than the card, the border would not show + borderColor: theme.colors.useCases.surfaces.surface3, + backgroundColor: "transparent" + }, + /** + * For a button or a chip inside a field: it overlaps the padding of the field + * instead of making it taller, so that every field has the height of a line. + */ + embeddedControl: { + marginTop: -theme.spacing(2), + marginBottom: -theme.spacing(2) + }, + placeholder: { + ...theme.typography.variants["body 1"].style, + color: theme.colors.useCases.typography.textSecondary, + opacity: 1 + }, + /** The dropdown of a select or an autocomplete */ + menuPaper: { + marginTop: theme.spacing(1), + borderRadius: theme.spacing(2.5), + backgroundColor: theme.colors.useCases.surfaces.surface1, + backgroundImage: "none", + boxShadow: theme.shadows[2] + }, + /** A pill, whatever its height */ + pill: { + borderRadius: 9999 + } + } as const; +} diff --git a/web/src/ui/pages/account/AccountAiTab/shared/providerTypeLogoUrl.ts b/web/src/ui/pages/account/AccountAiTab/shared/providerTypeLogoUrl.ts new file mode 100644 index 000000000..86f9610c7 --- /dev/null +++ b/web/src/ui/pages/account/AccountAiTab/shared/providerTypeLogoUrl.ts @@ -0,0 +1,28 @@ +import type { ThemedAssetUrl } from "onyxia-ui"; +import type { AiConfig } from "core/ports/OnyxiaApi/AiConfig"; +// NOTE: Logos from LobeHub Icons (MIT): https://github.com/lobehub/lobe-icons +import anthropicDarkLogoUrl from "ui/assets/img/ai-providers/anthropic-dark.svg"; +import anthropicLightLogoUrl from "ui/assets/img/ai-providers/anthropic-light.svg"; +import deepseekLogoUrl from "ui/assets/img/ai-providers/deepseek.svg"; +import mistralLogoUrl from "ui/assets/img/ai-providers/mistral.svg"; +import openaiDarkLogoUrl from "ui/assets/img/ai-providers/openai-dark.svg"; +import openaiLightLogoUrl from "ui/assets/img/ai-providers/openai-light.svg"; + +const openaiLogoUrl: ThemedAssetUrl = { + light: openaiLightLogoUrl, + dark: openaiDarkLogoUrl +}; + +export const providerTypeLogoUrl: Record< + AiConfig.SupportedAiProviderType, + ThemedAssetUrl +> = { + deepseek: deepseekLogoUrl, + openai: openaiLogoUrl, + "openai-compatible": openaiLogoUrl, + mistral: mistralLogoUrl, + anthropic: { + light: anthropicLightLogoUrl, + dark: anthropicDarkLogoUrl + } +}; diff --git a/web/src/ui/pages/account/Page.tsx b/web/src/ui/pages/account/Page.tsx index 0ee464afc..67f4d544d 100644 --- a/web/src/ui/pages/account/Page.tsx +++ b/web/src/ui/pages/account/Page.tsx @@ -1,9 +1,8 @@ -import { Suspense, lazy } from "react"; +import { Suspense, lazy, useEffect, useMemo } from "react"; import { Tabs } from "onyxia-ui/Tabs"; -import { type AccountTabId, accountTabIds } from "./accountTabIds"; -import { useMemo } from "react"; +import { type AccountTabId, accountTabIds } from "ui/pages/account/accountTabIds"; import { routes, useRoute } from "ui/routes"; -import { routeGroup } from "./route"; +import { routeGroup } from "ui/pages/account/route"; import { useTranslation } from "ui/i18n"; import { PageHeader } from "onyxia-ui/PageHeader"; import { useConstCallback } from "powerhooks/useConstCallback"; @@ -22,6 +21,7 @@ const Page = withLoader({ }); export default Page; +const AccountAiGatewayTab = lazy(() => import("./AccountAiTab")); const AccountGitTab = lazy(() => import("./AccountGitTab")); const AccountKubernetesTab = lazy(() => import("./AccountKubernetesTab")); const AccountProfileTab = lazy(() => import("./AccountProfileTab")); @@ -35,19 +35,20 @@ function Account() { const { t } = useTranslation({ Account }); const { - functions: { k8sCodeSnippets, vaultCredentials } + functions: { k8sCodeSnippets, vaultCredentials, aiAccountUiController } } = getCoreSync(); const tabs = useMemo( () => accountTabIds - .filter(accountTabId => - accountTabId !== "k8sCodeSnippets" - ? true - : k8sCodeSnippets.getIsAvailable() + .filter( + accountTabId => + accountTabId !== "k8sCodeSnippets" || + k8sCodeSnippets.getIsAvailable() ) - .filter(accountTabId => - accountTabId !== "vault" ? true : vaultCredentials.isAvailable() + .filter( + accountTabId => + accountTabId !== "vault" || vaultCredentials.isAvailable() ) .filter(accountTabId => { if (env.ONYXIA_API_URL !== undefined) { @@ -56,6 +57,10 @@ function Account() { return accountTabId === "user-interface"; }) + .filter( + accountTabId => + accountTabId !== "ai" || aiAccountUiController.isAvailable() + ) .map(id => ({ id, title: t(id) })), [t] ); @@ -66,13 +71,19 @@ function Account() { const { classes } = useStyles(); + const fallbackTab = tabs.at(0); + assert(fallbackTab !== undefined); + const activeTabId = - route.params.tabId ?? - (() => { - const tab = tabs.at(0); - assert(tab !== undefined); - return tab.id; - })(); + tabs.find(tab => tab.id === route.params.tabId)?.id ?? fallbackTab.id; + + useEffect(() => { + if (route.params.tabId === undefined || route.params.tabId === activeTabId) { + return; + } + + routes.account({ tabId: activeTabId }).replace(); + }, [route.params.tabId, activeTabId]); return (
    @@ -104,6 +115,8 @@ function Account() { return ; case "vault": return ; + case "ai": + return ; default: 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/ui/shared/formattedDate/getFormattedDate.test.ts b/web/src/ui/shared/formattedDate/getFormattedDate.test.ts index 481f97853..c81ef2b2e 100644 --- a/web/src/ui/shared/formattedDate/getFormattedDate.test.ts +++ b/web/src/ui/shared/formattedDate/getFormattedDate.test.ts @@ -49,7 +49,9 @@ describe("getFormattedDate", () => { const formattedDate = getFormattedRelativeDate({ time, lang: "en", now }); - expect(formattedDate).toBe("Today at 10:25\u202fAM"); + // Node 18/20/22 (CLDR 46/47) → U+202F + // Node 26 (CLDR 48) → U+0020 + expect(formattedDate.replace(/[\u00A0\u202F]/g, " ")).toBe("Today at 10:25 AM"); }); it("formats yesterday's date relative to now", () => { diff --git a/web/src/ui/tools/withLoader.tsx b/web/src/ui/tools/withLoader.tsx index e80a59bc6..bb44dfcfd 100644 --- a/web/src/ui/tools/withLoader.tsx +++ b/web/src/ui/tools/withLoader.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ComponentType, type FC } from "react"; +import { useEffect, useState, type ComponentType, type FC, Suspense } from "react"; import { use } from "./use"; import { assert } from "tsafe"; @@ -34,12 +34,17 @@ export function withLoader>(params: { }; }, []); + assert(FallbackComponent !== undefined); + if (!isLoaded) { - assert(FallbackComponent !== undefined); return ; } - return ; + return ( + }> + + + ); } function ComponentWithLoader_Suspense(props: Props) { diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 5f65516a4..72edbf894 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 DISABLE_DISPLAY_ALL_CATALOG: string S3: string + AI: string ONYXIA_API_URL: string ONYXIA_VERSION: string ONYXIA_VERSION_URL: string @@ -68,15 +69,15 @@ type ImportMetaEnv = { OIDC_DISABLE_DPOP: string OIDC_SESSION_RESTORATION_METHOD: string // @user-defined-start - /* - * Here you can define your own special variables - * that would be available on `import.meta.env` but - * that vite-envs does not know about. - * This section will be preserved thanks to the special comments. - * Example: - */ - // SSR: boolean; - // @user-defined-end + /* + * Here you can define your own special variables + * that would be available on `import.meta.env` but + * that vite-envs does not know about. + * This section will be preserved thanks to the special comments. + * Example: + */ + // SSR: boolean; + // @user-defined-end } interface ImportMeta { @@ -92,5 +93,5 @@ interface ImportMeta { } interface Window { - kcContext?: import("./keycloak-theme/login/KcContext").KcContext; + kcContext?: import("./keycloak-theme/login/KcContext").KcContext; }