Spaces:
Sleeping
Sleeping
| "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> | |
| ); | |
| } | |