Files
Epicure/apps/web/components/admin/admin-default-model-form.tsx
Arnaud 623e5bcd34 feat: chatbot model setting + tool-calling toggle, simplify admin AI config (v0.59.0)
Chatbot (general assistant + per-recipe Q&A) now resolves its default model
from its own site setting (DEFAULT_CHAT_PROVIDER/MODEL) instead of sharing
the generic "text" use case with recipe generation, so admins can point it
at a different model.

Added AI_TOOL_CALLING_ENABLED: turns off the chatbot's createRecipe/
addToShoppingList tools (and the forced-retry pass) for models that don't
reliably support tool calling — it falls back to plain text answers.

Simplified the admin AI Configuration page: it showed provider keys and
routing settings twice (a read-only card, then an identical edit form).
Merged into one editable section; the resolved fallback provider is now a
one-line note instead of its own card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 21:14:04 +02:00

222 lines
8.3 KiB
TypeScript

"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
type Provider = "openai" | "anthropic" | "openrouter" | "ollama" | "";
type UseCase = {
key: "text" | "chat" | "vision" | "mealPlan";
providerSetting: "DEFAULT_TEXT_PROVIDER" | "DEFAULT_CHAT_PROVIDER" | "DEFAULT_VISION_PROVIDER" | "DEFAULT_MEAL_PLAN_PROVIDER";
modelSetting: "DEFAULT_TEXT_MODEL" | "DEFAULT_CHAT_MODEL" | "DEFAULT_VISION_MODEL" | "DEFAULT_MEAL_PLAN_MODEL";
label: string;
description: string;
};
const USE_CASES: UseCase[] = [
{
key: "text",
providerSetting: "DEFAULT_TEXT_PROVIDER",
modelSetting: "DEFAULT_TEXT_MODEL",
label: "Text generation",
description: "Recipe generation, substitutions, pairings, and other text-based AI features.",
},
{
key: "chat",
providerSetting: "DEFAULT_CHAT_PROVIDER",
modelSetting: "DEFAULT_CHAT_MODEL",
label: "Chatbot",
description: "The general cooking assistant and per-recipe Q&A chat.",
},
{
key: "vision",
providerSetting: "DEFAULT_VISION_PROVIDER",
modelSetting: "DEFAULT_VISION_MODEL",
label: "Vision (photo import/scan)",
description: "Importing a recipe from a photo and pantry barcode/photo scanning.",
},
{
key: "mealPlan",
providerSetting: "DEFAULT_MEAL_PLAN_PROVIDER",
modelSetting: "DEFAULT_MEAL_PLAN_MODEL",
label: "Meal plan generation",
description: "Generating a full week's meal plan.",
},
];
const PRESET_MODELS: Record<string, { label: string; models: { value: string; label: string }[] }> = {
openai: {
label: "OpenAI",
models: [
{ value: "gpt-4o", label: "GPT-4o" },
{ value: "gpt-4o-mini", label: "GPT-4o mini (faster)" },
{ value: "o3-mini", label: "o3-mini (reasoning)" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
],
},
anthropic: {
label: "Anthropic",
models: [
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ value: "claude-opus-4-8", label: "Claude Opus 4.8 (most capable)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (fastest)" },
],
},
};
type Values = Partial<Record<UseCase["providerSetting"] | UseCase["modelSetting"], string | null>>;
export function AdminDefaultModelForm({
initialValues,
initialToolCallingEnabled,
}: {
initialValues: Values;
initialToolCallingEnabled: boolean;
}) {
const [values, setValues] = useState<Values>(initialValues);
const [toolCallingEnabled, setToolCallingEnabled] = useState(initialToolCallingEnabled);
const [saving, setSaving] = useState(false);
function setField(key: UseCase["providerSetting"] | UseCase["modelSetting"], value: string | null) {
setValues((prev) => ({ ...prev, [key]: value || null }));
}
async function handleSave() {
setSaving(true);
try {
const res = await fetch("/api/v1/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...values, AI_TOOL_CALLING_ENABLED: toolCallingEnabled ? null : "false" }),
});
if (!res.ok) throw new Error("Save failed");
toast.success("Default AI providers saved");
} catch {
toast.error("Failed to save defaults");
} finally {
setSaving(false);
}
}
return (
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Default Models</h2>
<p className="text-sm text-muted-foreground mt-1">
What to use for a given feature when a user hasn&apos;t set their own model preference and has no personal API key.
Leave a provider unset to fall back to whichever provider key is configured above.
</p>
</div>
<div className="space-y-3">
{USE_CASES.map(({ key, providerSetting, modelSetting, label, description }) => {
const provider = (values[providerSetting] ?? "") as Provider;
const model = (values[modelSetting] ?? "") as string;
const presets = provider && PRESET_MODELS[provider]?.models;
return (
<div key={key} className="rounded-lg border p-4 space-y-3">
<div>
<p className="font-medium text-sm">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Provider</Label>
<Select
value={provider || "default"}
onValueChange={(v) => {
const val = v === "default" ? null : v;
setField(providerSetting, val);
setField(modelSetting, null);
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
<span className="text-muted-foreground">Auto (first configured key)</span>
</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openrouter">OpenRouter</SelectItem>
<SelectItem value="ollama">Ollama (local)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Model</Label>
{presets ? (
<Select
value={model || "default"}
onValueChange={(v) => setField(modelSetting, v === "default" ? null : v)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Provider default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
<span className="text-muted-foreground">Provider default</span>
</SelectItem>
<SelectGroup>
<SelectLabel>{PRESET_MODELS[provider]?.label}</SelectLabel>
{presets.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-sm"
placeholder={provider ? "Model name" : "—"}
value={model}
onChange={(e) => setField(modelSetting, e.target.value)}
disabled={!provider}
/>
)}
</div>
</div>
{key === "chat" && (
<div className="flex items-center justify-between border-t pt-3">
<div>
<Label className="text-xs">Tool calling</Label>
<p className="text-xs text-muted-foreground">
Lets the chatbot draft a recipe or shopping list inline. Turn off for a model that can&apos;t reliably call tools (e.g. some local/Ollama models) it&apos;ll fall back to plain text answers.
</p>
</div>
<Switch
checked={toolCallingEnabled}
onCheckedChange={setToolCallingEnabled}
className="shrink-0"
/>
</div>
)}
</div>
);
})}
</div>
<Button onClick={() => { void handleSave(); }} disabled={saving} size="sm">
{saving ? "Saving…" : "Save"}
</Button>
</section>
);
}