235 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|