Merge remote-tracking branch 'origin/main' into ready/pr-1131

This commit is contained in:
Marc-André Bombeck committed 2026-10-04 14:08:27 +02:00
commit fbd639d9f1
87 files changed
+5627 -772

No files matched your search

+12
View File
@@ -464,6 +464,18 @@ function catalogueLabel(
if (catalogue === "symptomEpisodeReference") {
return t("admin.section.backups.restoreSkippedSymptomEpisodeReference");
}
if (catalogue === "visitCiphertext") {
return t("admin.section.backups.restoreSkippedVisitCiphertext");
}
if (catalogue === "vaccinationCiphertext") {
return t("admin.section.backups.restoreSkippedVaccinationCiphertext");
}
if (catalogue === "moodLabelCiphertext") {
return t("admin.section.backups.restoreSkippedMoodLabelCiphertext");
}
if (catalogue === "cycleCiphertext") {
return t("admin.section.backups.restoreSkippedCycleCiphertext");
}
if (catalogue === "medicationTarget") {
return t("admin.section.backups.restoreSkippedMedicationTarget");
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import en from "../../../../../messages/en.json";
import { CompatProviderForm } from "../compat-provider-form";
import { LocalProviderForm } from "../local-provider-form";
import type { UserAIProvider } from "../shared";
/**
* #1126 — the Reasoning select on the Local and OpenAI-compatible forms. It
* sits right under the model field, offers exactly Default / Off / Low /
* Medium / High, and seeds from the value the server stored on the entry.
* Saving it is pinned end to end in `e2e/ai-provider-dropdown.spec.ts`.
*/
function provider(overrides: Partial<UserAIProvider>): UserAIProvider {
return {
provider: "LOCAL",
model: "llama3:8b",
baseUrl: "http://ollama.example.org:11434/v1",
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: "https://gateway.example.org/v1",
compatModel: "qwen3",
hasCompatKey: false,
responseTimeoutSeconds: null,
...overrides,
};
}
function render(node: React.ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
/** The `<select>` element with the given id, as markup. */
function selectMarkup(html: string, id: string): string {
const match = new RegExp(`<select[^>]*\\bid="${id}"`).exec(html);
expect(match, `no select #${id}`).not.toBeNull();
const start = match!.index;
return html.slice(start, html.indexOf("</select>", start));
}
const OPTION_VALUES = ["", "none", "low", "medium", "high"];
describe.each([
{
name: "Local",
id: "ai-local-reasoning",
after: "ai-local-model",
form: (p: UserAIProvider) => <LocalProviderForm userProvider={p} />,
stored: (v: UserAIProvider["localReasoningEffort"]) => ({
localReasoningEffort: v,
}),
},
{
name: "OpenAI-compatible",
id: "ai-compat-reasoning",
after: "ai-compat-model",
form: (p: UserAIProvider) => <CompatProviderForm userProvider={p} />,
stored: (v: UserAIProvider["compatReasoningEffort"]) => ({
compatReasoningEffort: v,
provider: "OPENAI_COMPATIBLE",
}),
},
])("$name form", ({ id, after, form, stored }) => {
it("offers Default, Off, Low, Medium and High under the model field", () => {
const html = render(form(provider({})));
const select = selectMarkup(html, id);
expect(
[...select.matchAll(/<option value="([^"]*)"/g)].map((m) => m[1]),
).toEqual(OPTION_VALUES);
const r = en.settings.ai.reasoning;
for (const label of Object.values(r.options)) {
expect(select).toContain(`>${label}</option>`);
}
expect(html).toContain(`<label`);
expect(html).toContain(`for="${id}"`);
expect(html).toContain(r.label);
expect(html).toContain(r.hint);
expect(html.indexOf(`id="${id}"`)).toBeGreaterThan(
html.indexOf(`id="${after}"`),
);
});
it("seeds Default when nothing is stored, or a payload predates the field", () => {
const select = selectMarkup(render(form(provider({}))), id);
expect(select).toMatch(/<option value="" selected="">/);
});
it("seeds the stored value", () => {
const select = selectMarkup(render(form(provider(stored("none")))), id);
expect(select).toMatch(/<option value="none" selected="">/);
});
});
@@ -19,7 +19,12 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared";
import { ReasoningEffortField } from "./reasoning-effort-field";
import {
uiToLegacyProviderEnum,
type ReasoningEffort,
type UserAIProvider,
} from "./shared";
export function CompatProviderForm({
userProvider,
@@ -32,6 +37,7 @@ export function CompatProviderForm({
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [reasoning, setReasoning] = useState<ReasoningEffort | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
@@ -40,13 +46,14 @@ export function CompatProviderForm({
// render-time pattern the sibling forms use (no setState-in-effect).
const seededKey =
userProvider != null
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}`
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}|${userProvider.compatReasoningEffort ?? ""}`
: null;
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
if (seededKey && seededKey !== previousSeed) {
setPreviousSeed(seededKey);
setBaseUrl(userProvider?.compatBaseUrl ?? "");
setModel(userProvider?.compatModel ?? "");
setReasoning(userProvider?.compatReasoningEffort ?? null);
}
const saveMutation = useMutation({
@@ -55,6 +62,7 @@ export function CompatProviderForm({
provider: uiToLegacyProviderEnum("openai-compatible"),
compatBaseUrl: baseUrl.trim() || null,
compatModel: model.trim() || null,
compatReasoningEffort: reasoning,
};
if (apiKey.trim()) body.compatKey = apiKey.trim();
await apiPatch("/api/user/ai-provider", body);
@@ -130,6 +138,11 @@ export function CompatProviderForm({
{t("settings.ai.compat.modelHint")}
</p>
</div>
<ReasoningEffortField
id="ai-compat-reasoning"
value={reasoning}
onChange={setReasoning}
/>
<div>
<Button
@@ -17,10 +17,12 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { ReasoningEffortField } from "./reasoning-effort-field";
import {
CUSTOM_MODEL_SENTINEL,
LOCAL_MODEL_PRESETS,
uiToLegacyProviderEnum,
type ReasoningEffort,
type UserAIProvider,
} from "./shared";
@@ -36,17 +38,20 @@ export function LocalProviderForm({
const [apiKey, setApiKey] = useState("");
const [modelChoice, setModelChoice] = useState<string>("");
const [customModel, setCustomModel] = useState("");
const [reasoning, setReasoning] = useState<ReasoningEffort | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
const seededKey =
userProvider != null
? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}|${userProvider.localReasoningEffort ?? ""}`
: null;
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
if (seededKey && seededKey !== previousSeed) {
setPreviousSeed(seededKey);
// Stored on the chain entry, so it seeds whichever provider is selected.
setReasoning(userProvider?.localReasoningEffort ?? null);
if (userProvider?.provider === "LOCAL") {
setBaseUrl(userProvider.baseUrl ?? "");
const saved = userProvider.model ?? "";
@@ -71,6 +76,7 @@ export function LocalProviderForm({
provider: uiToLegacyProviderEnum("local"),
baseUrl: baseUrl.trim() || null,
model: effectiveModel || null,
localReasoningEffort: reasoning,
};
if (apiKey.trim()) body.localKey = apiKey.trim();
await apiPatch("/api/user/ai-provider", body);
@@ -168,6 +174,11 @@ export function LocalProviderForm({
/>
</div>
)}
<ReasoningEffortField
id="ai-local-reasoning"
value={reasoning}
onChange={setReasoning}
/>
<div>
<Button
@@ -0,0 +1,52 @@
"use client";
/* ────────────────────────────────────────────────────────────────
* Reasoning select (#1126) for the Local and OpenAI-compatible forms.
* Same Label + NativeSelect + hint layout as the model field above it.
* The empty value is Default: nothing is sent and the model decides.
* ──────────────────────────────────────────────────────────────── */
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
import type { ReasoningEffort } from "./shared";
export function ReasoningEffortField({
id,
value,
onChange,
}: {
id: string;
value: ReasoningEffort | null;
onChange: (next: ReasoningEffort | null) => void;
}) {
const { t } = useTranslations();
return (
<div>
<Label htmlFor={id}>{t("settings.ai.reasoning.label")}</Label>
<NativeSelect
id={id}
value={value ?? ""}
onChange={(e) =>
onChange(
e.target.value === "" ? null : (e.target.value as ReasoningEffort),
)
}
className="mt-1"
aria-describedby={`${id}-hint`}
>
<option value="">{t("settings.ai.reasoning.options.default")}</option>
<option value="none">{t("settings.ai.reasoning.options.none")}</option>
<option value="low">{t("settings.ai.reasoning.options.low")}</option>
<option value="medium">
{t("settings.ai.reasoning.options.medium")}
</option>
<option value="high">{t("settings.ai.reasoning.options.high")}</option>
</NativeSelect>
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.reasoning.hint")}
</p>
</div>
);
}
+9
View File
@@ -5,6 +5,8 @@
* this module under `src/components/settings/ai/`.
*/
import type { ReasoningEffort } from "@/lib/ai/reasoning-effort";
export interface InsightsSettings {
codexStatus: string;
codexConnectedAt: string | null;
@@ -40,8 +42,15 @@ export interface UserAIProvider {
hasCompatKey: boolean;
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: number | null;
// #1126 — reasoning setting of the Local and gateway entries (null =
// Default). Absent on a payload from a server older than the field.
localReasoningEffort?: ReasoningEffort | null;
compatReasoningEffort?: ReasoningEffort | null;
}
/** #1126 — the `reasoning_effort` values; "Off" is `none`. */
export type { ReasoningEffort };
/**
* v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
* `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to
@@ -116,7 +116,7 @@ export function ManualWorkoutForm({
// a submit after an edit updates the row the first submit stored.
const [externalId] = useState(newManualWorkoutExternalId);
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
emptyManualWorkoutDraft(new Date(), timezone),
emptyManualWorkoutDraft(),
);
const [errors, setErrors] = useState<
Partial<Record<ManualWorkoutField, string>>