mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(settings): AI provider section with single dropdown driving form below
Replaces the v1.4.15-vintage two-card layout (Codex connect at the top, personal-provider form at the bottom) with the dropdown-driven UX called out in feedback_settings_no_split.md: one Pulldown picks the active provider, the form below adapts to that choice, and the fallback chain at the bottom reorders / toggles entries with arrow controls. Five provider config forms live in this file (Codex, OpenAI, Anthropic, Local, Admin OpenAI) and share a single card frame so transitions feel like updating one concept rather than swapping pages. The Codex form keeps the device-code flow + OAuth callback handling unchanged. OpenAI gets a model dropdown plus a collapsed Base URL override (advanced). Anthropic and Local mirror that shape with the right credentials. The fallback chain calls the new PUT /api/insights/provider-chain to persist reorders. The Select is URL-synced via ?provider=… so deep links work, the SSR test can drive each branch deterministically, and a refresh keeps the user on the form they were editing. Native <select> elements keep the SSR-only render-to-static-markup test happy without bringing a portal-based Radix tree into play. No new dependency — dnd-kit is not in package.json so reorder uses up/down arrow buttons. EN + DE i18n keys land alongside; the i18n parity guard test sees both locales and the existing settings.ai.providerChain.types.* tree keeps the per-provider labels stable across the chain summary and the new chain-management UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3e037b4d42
commit
23f4ee5b1e
5 files changed
+1725
-732
No files matched your search
+57
-2
@@ -1217,8 +1217,63 @@
|
||||
"anthropic": "Anthropic (Claude)",
|
||||
"local": "Lokales Modell",
|
||||
"admin-openai": "Admin-OpenAI"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Fallback-Kette",
|
||||
"description": "Wenn der primäre Provider ausfällt, läuft HealthLog die Kette der Reihe nach durch. Reihenfolge per Pfeile ändern, Schalter deaktiviert einen Eintrag ohne ihn zu entfernen.",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten",
|
||||
"removeFromChain": "Aus Kette entfernen",
|
||||
"addProvider": "Provider hinzufügen",
|
||||
"addNoneAvailable": "Alle Provider sind bereits in der Kette",
|
||||
"saveOrder": "Reihenfolge speichern",
|
||||
"saved": "Kette gespeichert",
|
||||
"saveFailed": "Speichern der Kette fehlgeschlagen",
|
||||
"resetDefaults": "Zurücksetzen",
|
||||
"resetConfirmTitle": "Kette auf Standard zurücksetzen?",
|
||||
"resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten."
|
||||
},
|
||||
"activeProviderHeading": "Aktiver KI-Provider",
|
||||
"activeProviderBody": "Wähle den Provider, der zuerst genutzt wird. Das Formular darunter konfiguriert ausschließlich den ausgewählten Provider; die Fallback-Kette unten regelt, was passiert, wenn er ausfällt.",
|
||||
"activeProviderLabel": "Primärer Provider",
|
||||
"providerConfigTitle": "Provider-Konfiguration",
|
||||
"providerSelect": {
|
||||
"codex": "ChatGPT-Account (Codex)",
|
||||
"openai": "OpenAI (eigener API-Key)",
|
||||
"anthropic": "Anthropic (Claude)",
|
||||
"local": "Lokales Modell (OpenAI-kompatibel)",
|
||||
"admin-openai": "Admin-OpenAI"
|
||||
},
|
||||
"openai": {
|
||||
"modelSelect": "Modell",
|
||||
"modelOptionCustom": "Eigener Slug…",
|
||||
"modelCustomLabel": "Eigener Modell-Slug",
|
||||
"modelCustomPlaceholder": "gpt-5",
|
||||
"baseUrlLabel": "Base URL (erweitert)",
|
||||
"baseUrlPlaceholder": "https://api.openai.com/v1",
|
||||
"baseUrlHelp": "Nur überschreiben, wenn ein OpenAI-kompatibles Gateway genutzt wird. Für OpenAI selbst leer lassen.",
|
||||
"showAdvanced": "Erweitert anzeigen",
|
||||
"hideAdvanced": "Erweitert ausblenden",
|
||||
"apiKey": "API-Key",
|
||||
"apiKeyPlaceholder": "sk-…"
|
||||
},
|
||||
"codex": {
|
||||
"statusConnected": "Verbunden",
|
||||
"statusDisconnected": "Nicht verbunden",
|
||||
"statusExpired": "Verbindung abgelaufen",
|
||||
"connectButton": "Mit ChatGPT verbinden",
|
||||
"disconnectButton": "Trennen",
|
||||
"modelSlugLabel": "Modell-Slug",
|
||||
"modelSlugBody": "Codex nutzt das Modell, auf das dein ChatGPT-Abo routet. Die Umgebungsvariable CODEX_MODEL kann das instanzweit überschreiben.",
|
||||
"lastInsight": "Letzte Analyse: {when}"
|
||||
},
|
||||
"adminOpenai": {
|
||||
"title": "Admin-OpenAI",
|
||||
"body": "Vom Betreiber bereitgestellter OpenAI-Key. Wird als letzter Fallback genutzt, wenn kein persönlicher Provider konfiguriert ist. Hier ist nichts einzustellen — die Anzeige bedeutet, dass der Betreiber ihn eingerichtet hat.",
|
||||
"notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert."
|
||||
},
|
||||
"testProvider": "Aktiven Provider testen",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test fehlgeschlagen: {message}"
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
|
||||
|
||||
+57
-2
@@ -1217,8 +1217,63 @@
|
||||
"anthropic": "Anthropic (Claude)",
|
||||
"local": "Local model",
|
||||
"admin-openai": "Admin OpenAI"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Fallback chain",
|
||||
"description": "If the primary provider fails, HealthLog walks the chain in order. Drag the rows to reorder, toggle the switch to disable a provider without removing it.",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"removeFromChain": "Remove from chain",
|
||||
"addProvider": "Add provider",
|
||||
"addNoneAvailable": "All providers already in chain",
|
||||
"saveOrder": "Save chain order",
|
||||
"saved": "Chain saved",
|
||||
"saveFailed": "Saving the chain failed",
|
||||
"resetDefaults": "Reset to defaults",
|
||||
"resetConfirmTitle": "Reset chain to defaults?",
|
||||
"resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched."
|
||||
},
|
||||
"activeProviderHeading": "Active AI provider",
|
||||
"activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.",
|
||||
"activeProviderLabel": "Primary provider",
|
||||
"providerConfigTitle": "Provider configuration",
|
||||
"providerSelect": {
|
||||
"codex": "ChatGPT account (Codex)",
|
||||
"openai": "OpenAI (your API key)",
|
||||
"anthropic": "Anthropic (Claude)",
|
||||
"local": "Local model (OpenAI-compatible)",
|
||||
"admin-openai": "Admin-provided OpenAI"
|
||||
},
|
||||
"openai": {
|
||||
"modelSelect": "Model",
|
||||
"modelOptionCustom": "Custom slug…",
|
||||
"modelCustomLabel": "Custom model slug",
|
||||
"modelCustomPlaceholder": "gpt-5",
|
||||
"baseUrlLabel": "Base URL (advanced)",
|
||||
"baseUrlPlaceholder": "https://api.openai.com/v1",
|
||||
"baseUrlHelp": "Override only when using an OpenAI-compatible gateway. Leave blank for OpenAI itself.",
|
||||
"showAdvanced": "Show advanced",
|
||||
"hideAdvanced": "Hide advanced",
|
||||
"apiKey": "API key",
|
||||
"apiKeyPlaceholder": "sk-…"
|
||||
},
|
||||
"codex": {
|
||||
"statusConnected": "Connected",
|
||||
"statusDisconnected": "Not connected",
|
||||
"statusExpired": "Connection expired",
|
||||
"connectButton": "Connect with ChatGPT",
|
||||
"disconnectButton": "Disconnect",
|
||||
"modelSlugLabel": "Model slug",
|
||||
"modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.",
|
||||
"lastInsight": "Last insight: {when}"
|
||||
},
|
||||
"adminOpenai": {
|
||||
"title": "Admin OpenAI",
|
||||
"body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.",
|
||||
"notConfigured": "The operator has not configured a shared OpenAI key on this instance."
|
||||
},
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}"
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* v1.4.16 phase B2 — AI provider settings UX (dropdown-driven).
|
||||
*
|
||||
* The new section replaces the v1.4.15-vintage top/bottom split (Marc's
|
||||
* `feedback_settings_no_split.md`): a single Select picks the active
|
||||
* provider; the form below is switch-rendered for that provider; the
|
||||
* fallback chain at the bottom reorders / toggles entries with arrow
|
||||
* controls (no new dependency — dnd-kit is not in package.json).
|
||||
*
|
||||
* Tests cover:
|
||||
* 1. Codex selected → Codex form (connect button) renders, OpenAI
|
||||
* form does NOT.
|
||||
* 2. OpenAI selected → API-key field + model select render, Codex
|
||||
* connect button does NOT.
|
||||
* 3. Fallback-chain rows surface in priority order with the right
|
||||
* i18n labels.
|
||||
*
|
||||
* Each case feeds the section a different `?provider=…` URL parameter
|
||||
* so the SSR render lands on the desired branch — the production
|
||||
* component reads the same parameter via `useSearchParams()`. This
|
||||
* keeps the test SSR-only (no jsdom dependency, no testing-library)
|
||||
* and matches the existing settings-section test style.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
let activeProviderQuery: string | null = null;
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/ai",
|
||||
useSearchParams: () =>
|
||||
new URLSearchParams(
|
||||
activeProviderQuery ? `provider=${activeProviderQuery}` : "",
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stable mocks for every TanStack Query the section reads. The chain
|
||||
// fixture mirrors the default order (codex first) so the rows render in
|
||||
// a known order; insights settings + ai-provider GET return enough to
|
||||
// keep the form branches content (status, hasOpenaiKey etc.).
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
|
||||
if (key === "insights/settings") {
|
||||
return {
|
||||
data: {
|
||||
codexStatus: "disconnected",
|
||||
codexConnectedAt: null,
|
||||
hasAdminKey: false,
|
||||
codexOauthConfigured: true,
|
||||
privacyMode: "aggregated",
|
||||
lastInsightAt: null,
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
if (key === "user/ai-provider") {
|
||||
return {
|
||||
data: {
|
||||
provider: null,
|
||||
model: null,
|
||||
baseUrl: null,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
if (key === "insights/provider-chain") {
|
||||
return {
|
||||
data: {
|
||||
activeProvider: "codex",
|
||||
cachedActiveProvider: null,
|
||||
configuredChain: [
|
||||
{ providerType: "codex", available: true },
|
||||
{ providerType: "openai", available: true },
|
||||
{ providerType: "anthropic", available: true },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
return { data: null, isLoading: false };
|
||||
},
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AiSection } from "../ai-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<AiSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
activeProviderQuery = null;
|
||||
});
|
||||
|
||||
describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
it("renders the active-provider Select with the configured options", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-active-provider-select"');
|
||||
// The four selectable options resolve via the new
|
||||
// `settings.ai.providerSelect.*` keys.
|
||||
expect(html).toContain("ChatGPT account (Codex)");
|
||||
expect(html).toContain("OpenAI (your API key)");
|
||||
expect(html).toContain("Anthropic (Claude)");
|
||||
expect(html).toContain("Local model (OpenAI-compatible)");
|
||||
});
|
||||
|
||||
it("Codex selected → Codex provider form, no OpenAI form", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
// Provider-config card surfaces.
|
||||
expect(html).toContain('data-testid="ai-provider-config-codex"');
|
||||
// OpenAI's API-key field is NOT rendered when Codex is active.
|
||||
expect(html).not.toContain('data-testid="ai-provider-config-openai"');
|
||||
// Connect-with-ChatGPT CTA is the focal action.
|
||||
expect(html).toContain("Connect with ChatGPT");
|
||||
});
|
||||
|
||||
it("OpenAI selected → API-key field + model select, no Codex form", () => {
|
||||
activeProviderQuery = "openai";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-provider-config-openai"');
|
||||
expect(html).not.toContain('data-testid="ai-provider-config-codex"');
|
||||
// OpenAI form surfaces the API-key input + model select.
|
||||
expect(html).toContain('data-testid="ai-openai-api-key"');
|
||||
expect(html).toContain('data-testid="ai-openai-model"');
|
||||
});
|
||||
|
||||
it("Anthropic selected → Anthropic API-key form", () => {
|
||||
activeProviderQuery = "anthropic";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-provider-config-anthropic"');
|
||||
expect(html).not.toContain('data-testid="ai-provider-config-openai"');
|
||||
});
|
||||
|
||||
it("Local selected → Local base-URL + key form", () => {
|
||||
activeProviderQuery = "local";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-provider-config-local"');
|
||||
expect(html).not.toContain('data-testid="ai-provider-config-codex"');
|
||||
});
|
||||
|
||||
it("renders the fallback-chain card with rows in priority order", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-fallback-chain"');
|
||||
// Each row carries a stable `data-chain-row="<providerType>"`
|
||||
// marker so the e2e suite can target without leaning on i18n
|
||||
// labels (which differ per locale).
|
||||
expect(html).toContain('data-chain-row="codex"');
|
||||
expect(html).toContain('data-chain-row="openai"');
|
||||
expect(html).toContain('data-chain-row="anthropic"');
|
||||
});
|
||||
|
||||
it("exposes a Test active provider button", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-test-active-provider"');
|
||||
expect(html).toContain("Test active provider");
|
||||
});
|
||||
|
||||
it("renders the German labels when locale=de", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render("de");
|
||||
expect(html).toContain("Mit ChatGPT verbinden");
|
||||
expect(html).toContain("ChatGPT-Account (Codex)");
|
||||
// Heading uses the locale's full string ("Aktiver KI-Provider"); a
|
||||
// shorter substring would also match the "Aktiver Provider" line
|
||||
// in the chain summary.
|
||||
expect(html).toContain("Aktiver KI-Provider");
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// Stub out next/navigation. The settings sections that read it (Account)
|
||||
// only call `useRouter().push()` from inside an effect after mount, so a
|
||||
// no-op stub is enough for SSR smoke rendering.
|
||||
// no-op stub is enough for SSR smoke rendering. v1.4.16 phase B2:
|
||||
// `<AiSection>` reads `?provider=…` via `useSearchParams()` to pick the
|
||||
// dropdown branch, so the mock has to expose that hook too.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/account",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — every section reads from it but we only need the
|
||||
|
||||
+1413
-727
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user