Luminaria / frontend /components /persona-editor.tsx
senlinyy's picture
feat: completed initial dev
4b81334
Raw
History Blame Contribute Delete
9.27 kB
"use client";
import { useEffect, useState, useRef } from "react";
import { Plus, Trash2, RotateCcw, Save, ChevronDown, ChevronUp, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { type Persona } from "@/lib/personas";
function uid() {
return Math.random().toString(36).slice(2, 10);
}
// ── Confirm dialog ────────────────────────────────────────────────────────────
function ConfirmDialog({
trigger,
title,
description,
confirmLabel = "Confirm",
destructive = false,
onConfirm,
}: {
trigger: React.ReactNode;
title: string;
description: string;
confirmLabel?: string;
destructive?: boolean;
onConfirm: () => void;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant={destructive ? "destructive" : "default"}
size="sm"
onClick={() => {
setOpen(false);
onConfirm();
}}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── Per-persona card ──────────────────────────────────────────────────────────
type SaveState = "idle" | "editing" | "saving" | "saved" | "error";
function PersonaCard({
persona,
onSave,
onDelete,
}: {
persona: Persona;
onSave: (p: Persona) => Promise<void>;
onDelete: () => void;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<Persona>(persona);
const [state, setState] = useState<SaveState>("idle");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Sync when parent updates (e.g. after reset)
const prevId = useRef(persona.id);
useEffect(() => {
if (persona.id !== prevId.current || state === "idle") {
setDraft(persona);
prevId.current = persona.id;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [persona]);
const isDirty = JSON.stringify(draft) !== JSON.stringify(persona);
function change(field: keyof Persona, value: string) {
setDraft((d) => ({ ...d, [field]: value }));
setState("editing");
}
async function save() {
setState("saving");
setErrorMsg(null);
try {
await onSave(draft);
setState("saved");
setTimeout(() => setState("idle"), 2500);
} catch (e) {
setErrorMsg((e as Error).message);
setState("error");
}
}
return (
<div className="rounded-lg border bg-card">
{/* Header row */}
<div className="flex items-center gap-2 px-4 py-3">
<button
type="button"
className="flex flex-1 items-center justify-between text-left text-sm font-medium"
onClick={() => setOpen((o) => !o)}
>
<span className={draft.name ? "" : "italic text-muted-foreground"}>
{draft.name || "Unnamed"}
</span>
{open ? (
<ChevronUp className="h-4 w-4 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 shrink-0" />
)}
</button>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
title="Delete persona"
description={`Remove "${draft.name || "this persona"}" permanently? Students will no longer see it.`}
confirmLabel="Delete"
destructive
onConfirm={onDelete}
/>
</div>
{/* Expandable fields */}
{open && (
<div className="space-y-3 border-t px-4 py-4">
<div className="space-y-1">
<Label>Name</Label>
<Input
value={draft.name}
onChange={(e) => change("name", e.target.value)}
placeholder="e.g. Socratic Tutor"
/>
</div>
<div className="space-y-1">
<Label>Short description</Label>
<Input
value={draft.description}
onChange={(e) => change("description", e.target.value)}
placeholder="One sentence shown in the dropdown"
/>
</div>
<div className="space-y-1">
<Label>System prompt</Label>
<textarea
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={draft.prompt}
onChange={(e) => change("prompt", e.target.value)}
placeholder="Instructions appended to the system message…"
/>
</div>
{errorMsg && <p className="text-xs text-destructive">{errorMsg}</p>}
<Button
size="sm"
className="w-full"
onClick={save}
disabled={!isDirty || state === "saving"}
>
<Save className="mr-1 h-3.5 w-3.5" />
{state === "saving"
? "Saving…"
: state === "saved"
? "Saved!"
: isDirty
? "Save changes"
: "No changes"}
</Button>
</div>
)}
</div>
);
}
// ── Main editor ───────────────────────────────────────────────────────────────
export function PersonaEditor() {
const [personas, setPersonas] = useState<Persona[]>([]);
async function load() {
try {
const r = await fetch("/api/personas");
const d = await r.json();
setPersonas(d.personas ?? []);
} catch {}
}
useEffect(() => { load(); }, []);
// Save a single updated persona
async function saveOne(updated: Persona) {
const next = personas.map((p) => (p.id === updated.id ? updated : p));
const r = await fetch("/api/personas", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
});
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
setPersonas(data.personas);
}
function remove(id: string) {
const next = personas.filter((p) => p.id !== id);
fetch("/api/personas", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
})
.then((r) => r.json())
.then((d) => setPersonas(d.personas ?? next))
.catch(() => setPersonas(next));
}
function addNew() {
setPersonas((prev) => [
...prev,
{ id: uid(), name: "", description: "", prompt: "" },
]);
}
async function reset() {
const r = await fetch("/api/personas/reset", { method: "POST" });
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
setPersonas(data.personas);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Personas</h2>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" type="button">
<RotateCcw className="mr-1 h-3.5 w-3.5" /> Reset to defaults
</Button>
}
title="Reset personas?"
description="All custom personas will be replaced with the bundled defaults. This cannot be undone."
confirmLabel="Reset"
destructive
onConfirm={reset}
/>
</div>
<p className="text-xs text-muted-foreground">
Each card saves individually. Changes are written to{" "}
<code className="rounded bg-muted px-1">personas.json</code> and synced
to your HF Dataset.
</p>
<div className="space-y-2">
{personas.map((p) => (
<PersonaCard
key={p.id}
persona={p}
onSave={saveOne}
onDelete={() => remove(p.id)}
/>
))}
</div>
<Button
variant="outline"
size="sm"
type="button"
className="w-full"
onClick={addNew}
>
<Plus className="mr-1 h-4 w-4" /> Add persona
</Button>
</div>
);
}