mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/main' into ready/pr-1131
This commit is contained in:
commit
fbd639d9f1
87 files changed
+5627
-772
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>>
|
||||
|
||||
Reference in new issue
Block a user