Files
famapp/src/components/assistant-settings.tsx
T

235 lines
7.0 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import {
resetAssistantName,
resetAssistantSystemPrompt,
setAssistantEnabled,
setAssistantName,
setAssistantModelRoute,
setAssistantSystemPrompt,
} from "@/app/settings/assistant-actions";
import { DEFAULT_ASSISTANT_NAME, MAX_ASSISTANT_NAME_LENGTH } from "@/lib/assistant-config";
import type { AssistantModelRoute } from "@/lib/llm/models";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
type Props = {
enabled: boolean;
name: string;
modelRoute: string | null;
systemPrompt: string | null;
defaultSystemPrompt: string;
};
function normalizeAssistantModelRoute(value: string | null): AssistantModelRoute {
return value === "uncensored" ? "uncensored" : "auto";
}
export function AssistantSettings({
enabled,
name,
modelRoute,
systemPrompt,
defaultSystemPrompt,
}: Props) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const effectivePrompt = systemPrompt ?? defaultSystemPrompt;
const [selectedRoute, setSelectedRoute] = useState<AssistantModelRoute>(() =>
normalizeAssistantModelRoute(modelRoute),
);
const [savedName, setSavedName] = useState(name);
const [draftName, setDraftName] = useState(name);
const [savedPrompt, setSavedPrompt] = useState(systemPrompt);
const [draftPrompt, setDraftPrompt] = useState(effectivePrompt);
const nameDirty = draftName.trim() !== savedName;
const promptDirty =
savedPrompt === null
? draftPrompt.trim() !== defaultSystemPrompt.trim()
: draftPrompt.trim() !== savedPrompt.trim();
const usingDefaultPrompt = savedPrompt === null;
function toggle(next: boolean) {
startTransition(async () => {
await setAssistantEnabled(next);
router.refresh();
});
}
function changeModelRoute(nextRoute: string) {
const route = normalizeAssistantModelRoute(nextRoute);
setSelectedRoute(route);
startTransition(async () => {
await setAssistantModelRoute(route);
router.refresh();
});
}
function saveName() {
const next = draftName.trim();
if (!next) return;
startTransition(async () => {
await setAssistantName(next);
setSavedName(next);
setDraftName(next);
router.refresh();
});
}
function savePrompt() {
const next = draftPrompt.trim();
if (!next) return;
startTransition(async () => {
const isDefault = next === defaultSystemPrompt.trim();
await setAssistantSystemPrompt(isDefault ? null : next);
setSavedPrompt(isDefault ? null : next);
setDraftPrompt(next);
router.refresh();
});
}
function handleResetName() {
startTransition(async () => {
await resetAssistantName();
setSavedName(DEFAULT_ASSISTANT_NAME);
setDraftName(DEFAULT_ASSISTANT_NAME);
router.refresh();
});
}
function handleResetPrompt() {
startTransition(async () => {
await resetAssistantSystemPrompt();
setSavedPrompt(null);
setDraftPrompt(defaultSystemPrompt);
router.refresh();
});
}
return (
<div className="flex flex-col gap-5">
<label className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">AI assistant</div>
<p className="muted text-[12px] mt-0.5">
Off by default. Turn on to show a chat bubble in the bottom-right corner.
</p>
</div>
<Switch
checked={enabled}
disabled={isPending}
onCheckedChange={toggle}
aria-label="AI assistant"
/>
</label>
<div className="flex flex-col gap-2">
<div className="min-w-0">
<label htmlFor="assistant-model-route" className="text-sm font-medium">
Model route
</label>
<p className="muted text-[12px] mt-0.5">
Choose the default router family for your assistant. Individual models can still be
picked from the chat panel.
</p>
</div>
<select
id="assistant-model-route"
aria-label="Assistant model route"
value={selectedRoute}
disabled={isPending}
onChange={(event) => changeModelRoute(event.target.value)}
className="input h-10"
>
<option value="auto">Auto</option>
<option value="uncensored">Uncensored</option>
</select>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<label htmlFor="assistant-name" className="text-sm font-medium">
Assistant name
</label>
<p className="muted text-[12px] mt-0.5">Shown in the chat bubble and message labels.</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || savedName === DEFAULT_ASSISTANT_NAME}
onClick={handleResetName}
>
Reset
</Button>
</div>
<Input
id="assistant-name"
value={draftName}
maxLength={MAX_ASSISTANT_NAME_LENGTH}
disabled={isPending}
onChange={(event) => setDraftName(event.target.value)}
aria-label="Assistant name"
/>
<Button
type="button"
size="sm"
disabled={!nameDirty || isPending || !draftName.trim()}
onClick={saveName}
>
{isPending ? "Saving…" : "Save name"}
</Button>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<label htmlFor="assistant-system-prompt" className="text-sm font-medium">
System prompt
</label>
<p className="muted text-[12px] mt-0.5">
Instructions sent to the model before each conversation. Customize tone, priorities,
or household context.
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || usingDefaultPrompt}
onClick={handleResetPrompt}
>
Reset
</Button>
</div>
<textarea
id="assistant-system-prompt"
value={draftPrompt}
rows={10}
disabled={isPending}
onChange={(event) => setDraftPrompt(event.target.value)}
aria-label="Assistant system prompt"
className="input min-h-[180px] resize-y font-mono text-[12px] leading-relaxed"
/>
<Button
type="button"
size="sm"
disabled={!promptDirty || isPending || !draftPrompt.trim()}
onClick={savePrompt}
>
{isPending ? "Saving…" : "Save prompt"}
</Button>
</div>
</div>
);
}